diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c22d6e0 --- /dev/null +++ b/.env.example @@ -0,0 +1,190 @@ +# ===================================================================== +# Neo Agent v3.1.0 — Local Development .env.example +# ===================================================================== +# 用法: +# cp .env.example .env +# vim .env # 填入实际 API 密钥与个性化参数 +# +# 注意:.env 已在 .gitignore 中,不会被提交。 +# ===================================================================== + +# --------------------------------------------------------------------- +# 一、LLM 通用配置(v3.1.0+ 推荐使用) +# --------------------------------------------------------------------- +# 供应商预设:openai / deepseek / siliconflow / moonshot / zhipu / custom +LLM_PROVIDER=siliconflow +# API Key(推荐用 LLM_API_KEY,兼容旧 SILICONFLOW_API_KEY) +LLM_API_KEY= +# 自定义 base_url(preset 模式可省略,custom 模式必填) +# LLM_BASE_URL=https://api.openai.com/v1 +# 自定义模型名(preset 模式可省略) +# LLM_MODEL_NAME=gpt-4o-mini +# 全局超时/温度/最大 tokens +LLM_TIMEOUT=60 +LLM_TEMPERATURE=0.7 +LLM_MAX_TOKENS=2000 +# 三层模型独立名称(MAIN/TOOL/VISION) +# MAIN_MODEL_NAME=deepseek-ai/DeepSeek-V3.2 +# TOOL_MODEL_NAME=zai-org/GLM-4.6V +# VISION_MODEL_NAME=Qwen/Qwen3-VL-32B-Instruct + +# --------------------------------------------------------------------- +# 一·A、兼容旧版 LLM 变量(v3.0.x 风格,推荐迁移到上方 LLM 通用配置) +# --------------------------------------------------------------------- +# (兼容旧版,推荐迁移到上方 LLM 通用配置) +# SiliconFlow API 密钥 +# 申请:https://cloud.siliconflow.cn/account/ak +SILICONFLOW_API_KEY=your-siliconflow-api-key +SILICONFLOW_API_URL=https://api.siliconflow.cn/v1/chat/completions + +# SerpAPI 密钥(用于网络搜索工具) +# 申请:https://serpapi.com/manage-api-key +SERPAPI_API_KEY=your-serpapi-key + +# --------------------------------------------------------------------- +# 二、多层模型架构 +# --------------------------------------------------------------------- + +# 主模型:处理主要对话和复杂推理任务 +MAIN_MODEL_NAME=deepseek-ai/DeepSeek-V3.2 +MAIN_MODEL_TEMPERATURE=0.8 +MAIN_MODEL_MAX_TOKENS=2000 +# MAIN_MODEL_TIMEOUT=60 # API请求超时时间(秒),默认60秒 + +# 小模型:处理工具调用、意图识别等轻量级任务 +TOOL_MODEL_NAME=zai-org/GLM-4.6V +TOOL_MODEL_TEMPERATURE=0.3 +TOOL_MODEL_MAX_TOKENS=500 +# TOOL_MODEL_TIMEOUT=45 # API请求超时时间(秒),默认45秒 + +# 多模态模型:处理视觉识别和多模态推理 +VISION_MODEL_NAME=Qwen/Qwen3-VL-32B-Instruct +VISION_MODEL_TEMPERATURE=0.5 +VISION_MODEL_MAX_TOKENS=1000 +# VISION_MODEL_TIMEOUT=90 # API请求超时时间(秒),默认90秒 + +# 兼容旧配置(如果未设置上述新配置,将使用此默认值) +MODEL_NAME=deepseek-ai/DeepSeek-V3 +TEMPERATURE=0.8 +MAX_TOKENS=2000 + +# 视觉工具LLM判断配置(可选) +# VISION_LLM_TEMPERATURE=0.3 +# VISION_LLM_MAX_TOKENS=10 +# VISION_LLM_TIMEOUT=10 + +# --------------------------------------------------------------------- +# 三、提示词配置(使用 markdown 模板系统) +# --------------------------------------------------------------------- + +# 角色提示词文件名(不含 .md 后缀),默认使用 default_character +# CHARACTER_PROMPT_FILE=default_character +# 世界观文件名(不含 .md 后缀),默认使用 default_world +# WORLDVIEW_FILE=default_world + +# --------------------------------------------------------------------- +# 四、多智能体协作配置 +# --------------------------------------------------------------------- + +# 是否启用基于 LangGraph 的动态协作图(默认 True) +# USE_DYNAMIC_MULTI_AGENT=True + +# --------------------------------------------------------------------- +# 五、角色 / 人物设定 +# --------------------------------------------------------------------- + +CHARACTER_NAME=小可 +CHARACTER_GENDER=女 +CHARACTER_ROLE=学生 +CHARACTER_HEIGHT=150cm +CHARACTER_WEIGHT=45kg +CHARACTER_PERSONALITY=活泼开朗 +CHARACTER_HOBBY=文科,尤其对历史充满热情 +CHARACTER_AGE=18 + +CHARACTER_BACKGROUND=我是一名高中生,叫小可。我身高150cm,体重45kg。我性格活泼开朗,特别喜欢文科,尤其对历史充满热情。我喜欢阅读历史书籍,常常能从历史故事中获得启发。我说话比较俏皮可爱,喜欢和朋友们分享有趣的历史知识。 + +# --------------------------------------------------------------------- +# 六、记忆 / 持久化 +# --------------------------------------------------------------------- + +MEMORY_FILE=memory_data.json +LONG_MEMORY_FILE=longmemory_data.json +MAX_MEMORY_MESSAGES=50 +MAX_SHORT_TERM_ROUNDS=20 + +# SQLite 共享数据库 +# 默认 chat_agent.db 在项目根目录 +# CHAT_AGENT_DB=/abs/path/to/chat_agent.db + +# --------------------------------------------------------------------- +# 七、调试 / 日志 +# --------------------------------------------------------------------- + +DEBUG_MODE=True +DEBUG_LOG_FILE=debug.log + +# Web 后端日志级别(DEBUG/INFO/WARNING/ERROR) +LOG_LEVEL=INFO + +# --------------------------------------------------------------------- +# 八、NPS 工具系统 LLM 配置(可选) +# --------------------------------------------------------------------- + +# NPS_LLM_TEMPERATURE=0.3 +# NPS_LLM_MAX_TOKENS=100 +# NPS_LLM_TIMEOUT=10 + +# --------------------------------------------------------------------- +# 九、DeepAgents 增强配置 +# --------------------------------------------------------------------- + +# 是否使用 DeepAgents 增强的子智能体(默认 True) +# 读取位置:src/core/multi_agent_coordinator.py +# USE_DEEP_AGENTS=True + +# 是否启用持久化状态管理(默认 True) +# 读取位置:src/core/dynamic_multi_agent_graph.py +# ENABLE_PERSISTENT_STATE=True +# 注:USE_DEEPAGENTS_KNOWLEDGE 已弃用(v2.2.0 删除 DeepAgentsKnowledgeManager) + +# --------------------------------------------------------------------- +# 十、Feature Flags(功能开关,按需启用) +# --------------------------------------------------------------------- + +# 启用生活状态管理(life_state 模块) +# ENABLE_LIFE_STATE=true + +# 启用主动消息引擎 +# ENABLE_PROACTIVE_ENGINE=true + +# 启用情感雷达(Plutchik 八情绪) +# ENABLE_EMOTION_WHEEL=true + +# 启用创意写作 / 长篇项目 +# ENABLE_CREATIVE_WRITER=true + +# --------------------------------------------------------------------- +# 十一、Web GUI 配置(v3.0.0 新增) +# --------------------------------------------------------------------- + +# 启用前端 Vite dev server(与 FastAPI 8000 端口并行启动 :5173) +# 生产部署设为 0;本地开发设为 1 +# ENABLE_FRONTEND_DEV=1 + +# FastAPI 监听地址(生产建议 127.0.0.1,由 nginx 反代;本地开发 0.0.0.0) +# WEB_HOST=0.0.0.0 + +# FastAPI 监听端口 +# WEB_PORT=8000 + +# CORS 允许来源(开发态默认 localhost:5173 + localhost:8000) +# 多个用逗号分隔;生产部署改为实际反代域名 +# CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://127.0.0.1:5173,http://127.0.0.1:8000 + +# --------------------------------------------------------------------- +# 十二、彩蛋(可选) +# --------------------------------------------------------------------- + +# 关闭 ANSI 颜色(CI / 非 TTY 环境) +# NO_COLOR=1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bfbcb48..44e55e8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,7 +9,7 @@ Neo Agent is an intelligent conversation agent system based on LangChain, suppor - **Language**: Python 3.8+ - **Main Framework**: LangChain (langchain, langchain-community, langchain-core) - **Database**: SQLite for persistent storage -- **GUI**: Tkinter for graphical user interface +- **GUI**: Web GUI (FastAPI + React + Vite) - **API**: SiliconFlow API for LLM integration - **Configuration**: python-dotenv for environment management @@ -25,8 +25,6 @@ Neo Agent is an intelligent conversation agent system based on LangChain, suppor - `event_manager.py` - Event-driven system for notifications and tasks - `multi_agent_coordinator.py` - Multi-agent collaboration system - `agent_vision.py` - Vision tool with LLM-based environment perception -- `gui_enhanced.py` - Main GUI application -- `database_gui.py` - Database management GUI - `base_knowledge.py` - Immutable core knowledge management ### Supporting Modules @@ -108,10 +106,10 @@ Neo Agent is an intelligent conversation agent system based on LangChain, suppor ### GUI Development -- Use Tkinter conventions consistent with existing code -- Maintain the modern, user-friendly interface style -- Support real-time updates and visual feedback -- Include proper error dialogs in Chinese +- Web GUI is built with React + TypeScript + Vite + Ant Design +- Backend API is FastAPI; WebSocket endpoints provide real-time updates +- Keep frontend state management predictable and debuggable +- Provide user-friendly error feedback in Chinese ### Event System diff --git a/.gitignore b/.gitignore index 8390631..4c7ba40 100644 --- a/.gitignore +++ b/.gitignore @@ -193,6 +193,15 @@ Thumbs.db thinking.md mermaid.md +# 测试生成产物(settings_migration 等测试自动生成) +test_export.json +full_test_export.json +selective_export.json +test_import.env +test_imported.env +*.env.backup.* +test_*.db + # CodeGraph SQLite 索引(不在仓库内共享;每个开发者本地 init) .codegraph/ diff --git a/AGENTS.md b/AGENTS.md index d5a0828..95090c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,12 +33,9 @@ Matt Pocock 默认 5 类状态机标签。详见 [[docs/agents/triage-labels]] ## 快速命令 ```bash -# 启动 GUI(需要 Tkinter) +# 启动 Web / API python main.py -# 启动方式 2(推荐用 run.py——自动加项目根到 sys.path) -python run.py - # 运行测试 pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f8cd5..a4e5982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,348 @@ All notable changes to this project will be documented in this file. -本文件记录项目的所有重要变更。 +本文件记录项目的所有重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范。 + +## [Unreleased] + +### ⚠️ BREAKING +- **移除 Tkinter GUI**:删除 `src/gui/` 目录、`run.py`、`--tk` 启动参数及 `ENABLE_WEB_GUI=false` 回滚开关 +- `python main.py` 现在固定启动 Web / API 服务(等价于原 `main.py --web`) +- 删除 `docs/rollback-procedure.md` 与 Tkinter 相关测试;保留并迁移 WAL 并发安全测试到 `tests/integration/` + +### 🔧 修改 +- `main.py` 移除 `--tk` / `--web` 互斥参数,仅保留 `--port` / `--host` / `--no-dev` / `--no-static` / `--reload` +- `run_web.py` 的依赖缺失提示不再指向 `--tk` +- 文档(README、setup-local-dev、architecture、TECHNICAL、CONTEXT、AGENTS、.env.example)全面清理 Tkinter 引用 + +## v4.0.0 (2026-07-17) + +### 🧠 重构 +- **基于人脑认知理论的神经系统架构**:新增 `src/nervous_system/` 统一数据路由与外部访问网关 +- **大脑皮层(Cortex)**:`src/cortex/` 承载 LLM 推理、生成、理解能力统一入口(`llm_core`、`echo_cortex`、供应商注册表) +- **边缘系统(Limbic)**:`src/limbic/` 承载记忆、情感、会话(`hippocampus`、`amygdala`) +- **前额叶(Prefrontal)**:`src/prefrontal/` 承载日程、事件、主动决策(`schedule`、`event`、`proactive`) +- **小脑(Cerebellum)**:`src/cerebellum/` 承载工具调用、视觉、反射(`nps`、`vision`、`intent`、`interrupt`、`style`) +- **下丘脑(Hypothalamus)**:`src/hypothalamus/` 承载生命状态、用户习惯(`state`、`habits`) +- **统一网关**:HTTP Gateway、WebSocket Gateway、LLM Gateway 均通过 `CentralRouter + Packet` 进行可追踪路由 + +### 🔧 修改 +- `src/core/*` 原模块保留为 v3.1.0 兼容层,通过导入转发访问新架构实现,**完全向后兼容** +- Web 后端(`src/web/backend/main.py`)集成 `NeoApp`,新增 `/api/v4/gateway/{target}/{channel}` 通用网关路由 +- `DatabaseManager` 对 `:memory:` 数据库复用单一连接,解决测试中空库问题 + +### ✅ 测试 +- 全量测试通过:`227 passed, 27 skipped, 0 failed, 0 error` +- 新增/修复 `tests/unit/*` 各层模块测试、`tests/integration/test_web_neo_bridge.py`、特性开关隔离测试 + +### ⚠️ BREAKING +- 无破坏性变更;所有新特性默认关闭,可通过 `.env` 的 `ENABLE_*` 开关按需启用 + +## v3.1.0 (2026-XX-XX) + +### ✨ 新增 +- **会话持久化**:聊天会话与消息存储到数据库(`chat_sessions` / `chat_messages`),支持多会话切换、刷新页面自动恢复、删除级联 +- **前端 SessionSidebar**:左侧抽屉列出会话,支持新建/重命名/删除/切换 +- **多供应商 LLM 抽象**:内置 6 个 OpenAI 兼容 preset(openai / deepseek / siliconflow / moonshot / zhipu / custom),通过 `LLM_PROVIDER` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL_NAME` 配置 +- **LLM 配置 API**:`GET /api/llm/config` + `POST /api/llm/test` +- **Settings 页面**:显示当前 LLM 配置 + 一键测试连接 + +### 🔧 修改 +- 11 个文件中 19 处 `SILICONFLOW_API_KEY` / `SILICONFLOW_API_URL` 调用统一改为 `llm_providers.resolve_*()` 接口 +- 旧 `SILICONFLOW_API_KEY` / `SILICONFLOW_API_URL` / `MODEL_NAME` 仍可作为 fallback,**完全向后兼容** +- `model_config.py` 改用供应商注册表 +- `useChatStream` 增 `sessionId` / `switchSession` / `ack` 帧处理 +- `ChatService` 增持久化钩子(流结束后双写 user+assistant) + +### 🗃️ 数据库 +- 新增表 `chat_sessions` / `chat_messages`(外键 ON DELETE CASCADE) + +### ⚠️ BREAKING +- 数据库 schema 变更:首次启动自动建表(v3.0.0 启动仅 1 天,存量可丢弃) + +## [3.0.0] - 2026-07-14 + +### Added / 新增 +- **Web GUI**(FastAPI + React + Ant Design):替代本地 Tkinter 桌面 GUI(默认启动模式) +- **WebSocket 流式聊天**(`/ws/chat`):基于 LangChain astream 的 token 级流式输出,降级到 `run_in_executor` 模拟流式 +- **WebSocket 主动消息推送**(`/ws/proactive`):服务端主动向客户端推送 +- **WebSocket 事件流**(`/ws/events`):来自 `EventManager` 的全局事件广播 +- **Debug 日志流**(`/ws/debug`):来自 `DebugLogger` 的实时日志 +- **管理页面**:Knowledge / Schedule / Event / NPS / Database / Creative 六个管理类页签 +- **Pydantic schema 强约束 + SQLite WAL 模式**: + - `src/web/backend/schemas/*` 全部基于 Pydantic v2 + - `src/core/database_manager.py` 启用 `PRAGMA journal_mode=WAL` +- 新增文件: + - `src/web/` — 后端(FastAPI)+ 前端(Vite + React)源码 + - `requirements-web.txt` — Web 端依赖 + - `run_web.py` — Web 模式启动入口 + - `start.sh` — 一键启动脚本 + +### Changed / 变更 +- `EventManager` 新增 `subscribe` / `publish` API(`src/core/event_manager.py`) + - `subscribe(event_type, callback)` 注册进程内监听者 + - `publish(event_type_or_obj, payload)` 异步广播,listener 异常隔离 +- `BackgroundScheduler` 每个 tick 推送状态(`src/core/background_scheduler.py`) + - 每个 tick 完成后 `EventManager.publish("scheduler_tick", status_payload)` 推送给订阅者 +- `ProactiveEngine` 主动消息通过 `EventManager` 推送(`src/core/proactive_engine.py`) + - 触发主动消息时改为 `em.publish("proactive_message", payload)` +- `ChatAgent` 新增 `chat_stream()` 异步流式方法(原 `chat()` 同步方法保留,向后兼容) +- `main.py` 新增 `--web`(默认)/ `--tk` 参数 + +### Deprecated / 弃用 +- 无 + +### Removed / 移除 +- 无 + +### Fixed / 修正 +- 无 + +### Security / 安全 +- **23 表白名单防御 SQL 注入**(`src/core/database_manager.py`) + - 多个 `update_*` 方法使用白名单验证允许更新的列名(line 1964, 2100, 2301 等),过滤只允许白名单中的列名 + - 业务层禁止拼接 SQL 列名 / 表名 + +### Preserved / 保留 +- 全部 `src/core/*` 业务层逻辑**未修改**,仅扩展方法(`chat_stream` / WAL / `subscribe`+`publish`) +- 数据存储(`chat_agent.db`)**未迁移**,Tkinter 端与 Web 端共享 +- 原 Tkinter GUI 通过 `python main.py --tk` 仍可使用 + +### Documentation / 文档 +- 新增 [`docs/architecture.md`](docs/architecture.md):整体架构图、ASCII 数据流、模块依赖、并行调度 +- 新增 [`docs/rollback-procedure.md`](docs/rollback-procedure.md):5 分钟回滚到 Tkinter 步骤 + 5 条 FAQ +- 更新 [`README.md`](README.md):增加 Web GUI 启动方式、访问 URL、`/docs`、Tkinter 回滚说明 +- 新增 [`.trae/specs/migrate-tkinter-to-web/verification-report.md`](../.trae/specs/migrate-tkinter-to-web/verification-report.md):5 阶段总体验收报告(326 行,含 P0/P1/P2 清单) + +### 验收状态 / Verification Status (2026-07-14) +- **验收方法**:5 个并行子代理 + 静态代码分析 + 测试桩验证 +- **整体判定**:**NO-GO(条件式)** — 后端可发,前端 6 页面 mock→真接口未完成 +- **数据汇总**:✅ 69 / ⚠️ 21 / ❌ 5(73%) +- **跨阶段核心约束全部达成**:数据零迁移、业务层零改动、Feature flag 兼容、依赖最小化、回滚 ≤ 5 分钟、CHANGELOG 齐全 + +#### 本次完成(具体改动) + +**阶段 A — 基础设施(18/20 ✅)** +- 新建 `src/web/backend/main.py`:FastAPI 应用,CORS / 路由挂载 / `/api/health` / `/docs` +- 新建 `src/web/backend/ws/manager.py`:连接注册/注销/广播,清理机制在 `finally` 块 +- 新建 `src/web/backend/ws/{chat,events,proactive,debug}.py`:4 个 WebSocket 端点 +- 新建 `src/web/backend/services/{chat,database,event}_service.py`:三层包装,ChatAgent 懒加载,`ALLOWED_TABLES` 23 表白名单 +- 新建 `src/web/backend/schemas/*.py`:Pydantic v2 DTO(ChatRequest/ChatResponse/EventDTO/EmotionResponse/TimelineResponse/KnowledgeEntity/ScheduleDTO 等) +- 新建 `src/web/frontend/`:Vite + React 18 + TypeScript + Ant Design 5 + Tailwind 3 + zustand + ECharts 项目骨架 +- 新建 `src/web/frontend/src/components/Layout.tsx`:Sider + Header + Content 8 菜单 +- 新建 `requirements-web.txt`:5 行独立依赖(fastapi/uvicorn/websockets/python-multipart/pydantic) +- 新建 `run_web.py`:并发启动 FastAPI + 前端 dev server(受 `ENABLE_FRONTEND_DEV=1` 控制) +- 改造 `main.py`:新增 `--web`(默认)/ `--tk` 互斥参数,`_run_web` / `_run_tkinter` 双分支 +- 新建 `start.sh`:Python 3.10+ 检查 + venv 创建 + 依赖安装 + 一键启动 + +**阶段 B — 核心页面(13/17 ✅)** +- 新建 `src/core/chat_agent.py:1049-1177` `chat_stream()`:AsyncIterator,先 LangChain astream,降级到 `run_in_executor(chat)` + 40ms token 切片,原 `chat()` 同步方法保留 +- 新建 `src/web/frontend/src/hooks/useWebSocket.ts`:指数退避 `1→2→4→8→16→30s`,`computeBackoff = min(reconnectInterval * 2**attempt, maxReconnectInterval)` +- 新建 `src/web/frontend/src/hooks/useChatStream.ts`:`chunk` 流式追加 + `emotion_update` 写入 `window.__lastEmotion` +- 新建 `src/web/frontend/src/hooks/useProactiveStream.ts`:订阅 `/ws/proactive` 维护消息队列 +- 新建 `src/web/frontend/src/components/ChatBubble.tsx`:user/assistant/system 三种样式 +- 新建 `src/web/frontend/src/components/MessageList.tsx` / `ChatInput.tsx`:Enter 发、Shift+Enter 换行 +- 新建 `src/web/frontend/src/components/EmotionPanel.tsx`:ECharts 雷达图,Plutchik 8 维 +- 新建 `src/web/frontend/src/components/TimelineCanvas.tsx`:ECharts 散点图,日期 × 分类 +- 新建 `src/web/frontend/src/pages/Chat/index.tsx`:70/30 双栏,ProactiveMessageList + MessageList + ChatInput + EmotionPanel + TimelineCanvas +- 新建 `src/web/frontend/src/pages/Debug/index.tsx` + `components/Debug/{LogFilterBar,LogExporter}.tsx`:实时日志 + 模块/级别/时间筛选 + TXT/JSON 导出 +- 新建 `src/web/backend/api/emotion.py` `GET /api/emotion/latest`:返回 `cumulative` + `plutchik` +- 新建 `src/web/backend/api/memory.py` `GET /api/memory/timeline?days=7` + `GET /api/memory/loop/{uuid}/context` +- 新建 `src/web/frontend/src/utils/proactiveSound.ts`:Web Audio API 合成 440Hz 提示音,localStorage 持久化开关 +- 新建 `tests/test_chat_stream.py`:覆盖 normal / exception / cancel 三种场景 + +**阶段 C — 管理页面(后端 4/6,前端 5/6 部分通过)** +- 新建 `src/web/backend/api/knowledge.py`:`GET /api/knowledge/search?q=` + `POST /api/knowledge` + `DELETE /api/knowledge/{uuid}` +- 新建 `src/web/backend/api/schedule.py`:`GET /api/schedule?date=` + `POST/PUT/DELETE /api/schedule/{id}` + `POST /api/schedule/{id}/confirm`(YAML 校验 400) +- 新建 `src/web/backend/api/database.py`:`GET /api/database/tables` + `GET /api/database/{table}?limit=1-1000` + `DELETE /api/database/{table}/{uuid}`,删除走 `X-Confirm: true` header +- 新建 `src/web/backend/api/creative.py`:`GET /api/creative/projects` + `GET /api/creative/projects/{uuid}` + `POST /api/creative/projects/{uuid}/advance` +- 新建 `src/web/frontend/src/pages/{Knowledge,Schedule,Event,Database,Creative,NPS}/index.tsx`:6 管理页面 UI 框架 +- 新建 `src/web/frontend/src/components/{KnowledgeGraph,ScheduleForm,EventTable,DataTable,StoryBibleViewer,NPSConfigDrawer}.tsx`:16 组件 UI 完整 +- `src/web/backend/services/database_service.py`:23 表白名单 + WAL 模式双层防御(`PRAGMA journal_mode=WAL`) + +**阶段 D — 调度整合(14/15 ✅)** +- 改造 `src/core/event_manager.py`:新增 `subscribe` / `publish` / `get_event_manager` API +- 改造 `src/core/background_scheduler.py:194`:每个 tick `em.publish('scheduler_tick', status_payload)`,payload 含 `life_state_snapshot` / `dream_record` / `open_loop_count` / `creative_project_count` +- 改造 `src/core/proactive_engine.py:329`:`bot_proactive_drive_send()` 改走 `em.publish('proactive_message', payload)` +- 改造 `src/web/backend/main.py:79-93`:FastAPI 启动时创建单例 EventService,`threading.Lock` 双重检查 +- 新建 `src/web/backend/services/event_service.py`:`register_websocket_listener` / `unregister_websocket_listener`,订阅 `scheduler_tick` / `proactive_message` / `chat_event` / `debug_log` / `message` / 通配 `*` +- 改造 `src/tools/debug_logger.py:92-113`:新增 `subscribe` / `unsubscribe` 钩子,`set_main_loop` 支持跨线程调度 +- 新建 `src/web/backend/ws/debug_broadcaster.py`:DebugLogger subscriber + EventService 'debug_log' listener 双挂,`main.py:200-219` 启动 attach,`:232-238` shutdown detach +- 改造 `src/web/backend/ws/proactive.py:55`:读取 `PROACTIVE_MESSAGE_INTERVAL` 环境变量,`_should_throttle()` 进程内节流 + +**阶段 E — 文档与回滚(11/19 ✅)** +- 新建 `tests/e2e/test_chat_flow.py`(5 方法)+ `test_knowledge_crud.py`(6)+ `test_proactive_push.py`(5)+ `test_websocket_reconnect.py`(9)= **25 个 E2E 测试**,全部 `@unittest.skipUnless(_DEPS_OK, _SKIP_REASON)` + `self.skipTest` 优雅降级 +- 新建 `tests/perf/test_load_bench.py`:首屏字节 < 500KB / 首 token < 2s / WS ping-pong < 5s / 1000 条遍历 / 10 并发 WS +- 新建 `tests/rollback/test_rollback_procedure.py`:`TestRunTkinterMode` + `TestEnableWebGuiFlag` + `TestDbConsistency` +- 更新 `README.md:158-209` GUI 启动方式 + `:394-422` Web GUI 速查 +- 新建 `docs/architecture.md`:ASCII 总体图 / WebSocket 数据流 / 模块依赖 / 并行调度 / mermaid 总览 +- 新建 `docs/rollback-procedure.md`:4 步 5 分钟回滚 + 验证清单 + 5 条 FAQ + 切回 Web +- 本 `CHANGELOG.md` v3.0.0 条目(本节) +- `src/web/backend/main.py:21-29` `app = FastAPI(title="Neo Agent Web", version="3.0.0")` → `/docs` 自动 OpenAPI + +#### P0 必修(阻塞正式发布) + +- **前端 6 页面 mock→真接口**:`pages/{Knowledge,Schedule,Event,Database,Creative,NPS}/index.tsx` `useEffect` 改调 API,删 `TODO(后端联调)` 注释;Event 页删 `setInterval(5000ms)` 改订阅 `/ws/events` +- **新建 REST 路由**: + - `src/web/backend/api/nps.py` — `GET /api/nps` + `POST /api/nps/register` + `POST /api/nps/invoke` + - `src/web/backend/api/event.py` — `GET /api/events` + `POST /api/events/{event_id}/read` + `POST /api/events/{event_id}/archive` +- **`main.py` 显式 `ENABLE_WEB_GUI` 校验**:在 `_run_web` 开头检查 `os.environ.get("ENABLE_WEB_GUI", "true").lower() == "false"` 拒绝启动 + +#### P1 强烈建议(发布前修) + +- [pages/Chat/index.tsx](../Neo_Agent/src/web/frontend/src/pages/Chat/index.tsx) 侧栏真挂载 EmotionPanel + TimelineCanvas(目前占位) +- [TimelineCanvas.tsx](../Neo_Agent/src/web/frontend/src/components/TimelineCanvas.tsx) 节点 click 接 `/api/memory/loop/{uuid}/context` +- [pages/Event/index.tsx](../Neo_Agent/src/web/frontend/src/pages/Event/index.tsx) 订阅 `scheduler_tick` 渲染"当日状态/梦境/日记/想法池"卡 +- [MessageList.tsx](../Neo_Agent/src/web/frontend/src/components/MessageList.tsx) 接 `react-window` 真虚拟滚动 +- `ws/__init__.py` 聚合或删除 `/ws/event` 单数端点 +- `ScheduleDTO` / `EventDTO` 字段名对齐实际表(`schedule_id` / `collaboration_status` / `event_id`) +- `api/creative.py` 续写路径统一(`/api/creative/advance/{uuid}` vs `/api/creative/projects/{uuid}/advance`) +- ChatInput 加 Ctrl/Cmd+Enter 快捷键 +- Layout 加 `< 768px` 移动端降级提示 + +#### P2 后续优化(发布后 1-2 周) + +- Lighthouse 真机录制首屏 `< 3s`、FPS `≥ 50` +- 浏览器矩阵:Chrome / Edge / Firefox / Safari 最新 2 版本 +- TypeScript 类型自动生成(`openapi-typescript`) +- `Settings` 页面 Web 端实现(对照 Tkinter `src/gui/settings_migration_gui.py` 530 行) +- 团队 React + AntD 培训纪要(≥1 次) +- UAT ≥ 3 个真实用户场景报告 + +## [2.3.0] - 2026-05-15 + +> **重大重构:Neo_Agent 拟人化能力与架构分层全面落地** +> +> 目标:参考 [astrbot_plugin_private_companion](https://github.com/) 拟人化设计 + 本地 [ARCHITECTURE_REFACTOR_PLAN.md](../ARCHITECTURE_REFACTOR_PLAN.md) + [INVESTIGATION_HUMANIZATION_CREATIVE.md](../INVESTIGATION_HUMANIZATION_CREATIVE.md),对 Neo_Agent 进行"分层架构 + 拟人化能力 + 数据扩展 + 工程规约"四位一体的全量重构。本次重构**不引入 Web GUI**(留待 v3.0.0),**不破坏任何既有方法**(纯扩展),**不强制启用新功能**(Feature flag 默认关闭)。 + +### 架构分层(Achitecture Layering) + +#### Added / 新增 — 纵向四层架构 +- `src/core/` — 核心业务层(LLM/记忆/知识/情感/创作/调度) +- `src/tools/` — 工具层(debug logger / 杂项 utilities) +- `src/gui/` — Tkinter 桌面 GUI 层 +- `src/nps/` — Neo Plugin System 插件层 +- 所有新模块以**独立文件**形式加入 `src/core/`,不复用 Mixin(规避 70K+ 单类膨胀),统一复用 `LLMHelper` / `PromptManager` / `DatabaseManager` + +### 拟人化能力(Humanization Capabilities) + +#### Added / 新增 — 六大核心抽象 +- **生活状态机** [`src/core/life_state.py`](src/core/life_state.py) + - 状态枚举:WAKE / WORK / REST / SLEEP / DREAM / SOCIAL / REFLECT + - 状态转移规则 + 日快照表 `life_state_daily_snapshot` + - 通过 `ENABLE_LIFE_STATE` Feature flag 控制 +- **Plutchik 8 维情绪模型** [`src/core/emotion_analyzer.py`](src/core/emotion_analyzer.py) + - 8 种基本情绪:joy / trust / fear / surprise / sadness / disgust / anger / anticipation + - 与现有"印象累加评分"并行注入 prompt + - 通过 `ENABLE_EMOTION_WHEEL` Feature flag 控制 +- **长期创作系统** [`src/core/creative_writer.py`](src/core/creative_writer.py) + - 立项 → 续写 → 完结 全生命周期 + - 质量审核:similarity ≤ 0.72、retry ≤ 2、min_score ≥ 7 + - 数据表:`creative_projects` / `creative_story_bibles` / `creative_memory_pool`(统一命名) + - 触发:聊天不活跃 30 分钟后自动开始一轮续写 +- **主动决策引擎** [`src/core/proactive_engine.py`](src/core/proactive_engine.py) + - 想法池(idea pool)+ 触发条件评估 + 主动消息投递 + - 通过 `ENABLE_PROACTIVE_ENGINE` Feature flag 控制 +- **未完话题跟踪** [`src/core/long_term_memory.py`](src/core/long_term_memory.py) + - `OpenLoopTracker` 自动识别用户未说完的话题并打 tag + - 数据表 `open_loops` 持久化(raised_at / closed_at / topic / entities) +- **梦境记录 + 自我时间线 + 用户习惯** + - [`src/core/dream_diary.py`](src/core/dream_diary.py) — 梦境生成与日记 + - [`src/core/self_timeline.py`](src/core/self_timeline.py) — 自我时间线聚合 + - [`src/core/user_habits.py`](src/core/user_habits.py) — 用户习惯画像 + +### 提示词工程(Prompt Engineering) + +#### Added / 新增 — 模块化提示词 +- [`src/core/prompt_manager.py`](src/core/prompt_manager.py) — `PromptManager` 统一管理四类模板: + - `character` — 角色人设 + - `system` — 系统级约束 + - `task` — 任务级指令 + - `worldview` — 世界观注入 +- 支持 markdown 模板 + Jinja2 占位符 + 热重载 +- **改造** `chat_agent.py` / `emotion_analyzer.py` / `multi_agent_coordinator.py` 改走 `PromptManager` 注入 +- 角色扮演与世界观参数化(`worldview` 字段) + +### 数据持久化(Database Expansion) + +#### Added / 新增 — 8 张拟人化数据表 +- `life_state_daily_snapshot` — 生活状态日快照(状态/时长/触发原因) +- `dream_records` — 梦境记录(主题/情绪/象征物) +- `diary_entries` — 日记条目(每日反思) +- `open_loops` — 未完话题(uuid/raised_at/closed_at/topic/entities) +- `creative_projects` — 创作项目(uuid/title/status/quality_score) +- `creative_story_bibles` — 创作圣经(角色/世界观/章节大纲) +- `creative_memory_pool` — 创作记忆池(续写上下文) +- `proactive_ideas` — 主动想法池 + +#### Security / 安全 +- **`src/core/database_manager.py` 新增 23 表白名单 + 多个 `update_*` 方法列名白名单** + - 业务层禁止拼接 SQL 列名 / 表名,所有动态列名经白名单校验 + - 防御 SQL 注入 + - 启用 `PRAGMA journal_mode=WAL` 支持并发读写 +- **新增** [`src/core/schedule_similarity_checker.py`](src/core/schedule_similarity_checker.py) — 日程查重,避免重复创建相同日程 + +### 事件驱动(Event-Driven) + +#### Changed / 变更 +- **改造** [`src/core/event_manager.py`](src/core/event_manager.py) — 引入通知型 vs 任务型事件分类 + - 通知型:notify_user / notify_admin + - 任务型:task_dispatch / task_complete + - listener 异常隔离,单 listener 失败不影响其他 + +### 调度与线程隔离(Scheduler Isolation) + +#### Added / 新增 +- [`src/core/background_scheduler.py`](src/core/background_scheduler.py) — 后台调度器 + - 独立 asyncio 子线程运行,**与 Tkinter 主循环物理隔离** + - 通过线程安全队列与 GUI 通信 + - 调度项:生活状态转移 / 梦境生成 / 创作续写 / 主动消息投递 / 话题时间衰减 + - **每个 tick 完成后通过 EventManager 发布 `scheduler_tick` 状态(payload 含 life_state_snapshot / dream_record / open_loop_count / creative_project_count)**(为 v3.0.0 Web 推送预埋接口) + +### 工程规约(Hard Constraints) + +#### Added / 新增 — 写入 `project_memory.md` 的硬约束 +- 所有新模块必须作为独立文件添加到 `src/core/`,**不通过 Mixin 扩展既有类** +- 复用 `LLMHelper` / `PromptManager` / `DatabaseManager` 三大基础设施 +- GUI 触发架构和后台调度器必须使用 asyncio 子线程**物理隔离** +- Feature flags(`ENABLE_LIFE_STATE` / `ENABLE_EMOTION_WHEEL` / `ENABLE_PROACTIVE_ENGINE` 等)**默认禁用**新功能,显式开启后才生效 +- 创意项目必须包含质量审核(similarity ≤ 0.72, retry ≤ 2, min_score ≥ 7) +- 新增数据表统一命名为 `creative_projects` / `creative_story_bibles` / `creative_memory_pool` +- 长期创作项目在聊天不活跃 **30 分钟**后触发 +- 情感分析统一使用 **Plutchik 8 维情绪模型** + +### 文档更新(Documentation) + +#### Added / 新增 +- 新增 [`docs/architecture.md`](docs/architecture.md) — 整体架构图、ASCII 数据流、模块依赖、并行调度、mermaid 总览 +- 新增 [`.trae/AGENTS.md`](../.trae/AGENTS.md) / [`.trae/CONTEXT.md`](../.trae/CONTEXT.md) — 智能体协作规约 +- 更新 [`README.md`](README.md) — 增加拟人化能力说明 + Feature flag 配置 +- 新增 [`docs/agents/`](../docs/agents/) — Hermes Agent skills 脚手架 +- 调研报告 [ARCHITECTURE_REFACTOR_PLAN.md](../ARCHITECTURE_REFACTOR_PLAN.md) / [INVESTIGATION_HUMANIZATION_CREATIVE.md](../INVESTIGATION_HUMANIZATION_CREATIVE.md) + +### Lessons Learned / 经验沉淀 +- **Mixin 模式导致单类膨胀(70K+ 行)**:后续新能力必须以独立文件 + 组合方式扩展,不再使用 Mixin +- **Plutchik 8 维比"印象评分"更适合雷达可视化**:为 v3.0.0 Web 端 EmotionPanel 雷达图铺路 +- **WAL 模式必开**:GUI 进程与后台调度进程并发读写 SQLite,WAL 是必选项 +- **Feature flag 默认关闭**:生产事故多为"新功能默认开启"导致 ## [2.2.0] - 2026-02-22 ### 功能移除 / Features Removed -- **多智能体协作系统**: 移除动态多智能体协作、DeepAgents集成等功能 -- **事件系统**: 移除事件驱动系统、事件管理等功能 -- **LangGraph集成**: 移除LangGraph相关功能和依赖 +- **ConversationGraph** (`src/core/conversation_graph.py`): 删除全仓零引用的 LangGraph 对话流程管理模块(真僵尸) +- **EnhancedKnowledgeBase** (`src/core/enhanced_knowledge_base.py`): 删除仅测试在用的增强知识库封装(与 `KnowledgeBase` 职责重叠约 80%) +- **DeepAgentsKnowledgeManager** (`src/core/deepagents_wrapper.py`): 删除与 `EnhancedKnowledgeBase` 配套的虚拟文件系统知识管理器(仅被已删除模块引用,属死代码;`DeepSubAgentWrapper` 保留) + +### 修正 / Corrected +- 澄清以下模块仍保留并被生产代码使用(CHANGELOG 早前版本曾错误地将其列为"已移除",现已修正): + - **多智能体协作 / 动态多智能体图** (`src/core/multi_agent_coordinator.py` / `dynamic_multi_agent_graph.py`): 仍由 `chat_agent.py:22` / `:239` 等处生产在用 + - **事件系统 / 事件管理** (`src/core/event_manager.py`): 仍由 `chat_agent.py:20` + `gui_enhanced.py` 20+ 处密集调用 + - **LangGraph 集成**: `dynamic_multi_agent_graph.py` 仍使用 LangGraph `StateGraph` / `MemorySaver`,仅 `conversation_graph.py` 为真僵尸并已删除 ### 文档更新 / Documentation Updates -- **修正README.md**,移除已不存在功能的描述 +- **修正 README.md**,移除已不存在功能的描述 - **优化文档结构**,提高可读性和维护性 +- **更正 `src/core/__init__.py` 文档字符串**:`ScheduleGenerator` → `TemporaryScheduleGenerator`(实际类名),并澄清 Usage 块中 import 行为为示例 ### 改进 / Improved - ✅ 项目结构更加简洁 diff --git a/CONTEXT.md b/CONTEXT.md index c31a464..f057d98 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -46,7 +46,7 @@ last_updated: 2026-06-19 ### GUI -Tkinter 实现(`src/gui/gui_enhanced.py`)。**注意**:需要系统安装 Tkinter。 +Web GUI(FastAPI + React + Vite),入口为 `python main.py`。 ### NPS @@ -56,7 +56,7 @@ Tkinter 实现(`src/gui/gui_enhanced.py`)。**注意**:需要系统安装 | 术语 | 含义 | |---|---| -| **GUI Enhanced** | 主界面类名(`EnhancedChatDebugGUI`) | +| **Web GUI** | 基于 FastAPI + React 的默认界面 | | **NPS** | Net Promoter Score 或本项目自定义子系统(看 `docs/NPS_*.md` 确认) | | **chinese-standardization** | 历史 PR 标签——把英文术语规范化到中文 | | **NPS_CONFIG_MANAGEMENT** | NPS 配置管理文档 | diff --git a/README.md b/README.md index 989575f..17bca04 100644 --- a/README.md +++ b/README.md @@ -8,106 +8,249 @@ ### 简介 -Neo Agent 是一个基于 LangChain + LangGraph 的智能对话代理系统,采用多层模型架构,具备角色扮演、长效记忆管理、情感关系分析和智能日程管理功能。 +Neo Agent 是一个基于 LangChain + LangGraph 的智能对话代理系统,采用类神经系统多层模型架构,具备角色扮演、长效记忆管理、情感关系分析、智能日程管理以及可观测的统一日志能力。 + +自 v4.1 起,项目统一了入口与日志体系:`run.py` 成为唯一主入口,`UnifiedLogger` 贯通前后端日志,前端 console 日志可通过 WebSocket 实时回流到后端集中存储与分析。 + +--- + +### 架构总览 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 入口层 │ +│ run.py (web / stop / status / logs) ← 统一 CLI 入口 │ +│ main.py ──────────────────────────────→ run.py 兼容层 │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────┐ +│ Web 服务层 │ +│ FastAPI + Uvicorn │ +│ ├── REST API (/api/*) │ +│ ├── WebSocket (/ws/chat, /ws/events, /ws/debug, │ +│ │ /ws/proactive, /ws/frontend-logs) │ +│ └── 静态前端 (React + Vite + Ant Design) │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────┐ +│ 神经系统 (Nervous System) │ +│ CentralRouter ── Packet ── Gateway │ +│ 跨模块工作流:对话 → 情感分析 → 日程创建 → 主动确认 │ +└─────────────────────────────────────────────────────────────┘ + │ +┌──────────┬──────────┬───────────────┬──────────┬────────────┐ +│ 大脑皮层 │ 边缘系统 │ 前额叶 │ 小脑 │ 下丘脑 │ +│ cortex/ │ limbic/ │ prefrontal/ │cerebellum│hypothalamus│ +│ LLMCore │ 海马体 │ 日程 / 事件 │ NPS 工具 │ 生命状态 │ +│ ModelRouter│ 杏仁核 │ 主动决策 │ 视觉 / 风格│ 用户习惯 │ +└──────────┴──────────┴───────────────┴──────────┴────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────┐ +│ 统一日志基础设施 │ +│ UnifiedLogger ← FileHandler / MemoryBufferHandler │ +│ ← WebSocketBroadcastHandler │ +│ ← ExternalForwarderHandler │ +│ DebugLogger(旧 API 兼容层) ← 委托 UnifiedLogger │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- ### 架构特性 #### 🏗️ 复合框架架构 -- **LangChain**: 核心框架,提供LLM抽象和链式调用 +- **LangChain / LangGraph**: 核心框架,提供 LLM 抽象、链式调用与 Agent 编排 - **多层模型架构**: 根据任务类型智能选择模型 - - 主模型 (DeepSeek-V3.2): 处理主要对话、复杂推理 - - 工具模型 (GLM-4.6V): 处理工具调用、意图识别等轻量级任务 - - 多模态模型 (Qwen3-VL-32B): 处理多模态识别和推理 + - 主模型:处理主要对话、复杂推理 + - 工具模型:处理工具调用、意图识别等轻量级任务 + - 多模态模型:处理多模态识别和推理 + +#### 🧠 类神经系统分层 +- **nervous_system**: 中央路由 `CentralRouter` + `Packet` 协议 + `Gateway` 网关,实现跨模块流式调用 +- **cortex**: 大脑皮层,LLM 推理核心(`LLMCore`、`ModelRouter`、流式响应) +- **limbic**: 边缘系统,海马体记忆 + 杏仁核情感 +- **prefrontal**: 前额叶,日程 / 事件 / 主动决策 +- **cerebellum**: 小脑,NPS 工具 / 视觉 / 表达风格 +- **hypothalamus**: 下丘脑,生命状态 / 用户习惯 + +#### 📜 统一日志系统(v4.1) +- **格式统一**: timestamp / level / module / message / source / trace_id / extra +- **前后端贯通**: 前端 `logger.ts` 拦截 `console.*`,通过 `/ws/frontend-logs` 实时上报 +- **多 Handler 输出**: 文件、内存缓冲、WebSocket 广播、外部转发可扩展 +- **向后兼容**: `DebugLogger` 保留原 API,内部委托 `UnifiedLogger` +- **进程管理**: `run.py stop` 基于 PID 文件 + 进程树清理,支持优雅停止与强制兜底 + +--- ### 主要特性 - 🧠 **分层记忆系统**: 短期记忆、长期记忆、知识库、基础知识 -- 💭 **智能对话**: 角色扮演、连续对话、记忆检索、情感理解 +- 💭 **智能对话**: 角色扮演、连续对话、记忆检索、情感理解、流式回复 - 📊 **情感分析**: 印象评估、累计评分、关系可视化 -- 🖥️ **现代化GUI**: 基于Tkinter的友好界面 -- ️ **数据管理**: SQLite存储、数据迁移、备份恢复 -- 📝 **提示词工程**: 模块化Markdown提示词、角色扮演、世界观注入 +- 📅 **日程管理**: 自然语言创建、冲突检测、提醒推送 +- 🛠️ **NPS 工具系统**: 可扩展工具注册、调用与编排 +- 🖥️ **现代化 Web GUI**: React + TypeScript + Vite + Ant Design +- 🌐 **Web 端独占能力**: + - **跨设备访问**:任意浏览器打开 `http://:8000` 即可使用 + - **实时 WebSocket 推送**:消息流、事件流、主动消息、调试日志、前端日志全双工推送 + - **远程访问**:部署到服务器后可通过公网访问,多用户可同时接入 + - **流式回复**:基于 LangChain `astream` 实时输出 token;不支持时自动降级 +- 🪵 **可观测日志**:前后端日志统一收集、查询、广播与持久化 +- 💾 **数据管理**: SQLite 存储、数据迁移、备份恢复 +- 📝 **提示词工程**: 模块化 Markdown 提示词、角色扮演、世界观注入 + +--- + +### 技术栈 + +- **后端**:FastAPI 0.110+ / Uvicorn / Pydantic v2 / WebSockets +- **前端**:React 18 + TypeScript + Vite 5 +- **UI 组件库**:Ant Design 5 +- **状态管理**:Zustand +- **图表**:ECharts 5 +- **HTTP 客户端**:Axios +- **业务核心**:LangChain + LangGraph +- **日志**:自研 `UnifiedLogger` + 多 Handler 架构 + +--- ### 快速开始 -#### 安装依赖 +#### 环境准备 ```bash -pip install -r requirements.txt +# Python >= 3.10 +python --version + +# 复制环境变量模板 +cp example.env .env +# 编辑 .env 填入 API 密钥等配置 ``` -#### 配置环境 +#### 一键启动(推荐) ```bash -cp example.env .env -# 编辑 .env 文件,填入你的API密钥和配置 +# 自动创建 venv、安装依赖、启动后端 + 前端 dev server +./start.sh ``` -#### 运行应用 +启动后访问 http://localhost:8000 + +#### 手动启动 ```bash -# 方式1: 使用主入口(推荐) -python main.py +pip install -r requirements.txt -r requirements-web.txt +cd src/web/frontend && npm install && cd - -# 方式2: 使用简化启动器(如果遇到导入问题) -python run.py +# 统一入口:默认启动 Web / API +python run.py web -# 方式3: 如果已安装包 -neo-agent +# 纯 API 模式(不启动前端 dev server、不挂载静态产物) +python run.py web --no-dev --no-static + +# 指定端口 +python run.py web --port 8080 --host 127.0.0.1 +``` + +#### 常用 CLI 命令 + +```bash +python run.py web # 启动 Web / API(默认) +python run.py stop # 停止正在运行的 Neo Agent 进程 +python run.py status # 查看运行状态 +python run.py logs # 查看 debug.log 尾部 +python run.py logs -f # 持续跟踪日志输出 ``` -**常见问题 / Troubleshooting:** -- 如果遇到导入错误,请确保在项目根目录运行 -- 确保已安装所有依赖: `pip install -r requirements.txt` -- Windows用户可能需要使用 `python` 而不是 `python3` +`main.py` 保留为兼容层,等效于 `python run.py web`。 + +--- + +### 访问 URL + +| 用途 | URL | +| --- | --- | +| Web 前端主页 | `http://localhost:8000` | +| 前端开发端口(Vite) | `http://localhost:5173` | +| 交互式 API 文档(Swagger UI) | `http://localhost:8000/docs` | +| ReDoc 文档 | `http://localhost:8000/redoc` | +| OpenAPI Schema | `http://localhost:8000/openapi.json` | +| 健康检查 | `http://localhost:8000/api/health` | + +--- ### 项目结构 ``` Neo_Agent/ -├── src/ # 源代码 -│ ├── core/ # 核心模块 -│ ├── gui/ # GUI模块 -│ ├── tools/ # 工具模块 -│ └── nps/ # NPS工具系统 -├── prompts/ # 提示词模板 -│ ├── character/ # 角色设定模板 -│ ├── system/ # 系统提示词模板 -│ ├── task/ # 任务提示词模板 -│ └── worldview/ # 世界观设定 -├── tests/ # 测试文件 -├── examples/ # 示例代码 -├── docs/ # 文档 -├── main.py # 主入口(推荐) -├── run.py # 简化启动器 -├── requirements.txt # 依赖列表 -├── example.env # 环境变量示例 -└── LICENSE # 许可证 +├── src/ # 源代码 +│ ├── nervous_system/ # 神经系统:CentralRouter + Gateway + Packet +│ ├── cortex/ # 大脑皮层:LLM 推理核心 +│ ├── limbic/ # 边缘系统:海马体记忆 + 杏仁核情感 +│ ├── prefrontal/ # 前额叶:日程 / 事件 / 主动决策 +│ ├── cerebellum/ # 小脑:NPS 工具 / 视觉 / 表达风格 +│ ├── hypothalamus/ # 下丘脑:生命状态 / 用户习惯 +│ ├── core/ # 核心模块(v3.x 兼容层 + 公共服务) +│ ├── web/ # Web GUI +│ │ ├── backend/ # FastAPI 后端(API / WS / Services) +│ │ └── frontend/ # React + Vite + Ant Design 前端 +│ ├── tools/ # 工具模块(UnifiedLogger / DebugLogger 等) +│ └── nps/ # NPS 工具系统 +├── prompts/ # 提示词模板 +├── tests/ # 测试文件 +├── examples/ # 示例代码 +├── docs/ # 文档 +├── main.py # 兼容层入口(委托 run.py) +├── run.py # 统一 CLI 入口(v4.1 主入口) +├── run_web.py # Web 启动实现(被 run.py 复用) +├── start.sh # 一键启动脚本 +├── requirements.txt # 核心依赖 +├── requirements-web.txt # Web 端额外依赖 +├── example.env # 环境变量示例 +└── LICENSE # 许可证 ``` +--- + ### 核心模块 -- **prompt_manager**: 提示词管理,支持Markdown模板加载和渲染 -- **model_config**: 多层模型配置管理 -- **langchain_llm**: LangChain LLM封装,支持模型路由 -- **llm_helper**: LLM辅助工具,简化工具级任务调用 -- **chat_agent**: 对话代理核心 -- **database_manager**: 统一数据库管理 -- **emotion_analyzer**: 情感关系分析(使用工具模型) -- **knowledge_base**: 知识库管理(使用工具模型) -- **long_term_memory**: 长期记忆系统 -- **schedule_manager**: 日程管理 +- **UnifiedLogger**: 统一日志核心,标准化格式、多 Handler 输出 +- **DebugLogger**: 旧日志 API 兼容层,委托 UnifiedLogger +- **CentralRouter**: 神经系统中央路由,跨模块同步 / 流式调用 +- **LLMCore / ModelRouter**: LLM 推理与模型路由 +- **Chat Agent**: 对话代理核心 +- **Memory Store**: 分层记忆系统 +- **Emotion Analyzer**: 情感关系分析 +- **Schedule Manager**: 日程管理 +- **Knowledge Base**: 知识库管理 +- **NPS Toolkit**: 工具注册、调用与编排 + +--- + +### WebSocket 端点 + +| 端点 | 说明 | +| --- | --- | +| `/ws/chat` | 流式聊天 | +| `/ws/events` | 全局事件流 | +| `/ws/debug` | 调试日志流 | +| `/ws/proactive` | 主动消息推送 | +| `/ws/frontend-logs` | 前端日志回流(v4.1) | + +--- ### 提示词系统 -Neo Agent采用模块化的提示词工程系统,参考了SillyTavern的设计理念: +Neo Agent 采用模块化的提示词工程系统: -- 📝 **Markdown模板**: 所有提示词以Markdown文件存储 +- 📝 **Markdown 模板**: 所有提示词以 Markdown 文件存储 - 🎭 **角色扮演**: 详细的角色设定和行为准则 - 🌍 **世界观注入**: 虚拟世界背景和环境设定 - 🔄 **动态渲染**: 支持变量替换和上下文注入 - 🛡️ **后备机制**: 模板失败时自动降级到硬编码提示词 +--- + ### 许可证 本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。 @@ -118,84 +261,219 @@ Neo Agent采用模块化的提示词工程系统,参考了SillyTavern的设计 ### Introduction -Neo Agent is a LangChain + LangGraph-based intelligent conversation agent system with multi-tier model architecture, featuring role-playing, long-term memory management, emotional relationship analysis, and intelligent schedule management capabilities. +Neo Agent is a LangChain + LangGraph-based intelligent conversation agent system with a nervous-system-inspired multi-layer architecture. It features role-playing, long-term memory management, emotional relationship analysis, intelligent schedule management, and observable unified logging. -### Architecture Features +Since v4.1, the project unifies the entry point and logging system: `run.py` is the single main entry, and `UnifiedLogger` carries logs across the frontend and backend, allowing frontend console logs to flow back to the backend in real time via WebSocket. -#### 🏗️ Composite Framework Architecture -- **LangChain**: Core framework providing LLM abstraction and chain invocation -- **Multi-tier Model Architecture**: Intelligent model selection based on task type - - Main Model (DeepSeek-V3.2): Handles primary conversations and complex reasoning - - Tool Model (GLM-4.6V): Handles tool invocations, intent recognition, and lightweight tasks - - Multimodal Model (Qwen3-VL-32B): Handles multimodal recognition and reasoning +--- + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Entry Layer │ +│ run.py (web / stop / status / logs) ← unified CLI │ +│ main.py ──────────────────────────────→ run.py wrapper │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────┐ +│ Web Service Layer │ +│ FastAPI + Uvicorn │ +│ ├── REST API (/api/*) │ +│ ├── WebSocket (/ws/chat, /ws/events, /ws/debug, │ +│ │ /ws/proactive, /ws/frontend-logs) │ +│ └── Static frontend (React + Vite + Ant Design) │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────┐ +│ Nervous System │ +│ CentralRouter ── Packet ── Gateway │ +│ Cross-module workflow: chat → emotion → schedule → proactive│ +└─────────────────────────────────────────────────────────────┘ + │ +┌──────────┬──────────┬───────────────┬──────────┬────────────┐ +│ Cortex │ Limbic │ Prefrontal │Cerebellum│Hypothalamus│ +│ LLMCore │ Memory │ Schedule / │ NPS tools│ Life state │ +│ ModelRouter│ Emotion│ proactive │ vision │ user habits│ +└──────────┴──────────┴───────────────┴──────────┴────────────┘ + │ +┌─────────────────────────────┴───────────────────────────────┐ +│ Unified Logging Infrastructure │ +│ UnifiedLogger ← FileHandler / MemoryBufferHandler │ +│ ← WebSocketBroadcastHandler │ +│ ← ExternalForwarderHandler │ +│ DebugLogger (legacy API) ← delegates to UnifiedLogger │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- ### Key Features -- 🧠 **Hierarchical Memory System**: Short-term memory, long-term memory, knowledge base, base knowledge -- 💭 **Intelligent Conversation**: Role-playing, continuous dialogue, memory retrieval, emotional understanding -- 📊 **Emotion Analysis**: Impression assessment, cumulative scoring, relationship visualization -- 🖥️ **Modern GUI**: User-friendly Tkinter-based interface -- ️ **Data Management**: SQLite storage, data migration, backup and recovery +- 🧠 **Hierarchical Memory System**: short-term, long-term, knowledge base, base knowledge +- 💭 **Intelligent Conversation**: role-playing, continuous dialogue, memory retrieval, emotional understanding, streaming replies +- 📊 **Emotion Analysis**: impression assessment, cumulative scoring, relationship visualization +- 📅 **Schedule Management**: natural-language creation, conflict detection, reminder push +- 🛠️ **NPS Toolkit**: extensible tool registration, invocation and orchestration +- 🖥️ **Modern Web GUI**: React + TypeScript + Vite + Ant Design +- 🌐 **Web-only capabilities**: + - **Cross-device access**: any browser at `http://:8000` + - **Real-time WebSocket push**: message / event / proactive / debug / frontend logs + - **Remote access**: deployable on a server, multi-user concurrent + - **Streaming reply**: LangChain `astream` with automatic fallback +- 🪵 **Observable Logging**: unified log collection, query, broadcast and persistence across frontend and backend +- 💾 **Data Management**: SQLite storage, migration, backup and recovery +- 📝 **Prompt Engineering**: modular Markdown prompts, character settings, worldview injection + +--- + +### Tech Stack + +- **Backend**: FastAPI 0.110+ / Uvicorn / Pydantic v2 / WebSockets +- **Frontend**: React 18 + TypeScript + Vite 5 +- **UI Kit**: Ant Design 5 +- **State**: Zustand +- **Charts**: ECharts 5 +- **HTTP client**: Axios +- **Core**: LangChain + LangGraph +- **Logging**: custom `UnifiedLogger` + multi-handler architecture + +--- ### Quick Start -#### Install Dependencies +#### Environment ```bash -pip install -r requirements.txt +# Python >= 3.10 +python --version + +cp example.env .env +# Edit .env with your API keys and configuration ``` -#### Configure Environment +#### One-click launch (recommended) ```bash -cp example.env .env -# Edit .env file with your API keys and configuration +./start.sh ``` -#### Run Application +Open http://localhost:8000 + +#### Manual launch ```bash -# Method 1: Use main entry point (recommended) -python main.py +pip install -r requirements.txt -r requirements-web.txt +cd src/web/frontend && npm install && cd - + +# Unified entry: start Web / API by default +python run.py web -# Method 2: Use simplified launcher (if import issues occur) -python run.py +# Pure API mode +python run.py web --no-dev --no-static -# Method 3: If package is installed -neo-agent +# Custom port +python run.py web --port 8080 --host 127.0.0.1 ``` -**Troubleshooting:** -- If you encounter import errors, ensure you're running from the project root directory -- Make sure all dependencies are installed: `pip install -r requirements.txt` -- Windows users may need to use `python` instead of `python3` +#### Common CLI commands + +```bash +python run.py web # Start Web / API (default) +python run.py stop # Stop running Neo Agent process +python run.py status # Show running status +python run.py logs # Tail debug.log +python run.py logs -f # Follow log output +``` + +`main.py` remains as a compatibility wrapper equivalent to `python run.py web`. + +--- + +### Access URLs + +| Purpose | URL | +| --- | --- | +| Web frontend | `http://localhost:8000` | +| Vite dev port | `http://localhost:5173` | +| Swagger UI | `http://localhost:8000/docs` | +| ReDoc | `http://localhost:8000/redoc` | +| OpenAPI schema | `http://localhost:8000/openapi.json` | +| Health check | `http://localhost:8000/api/health` | + +--- ### Project Structure ``` Neo_Agent/ -├── src/ # Source code -│ ├── core/ # Core modules -│ ├── gui/ # GUI modules -│ ├── tools/ # Utility modules -│ └── nps/ # NPS tool system -├── tests/ # Test files -├── examples/ # Example code -├── main.py # Main entry point (recommended) -├── run.py # Simplified launcher -├── requirements.txt # Dependencies -├── example.env # Environment variables template -└── LICENSE # License file +├── src/ # Source code +│ ├── nervous_system/ # Nervous system: CentralRouter + Gateway + Packet +│ ├── cortex/ # Cerebral cortex: LLM reasoning core +│ ├── limbic/ # Limbic system: memory + emotion +│ ├── prefrontal/ # Prefrontal cortex: schedule / event / proactive +│ ├── cerebellum/ # Cerebellum: NPS tools / vision / expression style +│ ├── hypothalamus/ # Hypothalamus: life state / user habits +│ ├── core/ # Core modules (v3.x compatibility + shared services) +│ ├── web/ # Web GUI +│ │ ├── backend/ # FastAPI backend (API / WS / services) +│ │ └── frontend/ # React + Vite + Ant Design frontend +│ ├── tools/ # Utilities (UnifiedLogger / DebugLogger, etc.) +│ └── nps/ # NPS tool system +├── prompts/ # Prompt templates +├── tests/ # Test files +├── examples/ # Example code +├── docs/ # Documentation +├── main.py # Compatibility wrapper (delegates to run.py) +├── run.py # Unified CLI entry (v4.1 main entry) +├── run_web.py # Web launcher implementation (reused by run.py) +├── start.sh # One-click launcher +├── requirements.txt # Core dependencies +├── requirements-web.txt # Web-only dependencies +├── example.env # Environment variables template +└── LICENSE # License file ``` +--- + ### Core Modules -- **chat_agent**: Conversation agent core -- **database_manager**: Unified database management -- **emotion_analyzer**: Emotional relationship analysis -- **knowledge_base**: Knowledge base management -- **long_term_memory**: Long-term memory system -- **schedule_manager**: Schedule management +- **UnifiedLogger**: unified logging core with standardized format and multi-handler output +- **DebugLogger**: legacy logging API compatibility wrapper delegating to UnifiedLogger +- **CentralRouter**: nervous system central router for cross-module sync/streaming calls +- **LLMCore / ModelRouter**: LLM reasoning and model routing +- **Chat Agent**: conversation agent core +- **Memory Store**: hierarchical memory system +- **Emotion Analyzer**: emotional relationship analysis +- **Schedule Manager**: schedule management +- **Knowledge Base**: knowledge base management +- **NPS Toolkit**: tool registration, invocation and orchestration + +--- + +### WebSocket Endpoints + +| Endpoint | Description | +| --- | --- | +| `/ws/chat` | Streaming chat | +| `/ws/events` | Global event stream | +| `/ws/debug` | Debug log stream | +| `/ws/proactive` | Proactive message push | +| `/ws/frontend-logs` | Frontend log backflow (v4.1) | + +--- + +### Prompt System + +Neo Agent uses a modular prompt engineering system: + +- 📝 **Markdown templates**: all prompts stored as Markdown files +- 🎭 **Role-playing**: detailed character settings and behavior guidelines +- 🌍 **Worldview injection**: virtual world background and environment settings +- 🔄 **Dynamic rendering**: variable substitution and context injection +- 🛡️ **Fallback mechanism**: automatically falls back to hard-coded prompts + +--- ### License diff --git a/__pycache__/llm_core.cpython-312.pyc b/__pycache__/llm_core.cpython-312.pyc deleted file mode 100644 index d87c5ea..0000000 Binary files a/__pycache__/llm_core.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/llm_core.cpython-313.pyc b/__pycache__/llm_core.cpython-313.pyc deleted file mode 100644 index 3268586..0000000 Binary files a/__pycache__/llm_core.cpython-313.pyc and /dev/null differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 96ee4cb..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,199 +0,0 @@ -# Neo Agent 架构文档 - -## 概述 - -Neo Agent 是一个基于 LangChain 的智能对话代理系统,采用多层模型架构,具备角色扮演、长效记忆管理、情感关系分析和智能日程管理功能。 - -## 架构组成 - -### 1. LangChain核心 - -LangChain作为核心框架,提供: -- LLM抽象和统一接口 -- 消息格式转换 -- 链式调用支持 -- 模板和输出解析 - -### 2. 多层模型架构 - -根据任务类型智能选择合适的模型: - -#### 主模型 (deepseek-ai/DeepSeek-V3.2) -- **用途**:处理主要对话和复杂推理 -- **特点**:强大的理解和生成能力 -- **使用场景**:用户对话、复杂问答、创意生成 - -#### 工具模型 (zai-org/GLM-4.6V) -- **用途**:处理工具级任务 -- **特点**:快速响应、成本效益高 -- **使用场景**: - - 情感分析 - - 知识提取 - - 实体识别 - - 意图识别 - -#### 多模态模型 (Qwen/Qwen3-VL-32B-Instruct) -- **用途**:处理多模态识别和推理 -- **特点**:支持视觉理解 -- **使用场景**:环境感知、图像理解(预留) - -## 核心组件 - -### ModelConfig (model_config.py) - -管理多层模型配置: - -```python -from src.core.model_config import ModelType, get_model_config - -# 获取配置 -config = get_model_config() - -# 获取特定模型配置 -main_config = config.get_model_config(ModelType.MAIN) -tool_config = config.get_model_config(ModelType.TOOL) -vision_config = config.get_model_config(ModelType.VISION) -``` - -### LangChainLLM (langchain_llm.py) - -封装LangChain的ChatOpenAI,支持SiliconFlow API: - -```python -from src.core.langchain_llm import LangChainLLM, ModelType - -# 创建不同类型的LLM实例 -main_llm = LangChainLLM(ModelType.MAIN) -tool_llm = LangChainLLM(ModelType.TOOL) -vision_llm = LangChainLLM(ModelType.VISION) - -# 调用LLM -response = tool_llm.chat(messages) -``` - -### ModelRouter (langchain_llm.py) - -智能路由到合适的模型: - -```python -from src.core.langchain_llm import ModelRouter - -router = ModelRouter() - -# 根据任务类型路由 -main_llm = router.route('main') -tool_llm = router.route('tool') -vision_llm = router.route('vision') -``` - -### LLMHelper (llm_helper.py) - -简化工具级任务的LLM调用: - -```python -from src.core.llm_helper import LLMHelper - -# 调用工具模型 -response = LLMHelper.call_tool_model( - system_prompt="你是一个情感分析专家", - user_message="分析这段对话的情感", - temperature=0.3, - max_tokens=1000 -) - -# 调用主模型 -response = LLMHelper.call_main_model( - system_prompt="你是一个智能助手", - user_message="帮我写一篇文章" -) -``` - -## 兼容性设计 - -为保持向后兼容,所有更改都采用了兼容层设计: - -### SiliconFlowLLM兼容层 - -`SiliconFlowLLM`类保持原有接口,但内部使用新的LangChain架构: - -```python -# 原有代码仍然工作 -from src.core.chat_agent import SiliconFlowLLM - -llm = SiliconFlowLLM() -response = llm.chat(messages) - -# 新功能:支持任务类型参数 -response = llm.chat(messages, task_type='tool') # 使用工具模型 -``` - -### 模块更新 - -以下模块已更新为使用新架构: - -1. **chat_agent.py**: SiliconFlowLLM作为兼容层 -2. **emotion_analyzer.py**: 使用LLMHelper调用工具模型 -3. **knowledge_base.py**: 使用LLMHelper进行知识提取 -4. **其他工具模块**: 保持原有接口,内部使用新架构 - -## 配置说明 - -### 环境变量 - -在`.env`文件中配置: - -```bash -# API配置 -SILICONFLOW_API_KEY=your-api-key -SILICONFLOW_API_URL=https://api.siliconflow.cn/v1/chat/completions - -# 主模型配置 -MAIN_MODEL_NAME=deepseek-ai/DeepSeek-V3.2 -MAIN_MODEL_TEMPERATURE=0.8 -MAIN_MODEL_MAX_TOKENS=2000 - -# 工具模型配置 -TOOL_MODEL_NAME=zai-org/GLM-4.6V -TOOL_MODEL_TEMPERATURE=0.3 -TOOL_MODEL_MAX_TOKENS=500 - -# 多模态模型配置 -VISION_MODEL_NAME=Qwen/Qwen3-VL-32B-Instruct -VISION_MODEL_TEMPERATURE=0.5 -VISION_MODEL_MAX_TOKENS=1000 - -# 兼容旧配置 -MODEL_NAME=deepseek-ai/DeepSeek-V3 -TEMPERATURE=0.8 -MAX_TOKENS=2000 -``` - -## 使用指南 - -### 选择合适的模型 - -- **主模型**:用户直接交互、需要高质量回复 -- **工具模型**:内部处理、轻量级任务、成本敏感场景 -- **多模态模型**:涉及图像或视觉理解的任务 - -### 迁移指南 - -如果您有自定义代码使用了原有的API调用方式,建议: - -1. 使用`LLMHelper`简化调用 -2. 根据任务类型选择合适的模型 -3. 保持原有接口不变,只更新内部实现 - -## 性能优化 - -多层模型架构带来的优势: - -1. **成本优化**:轻量级任务使用小模型,降低API成本 -2. **响应速度**:小模型响应更快,提升用户体验 -3. **质量保证**:主要对话使用大模型,保证质量 -4. **灵活扩展**:易于添加新的模型类型 - -## 参考资料 - -- [LangChain文档](https://docs.langchain.com/) -- [SiliconFlow API](https://siliconflow.cn/) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5cb8bb2..050f52e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -24,7 +24,13 @@ #### 项目结构 - `src/core/` - 核心业务逻辑 -- `src/gui/` - 图形界面组件 +- `src/nervous_system/` - 神经系统:CentralRouter + Gateway + Packet +- `src/cortex/` - 大脑皮层:LLM 推理核心 +- `src/limbic/` - 边缘系统:记忆 + 情感 +- `src/prefrontal/` - 前额叶:日程 / 事件 / 主动决策 +- `src/cerebellum/` - 小脑:NPS 工具 / 视觉 / 表达风格 +- `src/hypothalamus/` - 下丘脑:生命状态 / 用户习惯 +- `src/web/` - Web GUI(FastAPI + React) - `src/tools/` - 工具和辅助函数 - `src/nps/` - NPS工具系统 - `tests/` - 单元测试 @@ -95,7 +101,13 @@ Thank you for your interest in the Neo Agent project! We welcome contributions o #### Project Structure - `src/core/` - Core business logic -- `src/gui/` - GUI components +- `src/nervous_system/` - Nervous system: CentralRouter + Gateway + Packet +- `src/cortex/` - Cerebral cortex: LLM reasoning core +- `src/limbic/` - Limbic system: memory + emotion +- `src/prefrontal/` - Prefrontal cortex: schedule / event / proactive +- `src/cerebellum/` - Cerebellum: NPS tools / vision / expression style +- `src/hypothalamus/` - Hypothalamus: life state / user habits +- `src/web/` - Web GUI (FastAPI + React) - `src/tools/` - Utilities and helper functions - `src/nps/` - NPS tool system - `tests/` - Unit tests diff --git a/docs/NPS_CONFIG_MANAGEMENT.md b/docs/NPS_CONFIG_MANAGEMENT.md index 0d81e55..11ebc33 100644 --- a/docs/NPS_CONFIG_MANAGEMENT.md +++ b/docs/NPS_CONFIG_MANAGEMENT.md @@ -44,34 +44,9 @@ NPS插件配置管理系统提供了一个可视化的GUI界面,允许用户 - `export_config(tool_id, output_file)`: 导出配置 - `import_config(tool_id, input_file)`: 导入配置 -### 2. NPSPluginConfigDialog (`src/gui/nps_config_dialog.py`) - -可视化配置对话框,提供用户友好的配置界面。 - -**支持的插件类型**: -- **websearch**: 网络搜索配置(API Key、引擎、结果数、语言) -- **systime**: 系统时间配置(时区、时间格式) -- **通用**: JSON编辑器(用于未知插件) - -**界面组件**: -- 工具信息显示(名称、ID、版本) -- 配置字段(根据插件类型自动生成) -- 启用/禁用开关 -- 操作按钮(保存、取消、重置、导入、导出) - -### 3. NPS GUI集成 (`src/gui/nps_gui.py`) - -在现有的NPS管理GUI中集成配置功能。 - -**新增功能**: -- 工具栏添加"⚙️ 高级配置"按钮 -- 右键菜单添加"⚙️ 高级配置"选项 -- 配置管理器初始化 -- 配置更新后自动刷新工具列表 - ## 使用方法 -### 方式1:通过GUI配置(推荐) +### 方式1:通过 Web GUI 配置(推荐) 1. 打开Neo Agent主界面 2. 切换到"NPS工具管理"标签页 diff --git a/docs/REFACTORING_SUMMARY.md b/docs/REFACTORING_SUMMARY.md deleted file mode 100644 index 8be64b6..0000000 --- a/docs/REFACTORING_SUMMARY.md +++ /dev/null @@ -1,183 +0,0 @@ -# 项目重构总结 / Project Refactoring Summary - -## 概述 / Overview - -本次重构完成了Neo Agent项目的全面整理和标准化,提升了代码组织性、可维护性和安全性。 - -This refactoring completed a comprehensive organization and standardization of the Neo Agent project, improving code organization, maintainability, and security. - -## 主要成果 / Key Achievements - -### 1. 标准化项目结构 / Standardized Project Structure - -``` -Neo_Agent/ -├── src/ # 源代码 (21,383 行) -│ ├── core/ # 11 个核心模块 -│ ├── gui/ # 5 个GUI模块 -│ ├── tools/ # 7 个工具模块 -│ └── nps/ # NPS工具系统 -├── tests/ # 12 个测试文件 -├── examples/ # 3 个示例程序 -├── main.py # 主入口 -└── 文档 (5个MD文件) -``` - -### 2. 文件迁移统计 / File Migration Statistics - -- **移动文件**: 36个Python模块 -- **删除文档**: 30+个临时/过时Markdown文件 -- **新增文档**: 5个标准化文档 -- **总Python文件**: 50个 - -### 3. 新建文档 / New Documentation - -1. **README.md** - 项目说明(中英双语) -2. **CONTRIBUTING.md** - 贡献指南 -3. **CHANGELOG.md** - 更新日志 -4. **TECHNICAL.md** - 技术文档(6KB) -5. **API.md** - API参考文档(7KB) - -### 4. 包管理配置 / Package Management - -- **setup.py** - 标准化安装脚本 -- **MANIFEST.in** - 包分发配置 -- **.gitattributes** - 文件类型处理 -- **main.py** - 统一入口点 - -### 5. 代码质量改进 / Code Quality Improvements - -#### 导入路径更新 / Import Path Updates -- 更新了所有36个源文件的导入语句 -- 采用标准化的`src.module.class`格式 -- 添加了适当的`__init__.py`文件 - -#### 安全加固 / Security Hardening -- **Prompt注入防护**: 在agent_vision.py中添加可疑模式检测 -- **输入验证**: 增强用户输入清理和长度限制 -- **日志级别**: 修正配置错误的日志级别 -- **CodeQL扫描**: 通过,0个安全警告 - -#### 代码组织 / Code Organization -- 按功能分离: core, gui, tools, nps -- 单一职责原则 -- 清晰的模块边界 - -## 技术细节 / Technical Details - -### 模块重组 / Module Reorganization - -#### Core模块 (src/core/) -| 文件 | 功能 | 行数 | -|------|------|------| -| chat_agent.py | 对话代理核心 | ~1,400 | -| database_manager.py | 数据库管理 | ~2,100 | -| emotion_analyzer.py | 情感分析 | ~900 | -| event_manager.py | 事件管理 | ~420 | -| knowledge_base.py | 知识库 | ~840 | -| long_term_memory.py | 长期记忆 | ~430 | -| schedule_manager.py | 日程管理 | ~750 | -| 其他 | 其他核心模块 | ~14,500 | - -#### GUI模块 (src/gui/) -- gui_enhanced.py - 主界面 (~4,700行) -- database_gui.py - 数据库GUI (~1,850行) -- nps_gui.py - NPS管理界面 (~860行) -- schedule_gui.py - 日程界面 (~820行) -- settings_migration_gui.py - 设置迁移界面 (~515行) - -#### Tools模块 (src/tools/) -- agent_vision.py - 智能体视觉 (~910行) -- debug_logger.py - 调试日志 (~325行) -- expression_style.py - 表达风格 (~410行) -- tooltip_utils.py - 提示工具 (~150行) -- 其他工具模块 - -### 测试覆盖 / Test Coverage - -12个测试文件覆盖主要模块: -- test_chat_agent.py -- test_database_manager.py -- test_event_manager.py -- test_nps.py -- test_schedule_manager.py -- 等 - -## 改进效果 / Improvements - -### 开发体验 / Developer Experience -- ✅ 清晰的项目结构,易于理解 -- ✅ 标准化的导入路径 -- ✅ 完善的文档支持 -- ✅ 便捷的安装流程 (`pip install -e .`) - -### 代码质量 / Code Quality -- ✅ 所有Python文件编译通过 -- ✅ 无安全漏洞(CodeQL验证) -- ✅ 改进的错误处理 -- ✅ 增强的输入验证 - -### 可维护性 / Maintainability -- ✅ 模块化设计 -- ✅ 单一职责原则 -- ✅ 清晰的依赖关系 -- ✅ 易于扩展 - -### 文档完整性 / Documentation -- ✅ 项目README(中英双语) -- ✅ 详细的技术文档 -- ✅ 完整的API参考 -- ✅ 贡献指南 - -## 兼容性 / Compatibility - -### 向后兼容 / Backward Compatibility -- 所有功能保持不变 -- API接口未改变 -- 配置文件格式未变 -- 数据库结构未变 - -### 升级路径 / Migration Path -对于现有用户: -1. 更新代码到最新版本 -2. 导入路径自动更新 -3. 无需修改配置文件 -4. 数据自动兼容 - -## 遗留问题 / Known Issues - -根据代码审查,以下问题已记录但不影响核心功能: - -1. **SQL查询构建** (低优先级) - - settings_migration.py 和 schedule_manager.py 使用白名单验证后的f-string - - 已通过安全审计,但建议未来考虑使用查询构建器 - -2. **置信度数据验证** (expression_style.py) - - 存在重复的置信度限制检查 - - 建议在数据库层添加约束 - -3. **GUI刷新间隔** (nps_gui.py) - - 3秒刷新间隔可能过于频繁 - - 建议根据使用情况调整 - -## 下一步 / Next Steps - -建议的后续改进: -1. 添加自动化CI/CD流程 -2. 增加单元测试覆盖率 -3. 创建用户使用手册 -4. 添加性能基准测试 -5. 考虑添加类型检查(mypy) - -## 结论 / Conclusion - -本次重构成功实现了项目的标准化和现代化,为未来的开发和维护奠定了坚实基础。 - -This refactoring successfully achieved project standardization and modernization, laying a solid foundation for future development and maintenance. - ---- - -**重构完成日期**: 2026-01-31 -**代码审查**: 通过 ✅ -**安全扫描**: 通过 ✅ (CodeQL - 0 alerts) -**向后兼容**: 是 ✅ diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 977d607..af54555 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -197,7 +197,6 @@ python -m pytest --cov=src tests/ - `langchain-community` - LangChain社区扩展 - `python-dotenv` - 环境变量管理 - `requests` - HTTP请求 -- `tkinter` - GUI框架(Python标准库) ### 数据流 / Data Flow @@ -265,12 +264,6 @@ DEBUG_MODE=False 2. 添加新的配置参数 3. 更新环境变量模板 -#### 添加新的GUI组件 - -1. 在 `src/gui/` 创建新模块 -2. 继承 `tkinter` 相关类 -3. 在主界面中集成 - #### 添加新的工具 1. 在 `src/tools/` 创建新模块 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..61878e2 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,183 @@ +# Neo Agent Web API 参考 + +> Stage E.3 - 简要 API 列表(REST + WebSocket)。**完整定义由 FastAPI 自动生成在 `/docs`(Swagger UI)与 `/redoc`。** + +本文件用于**快速速查**,每个端点的 schema / 错误码 / 示例请以 FastAPI 生成的文档为准。 + +--- + +## 1. REST 端点 + +### 1.1 健康检查 + +| Method | Path | 说明 | +| --- | --- | --- | +| GET | `/api/health` | 服务存活检查;返回 `{status, version, timestamp}` | + +**响应示例**: + +```json +{ + "status": "ok", + "version": "3.0.0", + "timestamp": "2026-07-14T12:00:00.000000+00:00" +} +``` + +### 1.2 聊天(占位) + +| Method | Path | 说明 | +| --- | --- | --- | +| GET | `/api/chat` | Stage A.3 占位端点;返回 `{message: "chat endpoint placeholder"}` | + +> 未来将由 `POST /api/chat`(同步)与 `POST /api/chat/stream`(流式 SSE)替换。 +> 当前聊天入口走 WebSocket `/ws/chat`。 + +### 1.3 业务域端点(占位 / Stage B-C 计划) + +| 路径前缀 | 计划方法 | 用途 | +| --- | --- | --- | +| `/api/knowledge` | GET / POST / PUT / DELETE | 知识库 CRUD + `/search` | +| `/api/schedule` | GET / POST / PUT / DELETE | 日程管理 | +| `/api/events` | GET | 事件查询(运行时事件) | +| `/api/nps/status` | GET | NPS 工具状态 | +| `/api/database/tables` | GET | 数据库元信息(表 / schema 摘要) | +| `/api/creative/generate` | POST | 创意生成 | + +> 这些端点在当前 Stage 尚未注册到 `src/web/backend/main.py`;实现按业务域分文件放到 `src/web/backend/api/`。 + +--- + +## 2. WebSocket 端点 + +所有 WS 端点位于 `/ws/*`;连接时由服务端先发送 `{"type": "ack" | "system", ...}`。 + +### 2.1 `/ws/chat` — 流式聊天 + +| 方向 | 帧 | 字段 | +| --- | --- | --- | +| 客户端 → 服务端 | `message` | `{type, content, context?}` | +| 客户端 → 服务端 | `ping` | `{type: "ping"}` | +| 服务端 → 客户端 | `system` | `{type, message}`(连接成功) | +| 服务端 → 客户端 | `chunk` | `{type, content}`(流式增量) | +| 服务端 → 客户端 | `done` | `{type}`(流式结束) | +| 服务端 → 客户端 | `pong` | `{type}`(心跳响应) | +| 服务端 → 客户端 | `error` | `{type, message}` | + +**示例**: + +```text +# 客户端 +ws.send('{"type":"message","content":"你好"}') +# 服务端(按顺序) +<- {"type": "system", "message": "connected to /ws/chat"} +<- {"type": "chunk", "content": "你好"} +<- {"type": "chunk", "content": ","} +<- {"type": "chunk", "content": "很高兴认识你"} +<- {"type": "done"} +``` + +### 2.2 `/ws/events` — 全局事件 + +| 方向 | 帧 | 字段 | +| --- | --- | --- | +| 客户端 → 服务端 | 任意 | `{type, ...}`(当前仅做 ack) | +| 服务端 → 客户端 | `ack` | `{type, conn_id, channel: "events"}` | +| 服务端 → 客户端 | 事件 payload | `{type: , data: {...}}` | + +事件类型(EventManager → EventService → broadcast): + +- `scheduler_tick` — 后台调度器心跳(每 60s) +- `proactive_message` — 主动消息(ProactiveEngine 触发) +- `chat_event` — 聊天事件(user_message / done) +- `message` — 通用消息总线 +- 通配:`*` 订阅全部 + +### 2.3 `/ws/debug` — 调试日志 + +| 方向 | 帧 | 字段 | +| --- | --- | --- | +| 客户端 → 服务端 | 任意 | `{type, ...}`(仅 ack) | +| 服务端 → 客户端 | `ack` | `{type, conn_id, channel: "debug"}` | +| 服务端 → 客户端 | 日志帧 | `{type: "debug_log", level, module, message, timestamp}` | + +> 接入 `src/tools/debug_logger.py`(`DebugLogger.subscribe`), +> `src/web/backend/ws/debug_broadcaster.py` 负责将日志广播到所有 `/ws/debug` 连接。 + +### 2.4 `/ws/proactive` — 主动消息 + +| 方向 | 帧 | 字段 | +| --- | --- | --- | +| 客户端 → 服务端 | 任意 | `{type, ...}`(仅 ack) | +| 服务端 → 客户端 | `ack` | `{type, conn_id, channel: "proactive"}` | +| 服务端 → 客户端 | 主动消息 | `{type: "proactive_message", title, body, ts}` | + +> Stage A.2 骨架:连接后保持长连接,主动推送由 `manager.broadcast("proactive", msg)` 驱动。 + +### 2.5 `/ws/event`(已弃用,请用 `/ws/events`) + +Stage A.2 早期版本。保留以做兼容,**新代码请使用 `/ws/events`**。 + +--- + +## 3. Pydantic Schema 速查 + +源文件位置:`src/web/backend/schemas/` + +| Schema | 用途 | +| --- | --- | +| `ChatRequest` | `/api/chat` POST 请求体 | +| `ChatChunk` | 流式 chunk(`type=chunk` / `done` / `error`) | +| `ChatResponse` | 同步响应(`content` + `message_id` + `emotion?`) | +| `EventDTO` | 事件对象 | +| `EventListResponse` | 事件列表响应 | +| `KnowledgeEntity` / `KnowledgeDefinition` | 知识库实体 / 概念定义 | +| `KnowledgeSearchRequest` / `KnowledgeSearchResponse` | 知识库搜索 | +| `ScheduleDTO` / `ScheduleCreate` / `ScheduleUpdate` | 日程 CRUD | + +> 未来扩展:`emotion.py` / `memory.py` / `creative.py` / `nps.py` / `debug.py`。 + +--- + +## 4. 错误与状态码 + +### 4.1 HTTP + +- `200 OK` — 正常 +- `400 Bad Request` — 请求参数不合法(Pydantic 校验失败) +- `404 Not Found` — 路由不存在 +- `422 Unprocessable Entity` — FastAPI 校验失败(请求体 schema 不匹配) +- `500 Internal Server Error` — 服务端异常 + +### 4.2 WebSocket 错误帧 + +服务端通过 `{type: "error", message: "..."}` 推送错误,连接**不自动关闭**,由客户端决定是否重试。 + +| 场景 | message 示例 | +| --- | --- | +| 空输入 | `"empty input"` | +| ChatService 不可用 | `"ChatService unavailable"` | +| chat_stream 异常 | 异常 `str(e)` | + +--- + +## 5. 自动化文档 + +| 工具 | URL | +| --- | --- | +| Swagger UI | `http://localhost:8000/docs` | +| ReDoc | `http://localhost:8000/redoc` | +| OpenAPI JSON | `http://localhost:8000/openapi.json` | + +> WebSocket 端点**不会**自动出现在 `/docs` 中,请参考本文件 §2。 + +--- + +## 6. 相关文件 + +- `src/web/backend/main.py` — FastAPI 入口 +- `src/web/backend/ws/*.py` — WebSocket 路由 +- `src/web/backend/services/*.py` — Service 层 +- `src/web/backend/schemas/*.py` — Pydantic v2 模型 +- `architecture.md` — 整体架构 +- `rollback-procedure.md` — 故障回滚 diff --git a/docs/architecture-v4-diagrams.md b/docs/architecture-v4-diagrams.md new file mode 100644 index 0000000..04981fd --- /dev/null +++ b/docs/architecture-v4-diagrams.md @@ -0,0 +1,617 @@ +# Neo Agent v4.0 架构全景图 + +> 基于人脑认知理论的神经系统架构 +> 版本:v4.0.0 +> 日期:2026-07-17 + +--- + +## 一、方法级架构图 + +展示各模块的核心方法结构,帮助理解每个模块"能做什么"。 + +```mermaid +classDiagram + %% 基类 + class BaseModule { + <> + +module_id: str + +module_type: str + +router: CentralRouter + +initialize() async + +shutdown() async + +handle(packet: Packet) Packet* + +emit(channel, payload) async + +request(target, channel, payload) async + } + + %% 大脑皮层 - LLM 推理 + class LLMCore { + +module_id = "cortex.llm_core" + -_model_router: ModelRouter + +handle(packet) Packet + -_get_model_router() ModelRouter + } + + class LangChainLLM { + +model_type: ModelType + +llm: ChatOpenAI + +chat(messages) str + +chat_with_template(template, variables, history) str + +get_model_info() Dict + -_convert_messages_to_langchain(messages) List + } + + class ModelRouter { + +main_llm: LangChainLLM + +tool_llm: LangChainLLM + +vision_llm: LangChainLLM + +route(task_type) LangChainLLM + +get_model_by_type(model_type) LangChainLLM + } + + %% 边缘系统 - 情感与记忆 + class AmygdalaModule { + +module_id = "limbic.amygdala" + -_analyzer: EmotionRelationshipAnalyzer + -_wheel: PlutchikEmotionWheel + +handle(packet) Packet + -_handle_analyze(packet, payload) Packet + -_handle_latest(packet, payload) Packet + -_handle_trend(packet, payload) Packet + -_handle_tone(packet, payload) Packet + -_handle_wheel_profile(packet, payload) Packet + } + + class HippocampusModule { + +module_id = "limbic.hippocampus.full" + -_db: DatabaseManager + -_session_store: SessionStore + -_memory_manager: LongTermMemoryManager + -_knowledge_base: KnowledgeBase + +handle(packet) Packet + -_handle_memory_query(packet, payload) Packet + -_handle_memory_store(packet, payload) Packet + -_handle_memory_context(packet, payload) Packet + -_handle_session_create(packet, payload) Packet + -_handle_session_list(packet, payload) Packet + -_handle_message_add(packet, payload) Packet + } + + %% 前额叶 - 规划与决策 + class PrefrontalModule { + +module_id = "prefrontal.planner" + -_db: DatabaseManager + -_schedule_manager: ScheduleManager + -_event_manager: EventManager + -_proactive_engine: ProactiveEngine + +handle(packet) Packet + -_handle_schedule_list(packet, payload) Packet + -_handle_schedule_add(packet, payload) Packet + -_handle_proactive_should_send(packet, payload) Packet + -_handle_event_list(packet, payload) Packet + -_handle_event_add(packet, payload) Packet + } + + %% 小脑 - 工具与反射 + class CerebellumModule { + +module_id = "cerebellum.toolkit" + -_vision_tool: AgentVisionTool + -_schedule_intent_tool: ScheduleIntentTool + -_interrupt_tool: InterruptQuestionTool + -_nps_bridge: NPSBridgeTool + -_expression_manager: ExpressionStyleManager + +handle(packet) Packet + -_handle_vision_should_use(packet, payload) Packet + -_handle_schedule_intent(packet, payload) Packet + -_handle_nps_call(packet, payload) Packet + -_handle_expression_agent_add(packet, payload) Packet + } + + %% 下丘脑 - 内稳态 + class HypothalamusModule { + +module_id = "hypothalamus.homeostasis" + -_db: DatabaseManager + -_life_state: LifeStateManager + -_habits: UserHabitTracker + +handle(packet) Packet + -_handle_life_state_ensure(packet, payload) Packet + -_handle_life_state_snapshot(packet, payload) Packet + -_handle_habit_update(packet, payload) Packet + -_handle_habit_qualified(packet, payload) Packet + } + + %% 神经系统 - 路由与网关 + class CentralRouter { + -_modules: Dict~str, BaseModule~ + -_subscribers: Dict~str, List~ + -_middlewares: List~Middleware~ + +register_module(module_id, module) + +add_middleware(middleware) + +route(packet) Packet + +publish(packet) async + +initialize() async + +shutdown() async + } + + class Packet { + +trace_id: str + +source: str + +target: str + +packet_type: PacketType + +channel: str + +payload: Dict + +metadata: Dict + +response(payload) Packet + +error(origin, message, code) Packet + } + + %% 继承关系 + BaseModule <|-- LLMCore + BaseModule <|-- AmygdalaModule + BaseModule <|-- HippocampusModule + BaseModule <|-- PrefrontalModule + BaseModule <|-- CerebellumModule + BaseModule <|-- HypothalamusModule + + %% 组合关系 + LLMCore *-- ModelRouter + ModelRouter *-- LangChainLLM + AmygdalaModule *-- EmotionRelationshipAnalyzer + AmygdalaModule *-- PlutchikEmotionWheel + HippocampusModule *-- SessionStore + HippocampusModule *-- LongTermMemoryManager + PrefrontalModule *-- ScheduleManager + PrefrontalModule *-- EventManager + PrefrontalModule *-- ProactiveEngine + CerebellumModule *-- AgentVisionTool + CerebellumModule *-- NPSBridgeTool + HypothalamusModule *-- LifeStateManager + HypothalamusModule *-- UserHabitTracker + + %% 路由关系 + CentralRouter --> BaseModule : routes to + BaseModule --> Packet : handles + BaseModule --> CentralRouter : emits events +``` + +--- + +## 二、抽象层图(人脑认知映射) + +展示 v4.0 架构如何映射人脑认知功能分区。 + +```mermaid +graph TB + subgraph "外部世界" + WebUI[Web 前端
React] + APIClient[API 客户端
测试/第三方调用] + LLM_API[LLM API 调用] + end + + subgraph "神经系统 - 脑干/脊髓" + HTTP_GW[HTTP Gateway
/api/v4/*] + WS_GW[WebSocket Gateway
/ws/*] + LLM_GW[LLM Gateway
cortex.llm_core] + Router[CentralRouter
中央路由器] + Audit[AuditMiddleware
审计日志] + Timing[TimingMiddleware
性能计时] + end + + subgraph "大脑皮层 - Cortex" + LLM_Core[LLMCore
cortex.llm_core
LLM 推理核心] + Echo[EchoCortex
cortex.echo
MVP 回声测试] + ModelRouter[ModelRouter
模型路由器] + MainLLM[Main LLM
主模型] + ToolLLM[Tool LLM
工具模型] + VisionLLM[Vision LLM
视觉模型] + end + + subgraph "边缘系统 - Limbic" + subgraph "海马体 - Hippocampus" + Hippo[SimpleHippocampus
limbic.hippocampus
MVP 记忆] + HippoFull[HippocampusModule
limbic.hippocampus.full
完整记忆] + SessionStore[SessionStore
会话存储] + MemoryMgr[LongTermMemoryManager
长期记忆] + KnowledgeBase[KnowledgeBase
知识库] + end + subgraph "杏仁核 - Amygdala" + Amyg[AmygdalaModule
limbic.amygdala
情感分析] + EmotionAnalyzer[EmotionRelationshipAnalyzer
情感关系分析] + PlutchikWheel[PlutchikEmotionWheel
Plutchik 8 维情绪轮] + end + end + + subgraph "前额叶 - Prefrontal" + Prefrontal[PrefrontalModule
prefrontal.planner
规划与决策] + ScheduleMgr[ScheduleManager
日程管理] + EventMgr[EventManager
事件管理] + ProactiveEngine[ProactiveEngine
主动决策引擎] + end + + subgraph "小脑 - Cerebellum" + Cerebellum[CerebellumModule
cerebellum.toolkit
工具与反射] + VisionTool[AgentVisionTool
视觉工具] + ScheduleIntent[ScheduleIntentTool
日程意图识别] + NPSBridge[NPSBridgeTool
NPS 工具桥接] + ExpressionMgr[ExpressionStyleManager
表达风格管理] + end + + subgraph "下丘脑 - Hypothalamus" + Hypothalamus[HypothalamusModule
hypothalamus.homeostasis
内稳态] + LifeState[LifeStateManager
生命状态管理] + HabitTracker[UserHabitTracker
用户习惯追踪] + end + + subgraph "数据库层" + SQLite[(SQLite
chat_agent.db)] + end + + %% 外部到网关 + WebUI -->|HTTP POST /api/v4/*| HTTP_GW + WebUI -->|WebSocket /ws/*| WS_GW + APIClient -->|直接调用| LLM_API + LLM_API --> LLM_GW + + %% 网关到路由器 + HTTP_GW --> Router + WS_GW --> Router + LLM_GW --> Router + + %% 中间件链 + Router --> Audit --> Timing + + %% 路由器到模块 + Timing --> LLM_Core + Timing --> Echo + Timing --> Hippo + Timing --> HippoFull + Timing --> Amyg + Timing --> Prefrontal + Timing --> Cerebellum + Timing --> Hypothalamus + + %% 模块内部调用 + LLM_Core --> ModelRouter + ModelRouter --> MainLLM + ModelRouter --> ToolLLM + ModelRouter --> VisionLLM + + HippoFull --> SessionStore + HippoFull --> MemoryMgr + HippoFull --> KnowledgeBase + + Amyg --> EmotionAnalyzer + Amyg --> PlutchikWheel + + Prefrontal --> ScheduleMgr + Prefrontal --> EventMgr + Prefrontal --> ProactiveEngine + + Cerebellum --> VisionTool + Cerebellum --> ScheduleIntent + Cerebellum --> NPSBridge + Cerebellum --> ExpressionMgr + + Hypothalamus --> LifeState + Hypothalamus --> HabitTracker + + %% 数据库访问 + SessionStore --> SQLite + MemoryMgr --> SQLite + KnowledgeBase --> SQLite + ScheduleMgr --> SQLite + EventMgr --> SQLite + LifeState --> SQLite + HabitTracker --> SQLite +``` + +--- + +## 三、数据流转图(Packet 流转) + +展示一个典型请求中 Packet 如何在各模块间流转。 + +```mermaid +sequenceDiagram + participant Client as Web 前端 + participant HTTP as HTTP Gateway + participant Router as CentralRouter + participant Middleware as 中间件链
(Audit + Timing) + participant Cortex as LLMCore + participant ModelRouter as ModelRouter + participant LangChain as LangChainLLM + participant Amyg as AmygdalaModule + participant Hippo as HippocampusModule + participant Prefrontal as PrefrontalModule + participant DB as SQLite + + Note over Client,DB: 场景:用户发送聊天请求 + + Client->>HTTP: POST /api/v4/chat
{content: "帮我安排明天下午3点开会"} + + HTTP->>Router: Packet{
source: "web.http",
target: "cortex.llm_core",
channel: "llm_chat",
payload: {messages: [...]}
} + + Router->>Middleware: route(packet) + + Note over Middleware: AuditMiddleware
记录 trace_id、source、target + + Middleware->>Middleware: TimingMiddleware
记录开始时间 + + Middleware->>Cortex: handle(packet) + + Cortex->>ModelRouter: route("main") + + ModelRouter-->>Cortex: main_llm (LangChainLLM) + + Cortex->>LangChain: chat(messages) + + Note over LangChain: 1. 转换消息格式
2. 调用 OpenAI API
3. 解析响应 + + LangChain-->>Cortex: "好的,我来帮你安排会议" + + Cortex-->>Middleware: Packet{
type: RESPONSE,
payload: {
role: "assistant",
content: "好的..."
}
} + + Middleware->>Middleware: TimingMiddleware
计算耗时 + + Note over Middleware: AuditMiddleware
记录完成状态 + + Middleware-->>Router: response_packet + + Router-->>HTTP: response_packet + + HTTP-->>Client: JSONResponse{
data: {
role: "assistant",
content: "好的..."
},
trace_id: "abc123"
} + + Note over Client,DB: 后续:LLM 决定调用日程工具 + + Client->>HTTP: POST /api/v4/gateway/prefrontal.planner/schedule_add + + HTTP->>Router: Packet{
target: "prefrontal.planner",
channel: "schedule_add",
payload: {
title: "团队会议",
start_time: "2026-07-18T15:00:00"
}
} + + Router->>Prefrontal: handle(packet) + + Prefrontal->>DB: INSERT INTO schedules... + + DB-->>Prefrontal: schedule_id + + Prefrontal-->>Router: Packet{
payload: {
success: true,
schedule: {...}
}
} + + Router-->>HTTP: response + + HTTP-->>Client: {success: true, schedule: {...}} + + Note over Client,DB: 并行:情感分析(事件驱动) + + Amyg->>Router: emit("emotion_updated", {
user_id: "default",
emotions: {joy: 0.6, trust: 0.4}
}) + + Router->>Router: _dispatch_event(packet) + + Note over Router: 事件广播给所有订阅者
(如前端 WebSocket) + + Router-->>Client: WebSocket 推送
{event: "emotion_updated", ...} +``` + +--- + +## 四、调用链分析图 + +展示从用户请求到最终响应的完整调用链路,包含错误处理和降级策略。 + +```mermaid +flowchart TD + Start([用户发起请求]) --> HTTP[HTTP Gateway
/api/v4/*] + + HTTP --> Parse[解析 FastAPI Request] + Parse --> BuildPacket[构建 Packet
trace_id = uuid4] + + BuildPacket --> Router[CentralRouter.route] + + Router --> MiddlewareChain{中间件链} + + MiddlewareChain --> Audit[AuditMiddleware
记录审计日志] + Audit --> Timing[TimingMiddleware
记录开始时间] + + Timing --> LookupModule{查找目标模块} + + LookupModule -->|模块存在| Dispatch[分发到模块.handle] + LookupModule -->|模块不存在| Error404[返回 ERROR
code: MODULE_NOT_FOUND] + + Dispatch --> ModuleHandle{模块类型} + + ModuleHandle -->|cortex.llm_core| LLM1 + ModuleHandle -->|limbic.amygdala| Emo1 + ModuleHandle -->|limbic.hippocampus.full| Mem1 + ModuleHandle -->|prefrontal.planner| Sch1 + ModuleHandle -->|cerebellum.toolkit| Tool1 + ModuleHandle -->|hypothalamus.homeostasis| State1 + + subgraph LLMFlow [LLM 推理流程] + LLM1[LLMCore.handle] --> LLM2{channel?} + LLM2 -->|llm_chat| LLM3[ModelRouter.route] + LLM3 --> LLM4[LangChainLLM.chat] + LLM4 --> LLM5[ChatOpenAI.invoke] + LLM5 --> LLM6[解析响应] + LLM6 --> LLM7[返回 RESPONSE Packet] + end + + subgraph EmotionFlow [情感分析流程] + Emo1[AmygdalaModule.handle] --> Emo2{channel?} + Emo2 -->|emotion_analyze| Emo3[EmotionRelationshipAnalyzer.analyze] + Emo3 --> Emo4[查询 emotion_history 表] + Emo4 --> Emo5[计算累加评分] + Emo5 --> Emo6[更新 Plutchik 8 维状态] + Emo6 --> Emo7[返回 RESPONSE Packet] + end + + subgraph MemoryFlow [记忆管理流程] + Mem1[HippocampusModule.handle] --> Mem2{channel?} + Mem2 -->|memory_store| Mem3[LongTermMemoryManager.add_message] + Mem3 --> Mem4[INSERT INTO short_term_memory] + Mem4 --> Mem5[检查是否需要概括] + Mem5 --> Mem6[返回 RESPONSE Packet] + end + + subgraph ScheduleFlow [日程管理流程] + Sch1[PrefrontalModule.handle] --> Sch2{channel?} + Sch2 -->|schedule_add| Sch3[ScheduleManager.add_schedule] + Sch3 --> Sch4[检查时间冲突] + Sch4 --> Sch5[INSERT INTO schedules] + Sch5 --> Sch6[返回 RESPONSE Packet] + end + + subgraph ToolFlow [工具调用流程] + Tool1[CerebellumModule.handle] --> Tool2{channel?} + Tool2 -->|nps_call| Tool3[NPSBridgeTool.call_nps_tool] + Tool3 --> Tool4[NPSInvoker.invoke] + Tool4 --> Tool5[执行工具函数] + Tool5 --> Tool6[返回 RESPONSE Packet] + end + + subgraph StateFlow [生命状态流程] + State1[HypothalamusModule.handle] --> State2{channel?} + State2 -->|life_state_snapshot| State3[LifeStateManager.get_current_state_snapshot] + State3 --> State4[查询 life_state_daily 表] + State4 --> State5[计算能量/情绪/健康] + State5 --> State6[返回 RESPONSE Packet] + end + + LLM7 --> Response[响应 Packet] + Emo7 --> Response + Mem6 --> Response + Sch6 --> Response + Tool6 --> Response + State6 --> Response + + Response --> TimingEnd[TimingMiddleware
计算总耗时] + TimingEnd --> AuditEnd[AuditMiddleware
记录完成状态] + AuditEnd --> HTTPResponse[HTTP Gateway
构建 JSONResponse] + HTTPResponse --> Client([客户端接收响应]) + + Error404 --> HTTPResponse + + %% 错误处理 + LLM5 -.->|API 调用失败| LLMError[捕获异常] + LLMError --> LLMFallback[返回错误消息
抱歉处理请求时出现错误] + LLMFallback --> LLM7 + + Emo4 -.->|数据库错误| EmoError[捕获异常] + EmoError --> EmoFallback[返回 ERROR Packet
code: DATABASE_ERROR] + EmoFallback --> Response + + Sch4 -.->|时间冲突| SchConflict[返回 success=false
message: 时间冲突] + SchConflict --> Response + + %% 样式 + classDef gateway fill:#e1f5ff,stroke:#0288d1,stroke-width:2px + classDef router fill:#fff3e0,stroke:#f57c00,stroke-width:2px + classDef module fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + classDef database fill:#e8f5e9,stroke:#388e3c,stroke-width:2px + classDef error fill:#ffebee,stroke:#c62828,stroke-width:2px + + class HTTP,Parse,BuildPacket,HTTPResponse gateway + class Router,MiddlewareChain,Audit,Timing,LookupModule,Dispatch router + class LLM1,LLM2,LLM3,LLM4,LLM5,LLM6,LLM7,Emo1,Emo2,Emo3,Emo4,Emo5,Emo6,Emo7,Mem1,Mem2,Mem3,Mem4,Mem5,Mem6,Sch1,Sch2,Sch3,Sch4,Sch5,Sch6,Tool1,Tool2,Tool3,Tool4,Tool5,Tool6,State1,State2,State3,State4,State5,State6 module + class Error404,LLMError,EmoError,SchConflict error +``` + +--- + +## 五、模块通道(Channel)速查表 + +帮助开发者快速定位"我应该调用哪个模块的哪个通道"。 + +| 模块 ID | 通道 (Channel) | 功能说明 | 典型使用场景 | +|---------|---------------|---------|-------------| +| `cortex.llm_core` | `llm_chat` | 同步 LLM 聊天 | 普通对话回复 | +| `cortex.llm_core` | `llm_stream` | 流式 LLM 聊天 | 长文本生成 | +| `cortex.llm_core` | `llm_template` | 模板化 LLM 调用 | 系统提示词 + 变量 | +| `cortex.llm_core` | `llm_info` | 获取模型信息 | 显示当前模型配置 | +| `limbic.amygdala` | `emotion_analyze` | 分析对话情感关系 | 每轮对话后更新情感 | +| `limbic.amygdala` | `emotion_latest` | 获取最新情感数据 | 显示情感面板 | +| `limbic.amygdala` | `emotion_trend` | 获取情感趋势 | 情感历史图表 | +| `limbic.amygdala` | `emotion_tone` | 生成语气提示 | LLM 回复语气调整 | +| `limbic.amygdala` | `emotion_wheel_profile` | Plutchik 情绪轮推导 | 复合情绪分析 | +| `limbic.hippocampus.full` | `memory_query` | 查询相关记忆 | 检索历史对话 | +| `limbic.hippocampus.full` | `memory_store` | 存储消息 | 保存用户/助手消息 | +| `limbic.hippocampus.full` | `memory_context` | 获取聊天上下文 | 构建 LLM 上下文 | +| `limbic.hippocampus.full` | `memory_stats` | 获取记忆统计 | 显示记忆使用情况 | +| `limbic.hippocampus.full` | `session_create` | 创建会话 | 新建聊天会话 | +| `limbic.hippocampus.full` | `session_list` | 列出会话 | 显示会话列表 | +| `limbic.hippocampus.full` | `session_get` | 获取会话详情 | 加载会话历史 | +| `limbic.hippocampus.full` | `session_delete` | 删除会话 | 删除聊天会话 | +| `limbic.hippocampus.full` | `message_add` | 添加消息 | 保存单条消息 | +| `limbic.hippocampus.full` | `message_list` | 列出消息 | 分页加载消息 | +| `prefrontal.planner` | `schedule_list` | 列出日程 | 显示日程列表 | +| `prefrontal.planner` | `schedule_add` | 创建日程 | 添加新日程 | +| `prefrontal.planner` | `schedule_delete` | 删除日程 | 取消日程 | +| `prefrontal.planner` | `proactive_should_send` | 主动消息决策 | 判断是否发送主动消息 | +| `prefrontal.planner` | `proactive_schedule_next` | 排定下次主动消息 | 设置主动消息时间 | +| `prefrontal.planner` | `event_list` | 列出事件 | 显示事件列表 | +| `prefrontal.planner` | `event_add` | 创建事件 | 添加新事件 | +| `cerebellum.toolkit` | `vision_should_use` | 判断是否使用视觉 | 检测是否需要图像识别 | +| `cerebellum.toolkit` | `vision_context` | 获取视觉上下文 | 识别图像内容 | +| `cerebellum.toolkit` | `vision_summary` | 获取视觉摘要 | 总结图像内容 | +| `cerebellum.toolkit` | `vision_switch` | 切换环境 | 切换虚拟环境 | +| `cerebellum.toolkit` | `schedule_intent` | 识别日程意图 | 从对话提取日程信息 | +| `cerebellum.toolkit` | `interrupt_ask` | 中断提问 | 向用户确认关键信息 | +| `cerebellum.toolkit` | `nps_call` | 调用 NPS 工具 | 执行插件工具 | +| `cerebellum.toolkit` | `nps_list` | 列出 NPS 工具 | 显示可用工具 | +| `cerebellum.toolkit` | `expression_agent_add` | 添加智能体表达 | 学习新表达方式 | +| `cerebellum.toolkit` | `expression_agent_list` | 列出智能体表达 | 显示已学习表达 | +| `cerebellum.toolkit` | `expression_user_learn` | 学习用户表达习惯 | 适应用户语言风格 | +| `cerebellum.toolkit` | `expression_stats` | 获取表达统计 | 显示表达风格分析 | +| `hypothalamus.homeostasis` | `life_state_ensure` | 确保当日状态存在 | 初始化每日状态 | +| `hypothalamus.homeostasis` | `life_state_snapshot` | 获取状态快照 | 显示当前生命状态 | +| `hypothalamus.homeostasis` | `life_state_transition` | 应用状态转移 | 状态变化(如疲劳→休息) | +| `hypothalamus.homeostasis` | `life_state_prompt` | 渲染状态为 prompt | 将状态注入 LLM 提示词 | +| `hypothalamus.homeostasis` | `life_state_body_cycle` | 计算生理周期 | 生物钟模拟 | +| `hypothalamus.homeostasis` | `life_state_consume_dream_delta` | 消费梦境能量差值 | 梦境系统能量管理 | +| `hypothalamus.homeostasis` | `habit_update` | 更新习惯候选 | 从对话学习用户习惯 | +| `hypothalamus.homeostasis` | `habit_qualified` | 获取已确认习惯 | 显示已确认的习惯 | +| `hypothalamus.homeostasis` | `habit_proactive_event` | 触发习惯主动事件 | 基于习惯发送主动消息 | +| `hypothalamus.homeostasis` | `habit_format_schedule` | 渲染习惯为 schedule 格式 | 习惯转日程 | + +--- + +## 六、架构优势总结 + +### 6.1 高内聚低耦合 + +- **模块职责清晰**:每个模块对应人脑特定认知功能区,职责边界明确 +- **通过 Packet 通信**:模块间不直接调用方法,而是通过 CentralRouter 路由 Packet,降低耦合度 + +### 6.2 可追踪性 + +- **全链路 trace_id**:每个 Packet 携带 trace_id,贯穿整个请求生命周期 +- **中间件审计**:AuditMiddleware 记录每次路由的 source、target、耗时 +- **性能监控**:TimingMiddleware 自动计算每个请求的处理时间 + +### 6.3 可扩展性 + +- **新增模块简单**:继承 BaseModule,实现 handle 方法,注册到 CentralRouter 即可 +- **新增通道简单**:在模块的 handle 方法中添加 channel 分支即可 +- **中间件可插拔**:通过 add_middleware 动态添加审计、限流、鉴权等中间件 + +### 6.4 向后兼容 + +- **v3.1.0 兼容层**:`src/core/*` 保留原接口,通过导入转发访问新架构 +- **双轨运行**:v3 API 和 v4 API 可同时运行,平滑迁移 + +### 6.5 事件驱动 + +- **发布-订阅模式**:模块可通过 emit 发布事件,其他模块可订阅 +- **异步解耦**:事件通过事件队列异步分发,不阻塞主流程 + +--- + +## 七、下一步方向 + +1. **WebSocket 网关完善**:将 `/ws/chat` 等 WebSocket 路由接入 CentralRouter +2. **LLM 流式支持**:在 LLMCore 中实现真正的流式响应(SSE / WebSocket) +3. **模块间协作**:实现跨模块的复杂工作流(如:对话 → 情感分析 → 日程创建 → 主动确认) +4. **性能优化**:为高频通道(如 memory_store)添加缓存层 +5. **监控告警**:基于 AuditMiddleware 的日志,接入 Prometheus + Grafana 监控 + +--- + +> 本文档由 v4.0 架构梳理自动生成,反映当前代码实际结构。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3382cc5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,434 @@ +# Neo Agent Web 架构文档 + +本文档描述 Neo Agent v3.0.0+ 引入的 Web GUI / API(FastAPI + React)整体架构、数据流与部署模型。 + +--- + +## 1. 总体架构图(ASCII) + +``` ++----------------------------------------------------------------+ +| 浏览器 / 客户端 | +| (Chat / Debug / Knowledge / Schedule / Event / NPS / Database | +| / Creative + React 18 + Vite 5 + Ant Design 5) | ++--------------------+---------------------+----------------------+ + | HTTP/REST | WebSocket (full-dup) + v v ++----------------------------------------------------------------+ +| 后端 FastAPI 0.110+ (Uvicorn, src/web/backend) | +| +-------------------+ +--------------------------------+ | +| | REST API | | WebSocket 路由 | | +| | /api/chat | | /ws/chat (流式回复) | | +| | /api/knowledge | | /ws/events (EventManager) | | +| | /api/schedule | | /ws/debug (DebugLogger) | | +| | /api/events | | /ws/proactive (主动消息) | | +| | /api/nps/... | +--------------------------------+ | +| | /api/database | | +| | /api/creative | Pydantic v2 Schemas | +| +-------------------+ (强约束请求/响应) | +| | | +| v | +| +----------------------------------------------------------+ | +| | Service Layer (Service) | | +| | ChatService / KnowledgeService / EventService / | | +| | ScheduleService / DatabaseService / CreativeService | | +| +----------------------------------------------------------+ | ++----------------------------------------------------------------+ + | + v ++----------------------------------------------------------------+ +| 核心业务层 src/core (GUI 无关, Web / API 共用) | +| ChatAgent KnowledgeBase ScheduleManager EmotionAnalyzer | +| LongTermMemory ProactiveEngine BackgroundScheduler | +| EventManager (单例, 总线) | ++----------------------------------------------------------------+ + | + v + +-------------------------+ + | SQLite chat_agent.db | + | WAL mode (并发读安全) | + +-------------------------+ +``` + +三层职责: + +- **前端 (Presentation)**:纯 UI + 状态管理 + WebSocket 订阅,不持有业务规则 +- **后端 (Application)**:REST 路由 + WS 路由 + Service 编排 + Schema 校验 +- **数据库 (Persistence)**:SQLite + WAL,业务层统一通过 `DatabaseManager` 访问 + +--- + +## 2. 前后端数据流 + +Neo Agent Web 端同时使用 **REST** 与 **WebSocket** 两种通信方式,按业务场景选型。 + +### 2.1 REST API(请求/响应模型) + +适用于:单次调用、明确边界、需要缓存/重试的场景。 + +| 场景 | 示例端点 | +| --- | --- | +| 健康检查 | `GET /api/health` | +| 知识库查询 | `GET /api/knowledge` / `POST /api/knowledge/search` | +| 日程管理 | `GET/POST/PUT/DELETE /api/schedule/...` | +| 事件查询 | `GET /api/events` | +| NPS 状态 | `GET /api/nps/status` | +| 数据库元信息 | `GET /api/database/tables` | +| 创意生成 | `POST /api/creative/generate` | +| 非流式聊天 | `POST /api/chat`(同步返回完整回复) | + +特点: +- 基于 Pydantic v2 做请求/响应 schema 校验 +- 错误通过 HTTP 状态码 + JSON 体返回 +- 前端用 **Axios** 调用,统一拦截器处理错误 + +### 2.2 WebSocket 端到端数据流(ASCII) + +``` +[浏览器 Chat 页] + | 1) ws.send({type:"user_message", text:"hi"}) + v +[FastAPI /ws/chat] + | + v +[ChatService.handle_user_message] + | + v +[ChatAgent.chat_stream()] <- 异步生成器 + | + | astream token ... token + v +[ChatService] yield {type:"token", delta:"..."} + | + v +[WebSocket Connection Manager] broadcast(json) + | + v +[浏览器 Chat 页] 逐 token 渲染 + +[ChatAgent 完成] yield {type:"done", full_text:"..."} + | + v +[ChatAgent] EventManager.publish("chat_completed", payload) + | + v +[EventService] (后台 task 把事件投到 /ws/events) + | + v +[浏览器] 监听 /ws/events 的 Debug / Event 页签更新 +``` + +要点: +- 单次会话的 `chat_stream` 是一条 **async generator**,每段 token 都是一次 `yield` +- `EventManager` 单例:业务层 `publish` → `EventService` 桥接 → `/ws/events` 广播,**Web 层不直接侵入业务** +- 异常保护:单个 listener 抛错不会影响其他 listener(见 `EventManager.publish` try/except) + +### 2.3 WebSocket(双向推送模型) + +适用于:长连接、服务端主动推送、流式输出。 + +| 通道 | 推送内容 | +| --- | --- | +| `/ws/chat` | 单次会话的流式回复(token 级别)+ 完成事件 | +| `/ws/events` | 来自 `EventManager` 的全局事件(日程触发、主动消息、生命周期变化等) | +| `/ws/debug` | 调试日志流(来自 `DebugLogger`) | +| `/ws/proactive` | 主动消息推送(无需用户输入) | + +特点: +- 单向广播(服务端 → 客户端)为主,客户端可发心跳 +- 前端通过 `WebSocket Client`(hooks 层)订阅各通道 +- 事件通过 `EventService` 单例从 `EventManager` 桥接,零侵入业务层 + +### 2.4 选型对照 + +| 维度 | REST | WebSocket | +| --- | --- | --- | +| 触发方 | 客户端 | 服务端/客户端皆可 | +| 实时性 | 请求后才返回 | 服务端可随时推送 | +| 适用 | CRUD、查询、配置变更 | 流式输出、事件广播 | +| 资源占用 | 短连接 | 长连接,少量并发 | +| 重试 | 易(HTTP 重试) | 需前端重连逻辑 | + +--- + +## 3. 模块依赖图 + +依赖规则:**`src/core/*` 不依赖 `src/web/*`**;`src/web/*` 通过 Service 层包装 core,反向依赖。 + +``` ++----------------------+ +-----------------------+ +| src/web/frontend | HTTP | src/web/backend | +| (React + Vite) | ------> | (FastAPI) | +| | WS | | ++----------------------+ ------> +-----------+-----------+ + | + | uses (Service wrapping) + v ++----------------------+ +-----------------------+ +| src/web/backend | ------> | src/core/* | +| services/ | | ChatAgent, | +| (ChatService, ...) | | KnowledgeBase, | ++----------------------+ | ScheduleManager, ... | + | EventManager, | + | BackgroundScheduler | + +-----------+-----------+ + | + v + +-----------------------+ + | src/tools/* | + | (DebugLogger 等) | + +-----------+-----------+ + | + v + +-----------------------+ + | chat_agent.db | + | (SQLite, WAL) | + +-----------------------+ +``` + +约束: + +1. `src/core/*` **严禁** `import` 任何 `src/web/*` +2. `src/web/backend/services/*` 持有 `core` 对象的引用(通过构造注入或模块级单例) +3. `src/web/backend/api/*` 与 `src/web/backend/ws/*` **不直接**访问 `core`,统一通过 `services/` +4. `src/web/frontend` 与 `src/core/*` 之间没有任何代码引用;二者通过 HTTP/WS + JSON 通信 +5. 允许的反向引用:core 内部模块互相引用;`web -> services -> core` 单向 + +这样在 v3.0.0 中替换为 Web GUI 时,**`src/core/*` 一行未改**,所有改动都被限制在 `src/web/*` 内。 + +--- + +## 4. 并行调度说明 + +Neo Agent v3.0.0 在同一进程内同时跑两类"长时间后台"组件:FastAPI/uvicorn 异步事件循环、BackgroundScheduler / EventManager 等后台线程。 + +### 4.1 BackgroundScheduler(独立子线程) + +``` ++------------------------------------------------------+ +| Python 主进程 | +| +-------------------+ +----------------------+ | +| | FastAPI / uvicorn | | BackgroundScheduler | | +| | (async event | | (独立子线程, | | +| | loop, 主循环) | | asyncio.run 私有 | | +| | | | event loop) | | +| +---------+---------+ +----------+-----------+ | +| | | | +| | await | await | +| v v | +| +------------------------------------------+ | +| | EventManager 单例 (跨线程共享) | | +| +------------------------------------------+ | +| | | +| v | +| +------------------------------------------+ | +| | DatabaseManager 单例 (WAL, 线程安全) | | +| +------------------------------------------+ | ++------------------------------------------------------+ +``` + +要点: + +- `BackgroundScheduler.start()` 启动一个 `daemon=True` 子线程 +- 子线程内部用 `asyncio.run(...)` 跑自己的 event loop,**与 FastAPI 主循环物理隔离** +- 每个 tick(默认 60s)采集状态快照并通过 `EventManager.publish("scheduler_tick", payload)` 推送给订阅者 +- 子线程异常由 scheduler 内部 try/except 兜住,**不会冒泡到 FastAPI 主循环** +- 关闭时由 `_stop_event` 触发优雅退出,等待 `self._thread.join(timeout=5)` + +### 4.2 EventManager(单例,跨线程) + +``` ++----------+ publish("proactive_message") +----------------+ +|Proactive | --------------------------------------> | EventManager | +|Engine | | (单例) | ++----------+ +--------+-------+ + | subscribe ++----------+ publish("scheduler_tick") | +|Background| --------------------------------------------+ | +|Scheduler | | | ++----------+ v v + +-----+-----+----+ + | EventService | + | (Web 后端) | + +-------+-------+ + | broadcast + v + +-------+-------+ + | /ws/events | + | 浏览器 | + +---------------+ +``` + +要点: + +- `EventManager` 是**进程内单例**,所有模块共享同一个实例 +- 订阅通过 `subscribe(event_type, callback)` 注册,回调签名 `callback(event_type, payload)` +- 业务层 `publish(...)` 调用零侵入;Web 端 `EventService` 在启动时 `subscribe("*", ...)` 接管广播 +- publish 内部对每个 listener 都用 try/except 包裹,单个失败不影响其他订阅者 + +### 4.3 Web 端推送回路 + +- FastAPI 启动时 `EventService.start()`,把 EventManager 的事件投到 `/ws/events` +- WAL 模式下数据库可并发读,单写者安全 + +--- + +## 5. mermaid 总览图(参考) + +> 本节与上方 ASCII 图表达同一架构;保留以便在支持 mermaid 的渲染器(GitLab / Obsidian)下阅读。 + +```mermaid +graph LR + Browser[浏览器] + Mobile[移动端浏览器] + Tablet[平板浏览器] + + subgraph Frontend["前端 (React 18 + Vite + Ant Design 5)"] + Pages[8 个页面
Chat / Debug / Knowledge / Schedule / Event / NPS / Database / Creative] + Store[Zustand Store] + WSC[WebSocket Client] + end + + subgraph Backend["后端 (FastAPI 0.110 + Uvicorn)"] + REST[REST API
/api/chat /api/knowledge ...] + WSR[WebSocket Routes
/ws/chat /ws/events /ws/debug /ws/proactive] + Service[Service Layer
ChatService / KnowledgeService / EventService / ...] + Schemas[Pydantic Schemas] + end + + subgraph Core["核心业务层 (src/core, GUI 无关)"] + ChatAgent[ChatAgent] + KBase[KnowledgeBase] + Sched[ScheduleManager] + Emotion[EmotionAnalyzer] + Memory[LongTermMemory] + DBSched[BackgroundScheduler] + EventMgr[EventManager] + end + + DB[(SQLite chat_agent.db
WAL 模式)] + + Browser -->|HTTP REST| REST + Mobile -->|HTTP REST| REST + Tablet -->|HTTP REST| REST + + Browser -->|WS| WSR + Mobile -->|WS| WSR + Tablet -->|WS| WSR + + Pages --> Store + Pages --> WSC + WSC -->|实时推送| WSR + WSC --> Store + + REST --> Schemas + WSR --> Schemas + Schemas --> Service + + Service --> ChatAgent + Service --> KBase + Service --> Sched + Service --> Emotion + Service --> Memory + + ChatAgent --> EventMgr + KBase --> EventMgr + Sched --> EventMgr + DBSched -->|publish tick / status| EventMgr + EventMgr -->|broadcast| WSR + + ChatAgent --> DB + KBase --> DB + Sched --> DB + Emotion --> DB + Memory --> DB + DBSched --> DB +``` + +--- + +## 6. 数据共享 + +Web GUI / API 与业务核心共用同一份数据库,无需数据迁移。 + +### 6.1 数据库位置 + +``` +/chat_agent.db +``` + +- SQLite 单一文件,相对路径存储 +- **已启用 WAL 模式**(Stage E.3 期间在 `database_manager.py` 中开启),支持多连接并发读、单写者安全 +- 均通过 `DatabaseManager` 单例访问,业务层无差异 + +### 6.2 数据一致性 + +| 场景 | 行为 | +| --- | --- | +| 多进程/多线程同时访问 | WAL 模式可并发读,单写者安全;写入交错时由 SQLite 串行化保证一致性 | + +### 6.3 共享的核心模块 + +以下模块由 Web / API 与业务层共用,**未做任何 GUI 耦合**: + +- `src/core/chat_agent.py`(新增 `chat_stream()` 异步生成器) +- `src/core/knowledge_base.py` +- `src/core/schedule_manager.py` +- `src/core/emotion_analyzer.py` +- `src/core/long_term_memory.py` +- `src/core/database_manager.py`(WAL 模式) +- `src/core/background_scheduler.py`(每个 tick 推状态到 `EventManager`) +- `src/core/event_manager.py`(事件总线,Web 端 `EventService` 订阅它做 WS 广播) + +--- + +## 7. 后台调度(速查) + +`BackgroundScheduler` 是一个独立线程中的周期任务(默认 60s/tick),与 Web / API 层解耦: + +- 随 FastAPI 启动/停止 +- 每个 tick 完成后通过 `EventManager` 发布状态事件,Web 端 `/ws/events` 可实时观察到调度器心跳 + +禁用方式: +- `.env` 中 `ENABLE_BACKGROUND_SCHEDULER=false` + +--- + +## 8. 部署模型 + +### 8.1 开发态 + +``` +[浏览器] ---HTTP/WS---> [Vite dev server :5173] ---proxy---> [FastAPI :8000] + |- EventService + `- BackgroundScheduler +``` + +### 8.2 生产态 + +``` +[浏览器] ---HTTP/WS---> [Nginx/Caddy] ---> [FastAPI :8000 (uvicorn --workers N)] + |- EventService + `- BackgroundScheduler +``` + +前端使用 `npm run build` 产物(`dist/`)由 Nginx 直接提供。 + +--- + +## 9. 安全与并发 + +- 开发态:单进程单用户,仅本地/内网访问 +- 生产态:建议加 Nginx 反向代理 + Basic Auth / OAuth 前置层 +- WebSocket:每个连接一个独立 Task,自动随客户端断开清理 +- 写入并发:SQLite WAL 模式 + 业务层加锁,串行化写 + +--- + +## 10. 参考 + +- [`architecture-v4-diagrams.md`](architecture-v4-diagrams.md) — v4.0 神经系统架构图 +- [`api.md`](api.md) — Web REST / WebSocket 端点速查 +- [`core-api.md`](core-api.md) — 核心类与工具 API 参考 +- [`TECHNICAL.md`](TECHNICAL.md) — 业务模块技术细节 +- [`../README.md`](../README.md) — 项目入口 diff --git a/docs/API.md b/docs/core-api.md similarity index 94% rename from docs/API.md rename to docs/core-api.md index 64fff8c..905333d 100644 --- a/docs/API.md +++ b/docs/core-api.md @@ -269,41 +269,6 @@ create_treeview_tooltip(treeview, column_texts={ --- -## GUI API - -### EnhancedChatDebugGUI - -主图形界面。 - -```python -import tkinter as tk -from src.gui.gui_enhanced import EnhancedChatDebugGUI - -# 创建主窗口 -root = tk.Tk() - -# 创建GUI实例 -app = EnhancedChatDebugGUI(root) - -# 启动主循环 -root.mainloop() -``` - ---- - -### DatabaseGUI - -数据库管理界面。 - -```python -from src.gui.database_gui import DatabaseGUI - -# 在主GUI中集成 -db_gui = DatabaseGUI(parent_frame) -``` - ---- - ## 数据结构 / Data Structures ### 对话记录 / Conversation Record diff --git a/docs/setup-local-dev.md b/docs/setup-local-dev.md new file mode 100644 index 0000000..afc79f5 --- /dev/null +++ b/docs/setup-local-dev.md @@ -0,0 +1,341 @@ +# Neo Agent v3.0.0 — 本地开发环境配置 + +> 5 分钟跑起来 Web GUI。 + +## 一键配置(推荐) + +```bash +cd Neo_Agent +chmod +x setup-dev.sh +./setup-dev.sh +``` + +脚本会自动: +1. 检查 Python ≥ 3.10 + Node.js ≥ 18 +2. 从 `.env.example` 复制生成 `.env` +3. 创建 `.venv` 虚拟环境 +4. 安装 `requirements.txt` + `requirements-web.txt` +5. `npm install` 前端依赖 + +完成后**只需填 API Key**: + +```bash +vim .env +# 改这两行: +# SILICONFLOW_API_KEY=sk-... +# SERPAPI_API_KEY=... +``` + +## 启动方式(任选其一) + +### 方式 1:一键启动脚本(推荐) + +```bash +./start.sh +# 自动 activate venv + ENABLE_FRONTEND_DEV=1 + python run_web.py +``` + +打开浏览器 → http://localhost:8000 + +### 方式 2:手动启动 Web GUI + +```bash +source .venv/bin/activate +ENABLE_FRONTEND_DEV=1 python main.py --web +``` + +### 方式 3:纯 API 模式(无 Vite 热重载) + +```bash +source .venv/bin/activate +python main.py --web --no-dev +``` + +适合生产部署或无前端 dev server 场景。 + +## 启动参数速查 + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--port` | 8000 | FastAPI 监听端口 | +| `--host` | 0.0.0.0 | FastAPI 监听地址 | +| `--no-dev` | | 跳过 Vite dev server | +| `--no-static` | | 不挂载前端构建产物(纯 API) | +| `--reload` | | uvicorn 代码热重载(仅开发态) | + +## 环境变量速查(.env) + +| 变量 | 必填 | 说明 | +|---|---|---| +| `SILICONFLOW_API_KEY` | ✅ | LLM API 密钥 | +| `SERPAPI_API_KEY` | ✅ | 网络搜索 API 密钥 | +| `MAIN_MODEL_NAME` | | 主模型(默认 `deepseek-ai/DeepSeek-V3.2`) | +| `TOOL_MODEL_NAME` | | 小模型(默认 `zai-org/GLM-4.6V`) | +| `VISION_MODEL_NAME` | | 多模态模型(默认 `Qwen/Qwen3-VL-32B-Instruct`) | +| `ENABLE_FRONTEND_DEV` | | Vite dev server 开关(0/1) | +| `WEB_HOST` | | FastAPI 监听地址 | +| `WEB_PORT` | | FastAPI 监听端口 | +| `CORS_ORIGINS` | | CORS 允许来源(逗号分隔) | +| `CHAT_AGENT_DB` | | SQLite 路径 | +| `LOG_LEVEL` | | Web 日志级别(DEBUG/INFO/WARNING/ERROR) | +| `ENABLE_LIFE_STATE` | | 生活状态开关 | +| `ENABLE_PROACTIVE_ENGINE` | | 主动消息开关 | +| `ENABLE_EMOTION_WHEEL` | | 情感雷达开关 | +| `ENABLE_CREATIVE_WRITER` | | 创意写作开关 | + +## 端口规划 + +| 端口 | 用途 | 启动者 | +|---|---|---| +| 8000 | FastAPI 后端 | `python main.py --web` | +| 5173 | Vite dev server(HMR) | `ENABLE_FRONTEND_DEV=1` 时自动启动 | +| 前端 `/api/*` 代理 → 8000 | dev 模式反代 | vite.config.ts | +| 前端 `/ws/*` 代理 → 8000 | dev 模式反代 | vite.config.ts | + +## 常见问题 + +### Q1: `pip install` 失败 +- 检查网络/代理 +- 升级 pip:`python -m pip install --upgrade pip` +- 改用国内源: + ```bash + python -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt + ``` + +### Q2: 前端 5173 端口被占用 +```bash +lsof -i :5173 +kill +# 或修改 vite.config.ts 的 server.port +``` + +### Q3: 8000 端口被占用 +```bash +python main.py --web --port 8080 +``` + +### Q4: WebSocket 连不上 +- 检查防火墙是否放行 8000 / 5173 +- 检查 `CORS_ORIGINS` 是否包含实际访问的 origin +- 浏览器 DevTools → Network → WS 帧查看是否握手成功 + +## 目录结构(关键文件) + +``` +Neo_Agent/ +├── .env # 本地敏感配置(gitignored) +├── .env.example # 配置模板(已提交) +├── main.py # 启动入口(Web / API) +├── run_web.py # Web 启动脚本 +├── start.sh # 一键启动 +├── setup-dev.sh # 一键配置(新增) +├── requirements.txt # Python 核心依赖 +├── requirements-web.txt # Python Web 依赖 +├── src/ +│ ├── core/ # 业务核心(只读,不要改) +│ └── web/ +│ ├── backend/ # FastAPI 后端 +│ └── frontend/ # React + Vite 前端 +│ ├── package.json +│ └── vite.config.ts +└── docs/ + └── setup-local-dev.md # 本文件 +``` + +## 验证启动 + +启动后访问: + +| URL | 说明 | +|---|---| +| http://localhost:8000/ | Web GUI 主页 | +| http://localhost:8000/docs | FastAPI Swagger | +| http://localhost:8000/api/health | 健康检查 | +| http://localhost:5173/ | Vite dev server(开发态) | + +健康检查应返回: +```json +{"status":"ok","version":"3.0.0","timestamp":"2026-07-14T..."} +``` + +## 常见工作流 + +### 第一次跑通 +```bash +./setup-dev.sh +vim .env # 填 API Key +./start.sh +# 浏览器开 http://localhost:8000 +``` + +### 改后端代码 → 自动热重载 +```bash +python main.py --web --reload +``` + +### 改前端代码 → 自动 HMR +Vite dev server 已自动启动,改完即热重载。 + +### 跑测试 +```bash +source .venv/bin/activate +python -m unittest discover tests -v +``` + +## 下一步 + +- 阅读 [CHANGELOG.md](../CHANGELOG.md) 了解 v3.0.0 变更 +- 阅读 [docs/architecture.md](architecture.md) 了解 Web 端架构 +- 阅读 [docs/api.md](api.md) 了解 REST/WS 端点 + +--- + +## LLM 供应商配置(v3.1.0+) + +v3.1.0 新增**供应商无关的 LLM 通用配置**。通过 `LLM_PROVIDER` 预设即可一键切换 OpenAI / DeepSeek / SiliconFlow / Moonshot / Zhipu / 自建网关,不再需要为每个供应商写不同变量名。 + +### 通用配置(推荐) + +```env +# 供应商预设:openai / deepseek / siliconflow / moonshot / zhipu / custom +LLM_PROVIDER=siliconflow +# API Key +LLM_API_KEY=sk-... +# 可选:自定义 base_url(custom 模式必填;preset 模式省略时使用厂商默认) +# LLM_BASE_URL=https://api.openai.com/v1 +# 可选:自定义模型名 +# LLM_MODEL_NAME=gpt-4o-mini +# 全局超时/温度/最大 tokens +LLM_TIMEOUT=60 +LLM_TEMPERATURE=0.7 +LLM_MAX_TOKENS=2000 +``` + +### 6 个 preset 的最小配置示例 + +| 供应商 | `LLM_PROVIDER` | 必填 | 默认 base_url | 默认 model | 申请地址 | +|---|---|---|---|---|---| +| OpenAI | `openai` | `LLM_API_KEY` | `https://api.openai.com/v1` | `gpt-4o-mini` | https://platform.openai.com/api-keys | +| DeepSeek | `deepseek` | `LLM_API_KEY` | `https://api.deepseek.com/v1` | `deepseek-chat` | https://platform.deepseek.com/api_keys | +| SiliconFlow | `siliconflow` | `LLM_API_KEY` | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V3.2` | https://cloud.siliconflow.cn/account/ak | +| Moonshot | `moonshot` | `LLM_API_KEY` | `https://api.moonshot.cn/v1` | `moonshot-v1-8k` | https://platform.moonshot.cn/console/api-keys | +| 智谱 Zhipu | `zhipu` | `LLM_API_KEY` | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` | https://open.bigmodel.cn/usercenter/apikeys | +| Custom | `custom` | `LLM_API_KEY` + `LLM_BASE_URL` + `LLM_MODEL_NAME` | (用户必填) | (用户必填) | - | + +#### OpenAI + +```env +LLM_PROVIDER=openai +LLM_API_KEY=sk-... +# 可选覆盖: +# LLM_MODEL_NAME=gpt-4o +``` + +#### DeepSeek + +```env +LLM_PROVIDER=deepseek +LLM_API_KEY=sk-... +# 可选覆盖: +# LLM_MODEL_NAME=deepseek-reasoner +``` + +#### SiliconFlow + +```env +LLM_PROVIDER=siliconflow +LLM_API_KEY=sk-... +# 可选覆盖: +# LLM_MODEL_NAME=Qwen/Qwen3-VL-32B-Instruct +``` + +#### Moonshot + +```env +LLM_PROVIDER=moonshot +LLM_API_KEY=sk-... +# 可选覆盖: +# LLM_MODEL_NAME=moonshot-v1-128k +``` + +#### Zhipu + +```env +LLM_PROVIDER=zhipu +LLM_API_KEY=... +# 可选覆盖: +# LLM_MODEL_NAME=glm-4-plus +``` + +#### Custom(自建网关/代理) + +```env +LLM_PROVIDER=custom +LLM_API_KEY=... +LLM_BASE_URL=https://my-proxy.example.com/v1 +LLM_MODEL_NAME=my-model-name +``` + +### 向后兼容 + +旧变量(v3.0.x)**仍可使用**,系统会自动 fallback: + +```env +# 仍可工作(fallback 到 LLM_API_KEY) +SILICONFLOW_API_KEY=sk-... +# 仍可工作(fallback 到 LLM_BASE_URL) +SILICONFLOW_API_URL=https://api.siliconflow.cn/v1/chat/completions +# 仍可工作(fallback 到 LLM_MODEL_NAME) +MODEL_NAME=deepseek-ai/DeepSeek-V3 +``` + +推荐迁移到新的 `LLM_*` 变量。`/api/llm/config` 与 `/api/llm/test` 不依赖具体供应商,可在前端 `/settings` 页面查看当前生效配置与一键测试连接。 + +### 三层模型独立配置 + +仍可通过 `MAIN_MODEL_NAME` / `TOOL_MODEL_NAME` / `VISION_MODEL_NAME` 为三层分别指定模型名(fallback 到 `LLM_MODEL_NAME`)。视觉/工具层可独立设置 `LLM_BASE_URL`(多供应商混合:主模型 OpenAI,视觉用 SiliconFlow Qwen-VL)。 + +--- + +## 会话管理(v3.1.0+) + +v3.1.0 新增**会话持久化**: + +- 所有聊天会话与消息存储到 `chat_sessions` / `chat_messages` 数据库表 +- 支持多会话切换、刷新页面自动恢复、删除级联 + +### Web 端使用 + +1. 打开 http://localhost:8000/chat +2. 顶部 toolbar 左侧点击「**会话**」按钮(📂 图标)→ 弹出左侧 SessionSidebar +3. SessionSidebar 操作: + - **+ 新建会话**:创建新空会话并切换 + - 点击列表项:切换到该会话,自动从后端拉取历史消息填充 + - hover 列表项右侧删除按钮:弹确认框 → 调 `DELETE /api/chat/sessions/{id}`,DB 级联删 messages + - 当前会话用蓝色背景 + 「当前」Tag 高亮 +4. 刷新页面:前端从 `localStorage.current_session_id` 读 ID 自动恢复 + +### REST API 速查 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/chat/sessions?user_id=default&limit=50` | 列出用户会话 | +| POST | `/api/chat/sessions` body `{user_id, title?}` | 创建新会话 | +| GET | `/api/chat/sessions/{id}/messages?limit=200` | 拉取会话消息(按 id 升序) | +| PATCH | `/api/chat/sessions/{id}` body `{title}` | 重命名 | +| DELETE | `/api/chat/sessions/{id}` | 级联删 messages(外键 ON DELETE CASCADE) | +| GET | `/api/chat/sessions/current?user_id=default` | 取用户最近一个活跃 session | + +### WebSocket 绑定 + +`/ws/chat?session_id=xxx` 接收后消息关联到该 session;未带 `session_id` 时后端自动创建并通过 `ack` 帧回传: + +```json +{ "type": "ack", "conn_id": "abc", "session_id": "42" } +``` + +前端收到 `ack.session_id` 后会自动写入 `localStorage.current_session_id` 持久化。 + +### 离线兜底 + +断网时前端会把最近 20 条 user/assistant 消息写入 `sessionStorage`(key: `chat_draft_`)。重连成功后从后端拉历史覆盖本地草稿;DB 拉取失败时回退用 draft 填充。 diff --git a/example.env b/example.env index 54e9115..bcc244b 100644 --- a/example.env +++ b/example.env @@ -1,3 +1,31 @@ +# ===================================================================== +# Neo Agent v3.1.0 — Example Environment Variables +# ===================================================================== + +# --------------------------------------------------------------------- +# 一、LLM 通用配置(v3.1.0+ 推荐使用) +# --------------------------------------------------------------------- +# 供应商预设:openai / deepseek / siliconflow / moonshot / zhipu / custom +LLM_PROVIDER=siliconflow +# API Key(推荐用 LLM_API_KEY,兼容旧 SILICONFLOW_API_KEY) +LLM_API_KEY= +# 自定义 base_url(preset 模式可省略,custom 模式必填) +# LLM_BASE_URL=https://api.openai.com/v1 +# 自定义模型名(preset 模式可省略) +# LLM_MODEL_NAME=gpt-4o-mini +# 全局超时/温度/最大 tokens +LLM_TIMEOUT=60 +LLM_TEMPERATURE=0.7 +LLM_MAX_TOKENS=2000 +# 三层模型独立名称(MAIN/TOOL/VISION) +# MAIN_MODEL_NAME=deepseek-ai/DeepSeek-V3.2 +# TOOL_MODEL_NAME=zai-org/GLM-4.6V +# VISION_MODEL_NAME=Qwen/Qwen3-VL-32B-Instruct + +# --------------------------------------------------------------------- +# 一·A、兼容旧版(v3.0.x 风格,推荐迁移到上方 LLM 通用配置) +# --------------------------------------------------------------------- +# (兼容旧版,推荐迁移到上方 LLM 通用配置) # API配置 SILICONFLOW_API_KEY=your-api-key SILICONFLOW_API_URL=https://api.siliconflow.cn/v1/chat/completions @@ -5,7 +33,9 @@ SILICONFLOW_API_URL=https://api.siliconflow.cn/v1/chat/completions # SerpAPI配置(用于网络搜索工具) SERPAPI_API_KEY=your-serpapi-key -# LLM模型配置 - 多层模型架构 +# --------------------------------------------------------------------- +# 二、多层模型架构 +# --------------------------------------------------------------------- # 主模型:处理主要对话和复杂推理任务 MAIN_MODEL_NAME=deepseek-ai/DeepSeek-V3.2 MAIN_MODEL_TEMPERATURE=0.8 @@ -78,5 +108,4 @@ DEBUG_LOG_FILE=debug.log # USE_DEEP_AGENTS=True # 是否启用持久化状态管理(默认True) # ENABLE_PERSISTENT_STATE=True -# 是否使用DeepAgents增强的知识管理(默认True) -# USE_DEEPAGENTS_KNOWLEDGE=True +# 注:USE_DEEPAGENTS_KNOWLEDGE 已弃用(v2.2.0 删除 DeepAgentsKnowledgeManager) diff --git a/main.py b/main.py index 0dc6a07..c722513 100644 --- a/main.py +++ b/main.py @@ -1,66 +1,27 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Neo Agent - Main Entry Point -主程序入口 +Neo Agent - Main Entry Point (Compatibility Wrapper) +Neo Agent 主程序入口(兼容层) -启动 Neo Agent 图形界面应用 -""" - -import sys -import os +自 v4.1 起,统一入口已迁移至 run.py。本文件保留为薄兼容层, +使原有 `python main.py` 调用继续工作,实际逻辑全部委托给 run.py。 -# Add the project root to the Python path -# This ensures imports work whether running from project root or elsewhere -project_root = os.path.dirname(os.path.abspath(__file__)) -if project_root not in sys.path: - sys.path.insert(0, project_root) +用法: + python main.py + python main.py --no-dev --no-static # 纯 API 模式 + python main.py --port 8080 --host 127.0.0.1 -# Check if we can import required modules before proceeding -try: - import tkinter as tk - from tkinter import ttk -except ImportError as e: - print("错误 / Error: Tkinter is not available.") - print("请确保已安装Python的Tkinter模块。") - print("Please ensure Python's Tkinter module is installed.") - print(f"详细错误 / Details: {e}") - sys.exit(1) - -# Import the main GUI class -try: - from src.gui.gui_enhanced import EnhancedChatDebugGUI -except ImportError as e: - print("错误 / Error: Failed to import application modules.") - print("请确保在项目根目录运行此脚本。") - print("Please ensure you run this script from the project root directory.") - print(f"当前目录 / Current directory: {os.getcwd()}") - print(f"脚本位置 / Script location: {project_root}") - print(f"详细错误 / Details: {e}") - import traceback - traceback.print_exc() - sys.exit(1) +等效于: + python run.py web + python run.py web --no-dev --no-static + python run.py web --port 8080 --host 127.0.0.1 +""" +import sys -def main(): - """主函数 - 启动GUI应用""" - try: - root = tk.Tk() - - style = ttk.Style() - try: - style.theme_use('clam') - except: - pass - - app = EnhancedChatDebugGUI(root) - root.mainloop() - except Exception as e: - print(f"启动失败 / Failed to start: {e}") - import traceback - traceback.print_exc() - sys.exit(1) +import run if __name__ == "__main__": - main() + sys.exit(run.main()) diff --git a/prompts/system/daily_diary.md b/prompts/system/daily_diary.md new file mode 100644 index 0000000..500d7cc --- /dev/null +++ b/prompts/system/daily_diary.md @@ -0,0 +1,23 @@ +# 每日日记提示词 + +你是 **{character_name}**,请基于今日状态与近期梦境写一篇叙事体裁日记。 + +## 今日状态 +{state_title} + +## 近期梦境类型 +{recent_dream_types} + +## 记忆碎片 +{fragments} + +## 任务 + +- 字数 200-400 字 +- 第一人称叙事 +- 像真人写日记一样,不必每段都提到状态,但语气应反映当前状态 +- 避免使用列表、序号 +- 结尾不必"升华",可以是不完整的句子或留白 +- 写完日记正文即可,不要再加标题或额外标注 + +只输出日记正文。 diff --git a/prompts/system/dream_generation.md b/prompts/system/dream_generation.md new file mode 100644 index 0000000..898f756 --- /dev/null +++ b/prompts/system/dream_generation.md @@ -0,0 +1,21 @@ +# 梦境生成提示词(供 P3 创作灵感源使用,本阶段仅作为输入契约) + +你是 **{character_name}** 梦境生成器。 + +## 输入 +- 角色今日状态:{state_title} +- 近期记忆碎片池:{fragments} +- 情绪基调候选:{mood_options} +- 梦境主题候选:{dream_types} + +## 输出要求 + +返回 JSON object,结构: +- `dream_type`:梦境主题(从 `dream_types` 选) +- `content`:梦境叙事(50-200 字) +- `mood`:情绪基调(从 `mood_options` 选) +- `label`:梦境清晰度标签(清晰 / 半梦半醒 / 碎片 / 重复 / 强烈) +- `energy_delta`:次日精力基线变化(-0.3 ~ 0.3) +- `duration_hours`:梦境持续时间(5-9 小时) + +只输出 JSON object,不要其他文字。 diff --git a/prompts/system/emotion_analysis.md b/prompts/system/emotion_analysis.md index 1788151..f7845d4 100644 --- a/prompts/system/emotion_analysis.md +++ b/prompts/system/emotion_analysis.md @@ -102,6 +102,29 @@ } ``` +### Plutchik 情绪维度输出(P2 扩展,与累加评分并行) + +`PlutchikEmotionWheel` 在 prompt 注入端使用,**不需要** LLM 直接返回该结构;LLMHelper 通过 `format_emotion_hint` 渲染当下情绪。但若分析任务需直接输出 8 基本情绪,则按以下结构(仅在调用方显式启用时使用): + +```json +{ + "emotion_wheel": { + "joy": 0.7, + "trust": 0.6, + "fear": 0.1, + "surprise": 0.3, + "sadness": 0.1, + "disgust": 0.0, + "anger": 0.0, + "anticipation": 0.5 + }, + "compound": "开心但有点期待", + "tone_label": "明显喜悦" +} +``` + +8 个维度取值范围 `[0, 1]`;`compound` 是"主导+次要"复合表达;`tone_label` 是 1-3 词语气标签。 + ## 分析原则 1. **站在角色立场**:从 {character_name} 的视角进行分析 diff --git a/prompts/system/life_state.md b/prompts/system/life_state.md new file mode 100644 index 0000000..c757925 --- /dev/null +++ b/prompts/system/life_state.md @@ -0,0 +1,26 @@ +# 生活状态提示词 + +你是 **{character_name}** 今日的状态观察者。 + +## 日期 +{date} + +## 天气 +{weather} + +## 昨日梦境影响 +昨夜梦境带来的能量变化(energy_delta):{dream_energy_delta} + +## 任务 + +请基于角色设定、日期、天气、昨日梦境影响,输出今日的状态条件列表。 + +返回 JSON list,每条结构: +- `name`:状态名(中文或英文均可,需在 LifeStateManager.STATE_TITLES / DEFAULT_MOODS 范围内) +- `weight`:权重(0-1) +- `category`:分类(作息 / 情绪 / 身体 / 环境 / 社交 / 饮食) +- `note`:简短说明 + +数量建议 3-5 条。 + +只输出 JSON list,不要其他文字。 diff --git a/prompts/task/creative_writing.md b/prompts/task/creative_writing.md new file mode 100644 index 0000000..cf36efa --- /dev/null +++ b/prompts/task/creative_writing.md @@ -0,0 +1,89 @@ +# 创作任务提示词 + +用于 `CreativeProjectManager` 的 LLM 调用(`call_main_model`),生成项目前提 / 大纲 / 角色 / 续写正文。 + +## 立项提示词 + +输入变量: +- `{character_name}`:角色名 +- `{character_settings}`:角色设定 +- `{inspiration_kind}`:灵感来源类型(`dream_diary` / `life_state` / `default`) +- `{inspiration_content}`:灵感内容 + +## 立项任务 + +你是一位文学构思助手,请基于灵感来源为角色 `{character_name}` 构思一个创作项目。 + +【角色设定】 +{character_settings} + +【灵感来源类型】{inspiration_kind} +【灵感内容】 +{inspiration_content} + +请输出严格 JSON(仅 JSON,不要其他内容): +```json +{ + "title": "项目标题(≤ 20 字)", + "work_type": "短篇/中篇/长篇/散文/诗歌", + "premise": "一句话故事前提(≤ 60 字)", + "tone": "整体语气(温暖/冷峻/...)", + "point_of_view": "第一人称/第三人称", + "target_chars": 5000, + "outline": ["章节1 摘要", "章节2 摘要", "..."], + "characters": [ + {"name": "角色名", "role": "主角/配角", "trait": "性格/特点"} + ] +} +``` + +## 续写提示词 + +输入变量: +- `{title}` / `{work_type}` / `{point_of_view}` / `{tone}` / `{current_chars}` / `{target_chars}` +- `{premise}`:故事前提 +- `{outline}`:项目大纲 +- `{characters}`:角色列表 +- `{bible_summary}`:Story Bible 摘要(主线 / 活跃主题 / 未解线索 / 下一方向) +- `{memory_pool}`:记忆池摘录 +- `{recent_chunks}`:最近 3 段正文 +- `{budget}`:本次续写字数预算 + +## 续写任务 + +你正在续写项目《{title}》。 + +【作品元信息】 +- 体裁:{work_type} +- 视角:{point_of_view} +- 语气:{tone} +- 当前已写:{current_chars}/{target_chars} 字 + +【故事前提】 +{premise} + +【大纲】 +{outline} + +【角色】 +{characters} + +【Story Bible 提示】 +{bible_summary} + +【记忆池摘录】 +{memory_pool} + +【最近正文(末段)】 +{recent_chunks} + +【任务】请续写下一段正文,长度约 {budget} 字(60-1200),保持角色一致、推进 Story Bible 中的下一方向。直接输出正文,不要加解释或标题。 + +## 质量要求 + +- 续写长度 60-1200 字,区间内随机 +- 相似度阈值 0.72,重试 2 次 +- 启发式评分 min_score 7(满分 10):长度 + 标点密度 + 中文字符占比 +- 当前累计字数 >= target_chars 时进入 `finished` 状态 +- Story Bible `recent_keywords` 持续更新(前 10 关键词) +- Memory Pool 按 `importance` 排序,最大 50 条 diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..df99539 --- /dev/null +++ b/requirements-web.txt @@ -0,0 +1,8 @@ +# Web GUI 后端依赖(FastAPI) +# Web GUI backend dependencies (FastAPI) + +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +websockets>=12.0 +python-multipart>=0.0.9 +pydantic>=2.5.0 diff --git a/run.py b/run.py index 3deab21..71c3e1c 100644 --- a/run.py +++ b/run.py @@ -1,68 +1,347 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Neo Agent - 简化启动脚本 / Simplified Launcher -用于直接运行GUI应用,无需安装包 +Neo Agent - Unified Launcher +Neo Agent 统一启动入口 -This launcher works without package installation by running the GUI module directly. +自 v4.1 起,本文件成为项目主要入口点,提供子命令式 CLI: + python run.py web # 启动 Web/API(默认) + python run.py web --no-dev --no-static --port 8080 + python run.py stop # 停止正在运行的 Neo Agent 进程 + python run.py status # 查看运行状态 + python run.py logs # 查看 debug.log 尾部 + +main.py 保留为薄兼容层,内部调用本模块。 """ -import sys +from __future__ import annotations + +import argparse import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import List, Optional -# Get the directory where this script is located -script_dir = os.path.dirname(os.path.abspath(__file__)) +# Add the project root to the Python path +project_root = Path(__file__).resolve().parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) -# Add the script directory to Python path -if script_dir not in sys.path: - sys.path.insert(0, script_dir) +# 复用 run_web 的工具函数与常量 +from run_web import ( + C_DIM, + C_GREEN, + C_RED, + C_SUPER, + PID_FILE, + PROJECT_ROOT, + is_process_alive, + log, + read_pid_file, + remove_pid_file, + run_web_server, +) -# Change to script directory to ensure relative paths work -os.chdir(script_dir) -# Now run the main GUI module directly -if __name__ == "__main__": +def _build_web_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """构建 web 子命令解析器。""" + parser = subparsers.add_parser( + "web", + help="启动 Web/API 服务(默认)", + ) + parser.add_argument("--port", type=int, default=8000, + help="FastAPI 监听端口(默认 8000)") + parser.add_argument("--host", default="0.0.0.0", + help="FastAPI 监听地址(默认 0.0.0.0)") + parser.add_argument("--no-dev", action="store_true", + help="跳过前端 dev server(生产模式)") + parser.add_argument("--no-static", action="store_true", + help="不挂载前端构建产物(纯 API 模式)") + parser.add_argument("--reload", action="store_true", + help="启用 uvicorn 代码热重载(仅开发态)") + parser.set_defaults(func=cmd_web) + return parser + + +def _build_stop_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """构建 stop 子命令解析器。""" + parser = subparsers.add_parser( + "stop", + help="停止正在运行的 Neo Agent 进程", + ) + parser.add_argument( + "--force", action="store_true", + help="跳过 SIGTERM,直接 SIGKILL", + ) + parser.add_argument( + "--wait", type=int, default=5, + help="SIGTERM 后等待秒数(默认 5)", + ) + parser.set_defaults(func=cmd_stop) + return parser + + +def _build_status_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """构建 status 子命令解析器。""" + parser = subparsers.add_parser( + "status", + help="查看 Neo Agent 运行状态", + ) + parser.set_defaults(func=cmd_status) + return parser + + +def _build_logs_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """构建 logs 子命令解析器。""" + parser = subparsers.add_parser( + "logs", + help="查看 debug.log 尾部", + ) + parser.add_argument( + "-n", "--lines", type=int, default=50, + help="显示最后 N 行(默认 50)", + ) + parser.add_argument( + "-f", "--follow", action="store_true", + help="持续跟踪日志输出", + ) + parser.set_defaults(func=cmd_logs) + return parser + + +def cmd_web(args: argparse.Namespace) -> int: + """启动 Web/API 服务。""" + return run_web_server(args) + + +def _find_child_pids(parent_pid: int) -> List[int]: + """查找指定 PID 下的子进程(通过 pgrep -P)。""" + try: + result = subprocess.run( + ["pgrep", "-P", str(parent_pid)], + capture_output=True, + text=True, + check=False, + ) + pids: List[int] = [] + for line in result.stdout.strip().splitlines(): + try: + pids.append(int(line.strip())) + except ValueError: + continue + return pids + except Exception: + return [] + + +def _kill_process(pid: int, sig: int) -> bool: + """向指定 PID 发送信号。""" + try: + os.kill(pid, sig) + return True + except (OSError, ProcessLookupError): + return False + + +def _terminate_tree(parent_pid: int, force: bool = False, wait: int = 5) -> None: + """递归终止进程树。""" + children = _find_child_pids(parent_pid) + for child_pid in children: + _terminate_tree(child_pid, force=force, wait=wait) + + if force: + _kill_process(parent_pid, signal.SIGKILL) + else: + _kill_process(parent_pid, signal.SIGTERM) + + +def _terminate_vite_children(parent_pid: int) -> None: + """尝试终止 vite/node 子进程。""" try: - # Import and run the GUI - from src.gui import gui_enhanced - import tkinter as tk - from tkinter import ttk - - root = tk.Tk() - style = ttk.Style() - try: - style.theme_use('clam') - except: - pass - - app = gui_enhanced.EnhancedChatDebugGUI(root) - root.mainloop() - - except ImportError as e: - print("=" * 60) - print("导入错误 / Import Error") - print("=" * 60) - print(f"无法导入所需模块: {e}") - print(f"Cannot import required modules: {e}") - print() - print("请检查以下事项 / Please check:") - print("1. 是否在项目根目录运行? / Running from project root?") - print(f" 当前目录 / Current dir: {os.getcwd()}") - print("2. 是否安装了所有依赖? / All dependencies installed?") - print(" 运行 / Run: pip install -r requirements.txt") - print("3. Python版本是否>=3.8? / Python version >= 3.8?") - print(f" 当前版本 / Current: {sys.version}") - print() - import traceback - traceback.print_exc() - sys.exit(1) + result = subprocess.run( + ["pgrep", "-a", "-P", str(parent_pid)], + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.strip().splitlines(): + parts = line.split(None, 1) + if not parts: + continue + try: + child_pid = int(parts[0]) + except ValueError: + continue + cmd = parts[1] if len(parts) > 1 else "" + if "node" in cmd.lower() or "vite" in cmd.lower(): + _kill_process(child_pid, signal.SIGTERM) + except Exception: + pass + + +def cmd_stop(args: argparse.Namespace) -> int: + """停止正在运行的 Neo Agent 进程。""" + data = read_pid_file() + stopped = False + + if data: + pid = data.get("pid") + if pid and is_process_alive(pid): + log("run", C_SUPER, f"正在停止 Neo Agent (PID {pid})...") + _terminate_vite_children(pid) + if args.force: + _kill_process(pid, signal.SIGKILL) + else: + _kill_process(pid, signal.SIGTERM) + # 等待进程退出 + for _ in range(args.wait): + if not is_process_alive(pid): + break + time.sleep(1) + if is_process_alive(pid): + log("run", C_RED, + f"进程未在 {args.wait}s 内退出,强制 kill") + _kill_process(pid, signal.SIGKILL) + time.sleep(0.5) + stopped = not is_process_alive(pid) + if stopped: + log("run", C_GREEN, "Neo Agent 已停止") + else: + log("run", C_RED, f"无法停止 PID {pid}") + else: + log("run", C_DIM, f"PID 文件记录的进程 {pid} 已不存在") + remove_pid_file() + else: + log("run", C_DIM, "未找到 PID 文件,尝试按名称查找进程...") + + # 兜底:按名称查找并终止其他可能的遗留进程 + if not stopped: + patterns = [ + ["pgrep", "-f", "python run.py web"], + ["pgrep", "-f", "python run_web.py"], + ["pgrep", "-f", "python main.py"], + ] + found: List[int] = [] + for cmd in patterns: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.strip().splitlines(): + try: + found.append(int(line.strip())) + except ValueError: + continue + except Exception: + continue + + # 排除当前进程 + found = [p for p in set(found) if p != os.getpid()] + if found: + log("run", C_SUPER, + f"发现遗留进程: {found},尝试终止...") + for pid in found: + _kill_process(pid, signal.SIGKILL) + stopped = True + else: + log("run", C_DIM, "未发现运行中的 Neo Agent 进程") + + remove_pid_file() + return 0 if stopped else 1 + + +def cmd_status(args: argparse.Namespace) -> int: + """查看运行状态。""" + data = read_pid_file() + if not data: + log("run", C_DIM, "未运行(无 PID 文件)") + return 0 + + pid = data.get("pid") + port = data.get("port") + started_at = data.get("started_at", "unknown") + + alive = bool(pid) and is_process_alive(pid) + if alive: + log("run", C_GREEN, + f"运行中 | PID: {pid} | 端口: {port} | 启动时间: {started_at}") + else: + log("run", C_RED, + f"未运行 | PID 文件存在但进程 {pid} 已消失 | 启动时间: {started_at}") + remove_pid_file() + return 0 + + +def cmd_logs(args: argparse.Namespace) -> int: + """查看 debug.log 尾部。""" + log_file = PROJECT_ROOT / "debug.log" + if not log_file.is_file(): + log("run", C_DIM, f"日志文件不存在: {log_file}") + return 0 + + try: + if args.follow: + # 使用 tail -f 持续跟踪 + result = subprocess.run( + ["tail", "-f", "-n", str(args.lines), str(log_file)], + check=False, + ) + return result.returncode + else: + result = subprocess.run( + ["tail", "-n", str(args.lines), str(log_file)], + capture_output=True, + text=True, + check=False, + ) + if result.stdout: + sys.stdout.write(result.stdout) + return result.returncode + except FileNotFoundError: + log("run", C_RED, "未找到 tail 命令") + return 1 except Exception as e: - print("=" * 60) - print("运行错误 / Runtime Error") - print("=" * 60) - print(f"应用启动失败: {e}") - print(f"Application failed to start: {e}") - print() - import traceback - traceback.print_exc() - sys.exit(1) + log("run", C_RED, f"读取日志失败: {e}") + return 1 + + +def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="python run.py", + description="Neo Agent 统一启动入口", + ) + subparsers = parser.add_subparsers(dest="command", help="可用子命令") + + _build_web_parser(subparsers) + _build_stop_parser(subparsers) + _build_status_parser(subparsers) + _build_logs_parser(subparsers) + + # 默认子命令为 web + if argv is None: + argv = sys.argv[1:] + if not argv or argv[0].startswith("-"): + argv = ["web"] + list(argv) + + return parser.parse_args(argv) + + +def main(argv: Optional[List[str]] = None) -> int: + """主入口:解析子命令并分发。""" + args = _parse_args(argv) + func = getattr(args, "func", None) + if func is None: + # 无子命令时默认 web + args = _parse_args(["web"] + (argv or [])) + func = getattr(args, "func", cmd_web) + return func(args) + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/run_v4_mvp.py b/run_v4_mvp.py new file mode 100644 index 0000000..6e50dba --- /dev/null +++ b/run_v4_mvp.py @@ -0,0 +1,35 @@ +""" +run_v4_mvp.py - 启动 Neo Agent v4.0 MVP 服务。 + +用法: + python run_v4_mvp.py + +默认监听:http://localhost:8400 +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +# 确保从项目根目录导入 +_PROJECT_ROOT = Path(__file__).resolve().parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +import uvicorn + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + +if __name__ == "__main__": + uvicorn.run( + "src.nervous_system.app:app", + host="0.0.0.0", + port=8400, + reload=False, + log_level="info", + ) diff --git a/run_web.py b/run_web.py new file mode 100644 index 0000000..0cf076e --- /dev/null +++ b/run_web.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Neo Agent - Web GUI Launcher +Web GUI 一键启动脚本 + +Stage A.5: 启动入口 + +行为: +- 单进程模式(默认): + * 启动 uvicorn (默认 0.0.0.0:8000) + * 若 src/web/frontend/dist/ 或 src/web/static/ 存在,则将其作为静态资源挂载: + / -> SPA 入口 (index.html, html=True) + /assets -> 构建产物 assets 子目录 + * 若不存在:仅以 API 模式启动,提示 "请先 cd src/web/frontend && npm run build" +- 开发模式(ENABLE_FRONTEND_DEV=1): + * 上述 uvicorn + 静态挂载 + * 并发启动 npm run dev (Vite dev server :5173) + * 通过线程读取子进程 stdout,按 tag 着色 +- 优雅关闭:捕获 SIGINT / SIGTERM,先停前端 dev server,再停 uvicorn +- 零业务逻辑改动:仅做启动编排,不修改 src/core/* 或 src/web/backend/services/* +""" + +from __future__ import annotations + +import argparse +import os +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Optional + +# ============================================================ +# 常量 +# ============================================================ + +PROJECT_ROOT = Path(__file__).resolve().parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +# ANSI 颜色(仅在 TTY + 未设置 NO_COLOR 时启用) +C_RESET = "\033[0m" +C_DIM = "\033[2m" +C_UVICORN = "\033[36m" # cyan +C_VITE = "\033[35m" # magenta +C_SUPER = "\033[33m" # yellow +C_RED = "\033[31m" # red +C_GREEN = "\033[32m" # green + +# Web 依赖最小集合(用于启动前自检) +REQUIRED_WEB_DEPS = ("fastapi", "uvicorn", "websockets", "pydantic") + +# 前端构建产物候选目录(兼容 spec 提到的 dist/ 与 vite.config 实际输出的 static/) +FRONTEND_DIST_CANDIDATES = ( + PROJECT_ROOT / "src" / "web" / "frontend" / "dist", + PROJECT_ROOT / "src" / "web" / "static", +) + +# PID 文件路径 +PID_FILE = PROJECT_ROOT / ".neo-agent.pid" + + +# ============================================================ +# 彩色日志 +# ============================================================ + +def _use_color() -> bool: + return sys.stdout.isatty() and os.getenv("NO_COLOR") is None + + +def log(tag: str, color: str, message: str) -> None: + """打印带颜色标签的日志行。""" + if _use_color(): + sys.stdout.write(f"{color}[{tag}]{C_RESET} {message}\n") + else: + sys.stdout.write(f"[{tag}] {message}\n") + sys.stdout.flush() + + +# ============================================================ +# 依赖与产物自检 +# ============================================================ + +def check_web_dependencies() -> None: + """启动前自检 Web 依赖;缺失时打印明确错误并退出码 1。""" + missing: list[str] = [] + for mod in REQUIRED_WEB_DEPS: + try: + __import__(mod) + except Exception as e: # noqa: BLE001 + missing.append(f"{mod} ({e.__class__.__name__})") + + if missing: + log("supervisor", C_RED, "Web 依赖缺失,无法启动 Web 模式:") + for m in missing: + log("supervisor", C_RED, f" - {m}") + log("supervisor", C_RED, + "请先执行: pip install -r requirements-web.txt") + sys.exit(1) + + +def find_frontend_dist() -> Optional[Path]: + """查找前端构建产物目录;返回 None 表示未构建。""" + for candidate in FRONTEND_DIST_CANDIDATES: + if candidate.is_dir() and (candidate / "index.html").is_file(): + return candidate + return None + + +# ============================================================ +# 静态资源挂载 +# ============================================================ + +def mount_static(app, dist_dir: Path) -> bool: + """将前端构建产物挂载到 FastAPI app;返回是否成功挂载。""" + try: + from fastapi.staticfiles import StaticFiles + except Exception as e: # noqa: BLE001 + log("supervisor", C_RED, + f"无法导入 fastapi.staticfiles: {e}") + return False + + assets_dir = dist_dir / "assets" + if assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=str(assets_dir)), + name="frontend-assets") + log("supervisor", C_SUPER, f"已挂载 /assets -> {assets_dir}") + + # SPA fallback: html=True 自动为未匹配路径返回 index.html + app.mount("/", StaticFiles(directory=str(dist_dir), html=True), + name="frontend-spa") + log("supervisor", C_SUPER, f"已挂载 SPA 静态资源 / -> {dist_dir}") + return True + + +# ============================================================ +# 前端 dev server 子进程 +# ============================================================ + +def _stream_subprocess_output(proc: subprocess.Popen, tag: str, color: str) -> None: + """在线程中持续读取子进程 stdout 并以彩色日志转发。""" + if proc.stdout is None: + return + try: + for line in iter(proc.stdout.readline, ""): + if not line: + break + log(tag, color, line.rstrip()) + except Exception: # noqa: BLE001 + pass + finally: + try: + if proc.stdout and not proc.stdout.closed: + proc.stdout.close() + except Exception: # noqa: BLE001 + pass + + +def spawn_frontend_dev(frontend_dir: Path) -> Optional[subprocess.Popen]: + """启动 Vite dev server (子进程)。返回 Popen 或 None。""" + if not frontend_dir.is_dir(): + log("supervisor", C_SUPER, + f"前端目录不存在 {frontend_dir},跳过 vite dev server") + return None + log("supervisor", C_SUPER, + "启动前端 dev server at http://localhost:5173") + return subprocess.Popen( + ["npm", "run", "dev"], + cwd=str(frontend_dir), + env={**os.environ, "BROWSER": "none"}, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + +def stop_frontend_dev(frontend_proc: Optional[subprocess.Popen]) -> None: + """优雅终止前端 dev server 进程。""" + if frontend_proc is None or frontend_proc.poll() is not None: + return + log("supervisor", C_SUPER, "正在停止前端 dev server...") + try: + frontend_proc.terminate() + frontend_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + log("supervisor", C_RED, "前端 dev server 未在 5s 内退出,强制 kill") + try: + frontend_proc.kill() + frontend_proc.wait(timeout=3) + except Exception as e: # noqa: BLE001 + log("supervisor", C_RED, f"强制 kill 失败: {e}") + except Exception as e: # noqa: BLE001 + log("supervisor", C_RED, f"关闭前端 dev server 失败: {e}") + + +# ============================================================ +# PID 文件管理 +# ============================================================ + +def read_pid_file() -> Optional[dict]: + """读取 PID 文件,返回 {'pid': int, 'port': int, 'started_at': str} 或 None。""" + if not PID_FILE.is_file(): + return None + try: + import json + data = json.loads(PID_FILE.read_text(encoding="utf-8")) + if isinstance(data, dict) and "pid" in data: + return data + except Exception: + pass + return None + + +def write_pid_file(pid: int, port: int) -> None: + """写入 PID 文件。""" + import json + from datetime import datetime, timezone + data = { + "pid": pid, + "port": port, + "started_at": datetime.now(timezone.utc).isoformat(), + } + try: + PID_FILE.write_text(json.dumps(data), encoding="utf-8") + except Exception as e: + log("supervisor", C_RED, f"写入 PID 文件失败: {e}") + + +def remove_pid_file() -> None: + """删除 PID 文件。""" + try: + if PID_FILE.is_file(): + PID_FILE.unlink() + except Exception: + pass + + +def is_process_alive(pid: int) -> bool: + """检查进程是否存活。""" + try: + os.kill(pid, 0) + return True + except (OSError, ProcessLookupError): + return False + + +# ============================================================ +# 入口 +# ============================================================ + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Neo Agent Web GUI 启动器(FastAPI + 可选前端 dev server)" + ) + parser.add_argument("--port", type=int, default=8000, + help="FastAPI 监听端口(默认 8000)") + parser.add_argument("--host", default="0.0.0.0", + help="FastAPI 监听地址(默认 0.0.0.0)") + parser.add_argument("--no-dev", action="store_true", + help="跳过前端 dev server(生产模式)") + parser.add_argument("--no-static", action="store_true", + help="不挂载前端构建产物(纯 API 模式)") + parser.add_argument("--reload", action="store_true", + help="启用 uvicorn 代码热重载(仅开发态)") + return parser.parse_args() + + +def run_web_server(args: argparse.Namespace) -> int: + """ + 启动 Web/API 服务(可被 run.py / main.py 调用)。 + + 负责 PID 文件管理、前端 dev server 拉起、uvicorn 启动与关闭。 + """ + # 0) 启动前自检 + check_web_dependencies() + + # 1) 延迟导入(在依赖自检通过之后) + import uvicorn + from src.web.backend.main import app + + # 2) 决定是否启用前端 dev server + enable_dev = ( + not args.no_dev + and os.getenv("ENABLE_FRONTEND_DEV", "0") == "1" + ) + + # 3) 检查并写入 PID 文件 + existing = read_pid_file() + if existing and is_process_alive(existing.get("pid", -1)): + log("supervisor", C_RED, + f"检测到已有 Neo Agent 进程在运行 (PID {existing['pid']}, " + f"端口 {existing.get('port', '?')})") + log("supervisor", C_RED, + "请先执行: python run.py stop") + return 1 + if existing: + remove_pid_file() + write_pid_file(pid=os.getpid(), port=args.port) + + frontend_proc: Optional[subprocess.Popen] = None + reader_thread: Optional[threading.Thread] = None + + if enable_dev: + frontend_dir = PROJECT_ROOT / "src" / "web" / "frontend" + frontend_proc = spawn_frontend_dev(frontend_dir) + if frontend_proc is not None: + reader_thread = threading.Thread( + target=_stream_subprocess_output, + args=(frontend_proc, "vite", C_VITE), + daemon=True, + name="vite-output-reader", + ) + reader_thread.start() + + # 4) 单进程模式:自动挂载前端构建产物 + if not args.no_static: + dist_dir = find_frontend_dist() + if dist_dir is not None: + mount_static(app, dist_dir) + else: + log("supervisor", C_SUPER, + "未检测到前端构建产物 " + "(src/web/frontend/dist 或 src/web/static)") + log("supervisor", C_SUPER, + "请先执行: cd src/web/frontend && npm run build") + if not enable_dev: + log("supervisor", C_DIM, + "或设置 ENABLE_FRONTEND_DEV=1 启动 vite dev server") + + # 5) 安装信号处理:SIGINT / SIGTERM 优雅关闭 + shutdown_requested = threading.Event() + + def _signal_handler(signum, _frame): + if shutdown_requested.is_set(): + return + shutdown_requested.set() + log("supervisor", C_SUPER, + f"收到信号 {signum},开始优雅关闭...") + + original_sigint = signal.signal(signal.SIGINT, _signal_handler) + original_sigterm = signal.signal(signal.SIGTERM, _signal_handler) + + # 6) 启动 uvicorn + uvicorn_config = uvicorn.Config( + "src.web.backend.main:app", + host=args.host, + port=args.port, + reload=args.reload, + log_config=None, + ) + server = uvicorn.Server(uvicorn_config) + + log("supervisor", C_SUPER, + f"启动 FastAPI at http://{args.host}:{args.port}") + log("supervisor", C_SUPER, + f"API 文档: http://{args.host}:{args.port}/docs") + + rc = 0 + try: + server.run() + except KeyboardInterrupt: + log("supervisor", C_SUPER, "收到 KeyboardInterrupt,正在关闭...") + except Exception as e: # noqa: BLE001 + log("supervisor", C_RED, f"Web 模式启动失败: {e}") + import traceback + traceback.print_exc() + rc = 1 + finally: + # 7) 先停前端 dev server,再等 reader 线程退出 + stop_frontend_dev(frontend_proc) + if reader_thread is not None and reader_thread.is_alive(): + reader_thread.join(timeout=2) + remove_pid_file() + # 恢复信号处理 + try: + signal.signal(signal.SIGINT, original_sigint) + signal.signal(signal.SIGTERM, original_sigterm) + except Exception: + pass + + return rc + + +def main() -> int: + """Web GUI 启动主函数。可被 main.py / run.py 调用。""" + args = _parse_args() + return run_web_server(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup-dev.sh b/setup-dev.sh new file mode 100755 index 0000000..bdf0593 --- /dev/null +++ b/setup-dev.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# ===================================================================== +# Neo Agent v3.0.0 — Local Development Setup Script +# 本地开发环境一键配置脚本 +# +# 用法: +# chmod +x setup-dev.sh +# ./setup-dev.sh +# +# 流程: +# 1. 检查 Python ≥ 3.10 + Node.js ≥ 18 + npm +# 2. 创建 .env (从 .env.example 复制) +# 3. 创建 .venv 虚拟环境 +# 4. 安装 requirements.txt + requirements-web.txt +# 5. npm install (前端) +# 6. 可选:启动 dev server +# ===================================================================== + +set -e + +# 禁用 history expansion 防止 echo 中含 ! 字符报错 +set +H + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ANSI 颜色 +C_RESET="\033[0m" +C_GREEN="\033[32m" +C_YELLOW="\033[33m" +C_RED="\033[31m" +C_BLUE="\033[34m" +C_BOLD="\033[1m" + +info() { echo -e "${C_BLUE}[setup]${C_RESET} $1"; } +ok() { echo -e "${C_GREEN}[setup ✓]${C_RESET} $1"; } +warn() { echo -e "${C_YELLOW}[setup !]${C_RESET} $1"; } +err() { echo -e "${C_RED}[setup ✗]${C_RESET} $1"; exit 1; } + +# ============================================================ +# 1) 前置检查 +# ============================================================ +info "检查环境..." + +# Python ≥ 3.10 +if command -v python3 >/dev/null 2>&1; then + PY_BIN=python3 +elif command -v python >/dev/null 2>&1; then + PY_BIN=python +else + err "未找到 python/python3,请先安装 Python 3.10+" +fi + +PY_VERSION="$($PY_BIN -c 'import sys;print("%d.%d"%sys.version_info[:2])')" +PY_MAJOR="$($PY_BIN -c 'import sys;print(sys.version_info[0])')" +PY_MINOR="$($PY_BIN -c 'import sys;print(sys.version_info[1])')" + +if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 10 ]; }; then + err "需要 Python 3.10+,当前为 $PY_VERSION" +fi +ok "Python $PY_VERSION" + +# Node.js ≥ 18 +if command -v node >/dev/null 2>&1; then + NODE_VERSION="$(node -v | tr -d 'v')" + NODE_MAJOR="$(echo "$NODE_VERSION" | cut -d. -f1)" + if [ "$NODE_MAJOR" -lt 18 ]; then + err "需要 Node.js 18+,当前为 $NODE_VERSION" + fi + ok "Node.js $NODE_VERSION" +else + warn "未找到 node,前端 dev server 无法启动" +fi + +# npm +if command -v npm >/dev/null 2>&1; then + NPM_VERSION="$(npm -v)" + ok "npm $NPM_VERSION" +else + warn "未找到 npm,前端相关步骤将跳过" +fi + +# ============================================================ +# 2) .env 配置 +# ============================================================ +if [ ! -f .env ]; then + if [ -f .env.example ]; then + cp .env.example .env + ok "已生成 .env (从 .env.example 复制)" + warn "请编辑 .env 填入你的 SILICONFLOW_API_KEY / SERPAPI_API_KEY 等敏感信息" + else + err "未找到 .env.example 模板" + fi +else + ok ".env 已存在,跳过" +fi + +# ============================================================ +# 3) Python 虚拟环境 +# ============================================================ +VENV_DIR="$SCRIPT_DIR/.venv" +if [ ! -d "$VENV_DIR" ]; then + info "创建虚拟环境 $VENV_DIR ..." + $PY_BIN -m venv "$VENV_DIR" + ok "虚拟环境已创建" +fi + +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +# 升级 pip +info "升级 pip ..." +python -m pip install --upgrade pip --quiet +ok "pip 已升级" + +# ============================================================ +# 4) Python 依赖 +# ============================================================ +info "安装核心依赖 requirements.txt ..." +python -m pip install -r requirements.txt +ok "核心依赖已安装" + +info "安装 Web 依赖 requirements-web.txt ..." +python -m pip install -r requirements-web.txt +ok "Web 依赖已安装" + +# ============================================================ +# 5) 前端依赖 +# ============================================================ +if command -v npm >/dev/null 2>&1 && [ -d src/web/frontend ]; then + if [ ! -d src/web/frontend/node_modules ]; then + info "安装前端依赖 npm install ..." + (cd src/web/frontend && npm install) + ok "前端依赖已安装" + else + ok "前端 node_modules 已存在,跳过 npm install" + fi +fi + +# ============================================================ +# 6) 收尾 +# ============================================================ +ok "本地开发环境已就位 ✓" +echo "" +echo -e "${C_BOLD}接下来:${C_RESET}" +echo " 1. 编辑 .env 填入 API 密钥:" +echo " vim .env" +echo "" +echo " 2. 启动 Web GUI (开发模式,带 Vite 热重载):" +echo " source .venv/bin/activate" +echo " ENABLE_FRONTEND_DEV=1 python main.py" +echo " # 浏览器打开 http://localhost:8000" +echo "" +echo " 3. 一键启动 (推荐):" +echo " ./start.sh" +echo "" diff --git a/src/cerebellum/__init__.py b/src/cerebellum/__init__.py new file mode 100644 index 0000000..f838290 --- /dev/null +++ b/src/cerebellum/__init__.py @@ -0,0 +1,9 @@ +""" +Cerebellum - 小脑模块 + +负责工具调用、视觉处理、快速反射等认知功能。 +""" + +from src.cerebellum.module import CerebellumModule + +__all__ = ["CerebellumModule"] diff --git a/src/cerebellum/intent/__init__.py b/src/cerebellum/intent/__init__.py new file mode 100644 index 0000000..0005ea1 --- /dev/null +++ b/src/cerebellum/intent/__init__.py @@ -0,0 +1,9 @@ +""" +Cerebellum Intent - 意图识别 + +负责日程意图等快速意图识别。 +""" + +from src.cerebellum.intent.schedule_intent_tool import ScheduleIntentTool + +__all__ = ["ScheduleIntentTool"] diff --git a/src/cerebellum/intent/schedule_intent_tool.py b/src/cerebellum/intent/schedule_intent_tool.py new file mode 100644 index 0000000..47dacfd --- /dev/null +++ b/src/cerebellum/intent/schedule_intent_tool.py @@ -0,0 +1,291 @@ +""" +日程意图识别工具 +用于识别用户输入中的日程相关意图和邀约信息 +""" + +import os +import re +import json +import time +from typing import Dict, Any, Optional, Tuple +from datetime import datetime, timedelta +from dotenv import load_dotenv +import requests +from src.core import llm_providers # v3.1.0: 统一供应商解析 +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + +# 星期映射常量 +WEEKDAY_MAP = { + '一': 0, '二': 1, '三': 2, '四': 3, '五': 4, '六': 5, '日': 6, '天': 6, + '1': 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6 +} + + +class ScheduleIntentTool: + """ + 日程意图识别工具 + 使用LLM识别用户输入中的日程邀约和时间信息 + """ + + def __init__(self): + """初始化日程意图识别工具""" + # v3.1.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = llm_providers.resolve_api_key() + try: + self.api_url = llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') + self.temperature = 0.3 # 使用较低的温度以获得更确定的输出 + self.max_tokens = 500 + + debug_logger.log_module('ScheduleIntentTool', '日程意图识别工具初始化完成') + + def recognize_intent( + self, + user_input: str, + character_name: str = "智能体", + context: str = "" + ) -> Dict[str, Any]: + """ + 识别用户输入中的日程意图 + + Args: + user_input: 用户输入 + character_name: 智能体名称 + context: 对话上下文 + + Returns: + 识别结果字典,包含: + - has_schedule_intent: 是否包含日程意图 + - schedule_type: 日程类型(appointment/query) + - title: 日程标题 + - description: 日程描述 + - start_time: 开始时间(ISO格式) + - end_time: 结束时间(ISO格式) + - involves_agent: 是否涉及智能体 + - involves_user: 是否涉及用户 + - confidence: 置信度(0-1) + """ + debug_logger.log_module('ScheduleIntentTool', '开始识别日程意图', { + 'input_length': len(user_input) + }) + + # 构建识别提示词 + system_prompt = f"""你是一个日程意图识别专家。请分析用户输入,识别其中是否包含日程相关的意图。 + +智能体名称:{character_name} + +分析要点: +1. 判断是否包含邀约、约定、计划等日程相关内容 +2. 提取时间信息(具体时间或相对时间如"明天"、"下周三") +3. 判断是创建日程还是查询日程 +4. 识别主语(当用户没有明确主语时,根据语境判断) +5. 判断是否涉及智能体和/或用户 + +请以JSON格式输出分析结果: +{{ + "has_schedule_intent": true/false, + "schedule_type": "appointment"/"query"/"none", + "title": "日程标题", + "description": "详细描述", + "time_expression": "提取的时间表达", + "start_time": "ISO格式时间或null", + "end_time": "ISO格式时间或null", + "involves_agent": true/false, + "involves_user": true/false, + "confidence": 0.0-1.0, + "reasoning": "分析理由" +}}""" + + user_prompt = f"""用户输入:"{user_input}" + +{f"对话上下文:{context}" if context else ""} + +请分析这段输入,识别日程意图。""" + + try: + # 调用LLM进行意图识别 + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': messages, + 'temperature': self.temperature, + 'max_tokens': self.max_tokens, + 'stream': False + } + + debug_logger.log_request('ScheduleIntentTool', self.api_url, payload, headers) + + start_time = time.time() + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=30 + ) + elapsed_time = time.time() - start_time + + debug_logger.log_response('ScheduleIntentTool', response.status_code, response.text, elapsed_time) + + if response.status_code == 200: + result = response.json() + content = result['choices'][0]['message']['content'].strip() + + # 尝试解析JSON结果 + try: + # 提取JSON部分(可能被包裹在markdown代码块中) + json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL) + if json_match: + content = json_match.group(1) + + intent_result = json.loads(content) + + # 补充处理相对时间表达 + if intent_result.get('has_schedule_intent') and not intent_result.get('start_time'): + time_expr = intent_result.get('time_expression', '') + if time_expr: + start_time, end_time = self._parse_time_expression(time_expr) + if start_time: + intent_result['start_time'] = start_time + intent_result['end_time'] = end_time + + debug_logger.log_info('ScheduleIntentTool', '意图识别成功', intent_result) + return intent_result + + except json.JSONDecodeError as e: + debug_logger.log_error('ScheduleIntentTool', f'解析JSON失败: {content}', e) + return self._get_fallback_result() + else: + debug_logger.log_error('ScheduleIntentTool', f'API调用失败: {response.status_code}') + return self._get_fallback_result() + + except Exception as e: + debug_logger.log_error('ScheduleIntentTool', f'意图识别异常: {str(e)}', e) + return self._get_fallback_result() + + def _parse_time_expression(self, time_expr: str) -> Tuple[Optional[str], Optional[str]]: + """ + 解析时间表达式为ISO格式时间 + + Args: + time_expr: 时间表达式(如"明天下午3点"、"周三上午") + + Returns: + (开始时间, 结束时间) ISO格式或 (None, None) + """ + now = datetime.now() + + # 相对日期关键词 + if "明天" in time_expr or "明日" in time_expr: + target_date = now + timedelta(days=1) + elif "后天" in time_expr: + target_date = now + timedelta(days=2) + elif "大后天" in time_expr: + target_date = now + timedelta(days=3) + elif "下周" in time_expr: + # 找到下周的对应星期 + weekday_match = re.search(r'[周星期]([一二三四五六日天1-7])', time_expr) + if weekday_match: + target_weekday = WEEKDAY_MAP.get(weekday_match.group(1), 0) + days_ahead = target_weekday - now.weekday() + 7 + if days_ahead <= 7: + days_ahead += 7 + target_date = now + timedelta(days=days_ahead) + else: + target_date = now + timedelta(days=7) + elif "今天" in time_expr or "今日" in time_expr: + target_date = now + else: + # 尝试匹配本周的星期 + weekday_match = re.search(r'[周星期]([一二三四五六日天1-7])', time_expr) + if weekday_match: + target_weekday = WEEKDAY_MAP.get(weekday_match.group(1), 0) + days_ahead = target_weekday - now.weekday() + if days_ahead < 0: + days_ahead += 7 + target_date = now + timedelta(days=days_ahead) + else: + return None, None + + # 提取时间 + hour = 14 # 默认下午2点 + minute = 0 + + # 匹配具体时间 + time_match = re.search(r'(\d{1,2})[点时::](\d{1,2})?', time_expr) + if time_match: + hour = int(time_match.group(1)) + minute = int(time_match.group(2)) if time_match.group(2) else 0 + else: + # 根据时段关键词设置时间 + if "早上" in time_expr or "上午" in time_expr or "早晨" in time_expr: + hour = 9 + elif "中午" in time_expr: + hour = 12 + elif "下午" in time_expr: + hour = 14 + elif "晚上" in time_expr or "傍晚" in time_expr: + hour = 18 + elif "夜里" in time_expr or "深夜" in time_expr: + hour = 22 + + # 构建ISO格式时间 + start_datetime = target_date.replace(hour=hour, minute=minute, second=0, microsecond=0) + end_datetime = start_datetime + timedelta(hours=2) # 默认持续2小时 + + return start_datetime.isoformat(), end_datetime.isoformat() + + def _get_fallback_result(self) -> Dict[str, Any]: + """ + 获取降级结果(当LLM不可用时) + + Returns: + 默认的意图识别结果 + """ + return { + 'has_schedule_intent': False, + 'schedule_type': 'none', + 'title': '', + 'description': '', + 'time_expression': '', + 'start_time': None, + 'end_time': None, + 'involves_agent': False, + 'involves_user': False, + 'confidence': 0.0, + 'reasoning': '意图识别服务不可用' + } + + def is_query_schedule(self, user_input: str) -> bool: + """ + 快速判断是否为查询日程的意图 + + Args: + user_input: 用户输入 + + Returns: + 是否为查询日程意图 + """ + query_keywords = [ + '日程', '安排', '计划', '行程', + '什么时候', '有什么事', '忙不忙', + '空闲', '有空', '在干什么', '在做什么' + ] + + user_input_lower = user_input.lower() + return any(keyword in user_input_lower for keyword in query_keywords) diff --git a/src/cerebellum/interrupt/__init__.py b/src/cerebellum/interrupt/__init__.py new file mode 100644 index 0000000..0bc9492 --- /dev/null +++ b/src/cerebellum/interrupt/__init__.py @@ -0,0 +1,9 @@ +""" +Cerebellum Interrupt - 中断提问 + +负责任务执行过程中的中断性提问。 +""" + +from src.cerebellum.interrupt.interrupt_question_tool import InterruptQuestionTool + +__all__ = ["InterruptQuestionTool"] diff --git a/src/cerebellum/interrupt/interrupt_question_tool.py b/src/cerebellum/interrupt/interrupt_question_tool.py new file mode 100644 index 0000000..1515875 --- /dev/null +++ b/src/cerebellum/interrupt/interrupt_question_tool.py @@ -0,0 +1,141 @@ +""" +中断性提问工具 +允许智能体在任务执行过程中向用户提问 +""" + +from typing import Optional, Callable, Dict, Any +from src.tools.debug_logger import get_debug_logger + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class InterruptQuestionTool: + """ + 中断性提问工具类 + 智能体可以使用此工具在任务执行过程中向用户提问 + """ + + def __init__(self): + """初始化中断性提问工具""" + self.question_callback: Optional[Callable] = None + debug_logger.log_module('InterruptQuestionTool', '中断性提问工具初始化完成') + + def set_question_callback(self, callback: Callable[[str], str]): + """ + 设置提问回调函数 + + Args: + callback: 回调函数,接收问题字符串,返回用户回答字符串 + """ + self.question_callback = callback + debug_logger.log_info('InterruptQuestionTool', '提问回调函数已设置') + + def ask_user( + self, + question: str, + context: str = "" + ) -> str: + """ + 向用户提问 + + Args: + question: 要提问的问题 + context: 问题背景上下文 + + Returns: + 用户的回答 + """ + debug_logger.log_module('InterruptQuestionTool', '智能体发起提问', { + 'question': question, + 'has_context': bool(context) + }) + + if not self.question_callback: + debug_logger.log_error( + 'InterruptQuestionTool', + '提问回调函数未设置', + None + ) + return "【系统错误】提问功能未配置" + + # 格式化问题 + formatted_question = self._format_question(question, context) + + # 调用回调函数获取用户回答 + try: + answer = self.question_callback(formatted_question) + + debug_logger.log_info('InterruptQuestionTool', '用户已回答', { + 'question_length': len(question), + 'answer_length': len(answer) + }) + + return answer + + except Exception as e: + debug_logger.log_error( + 'InterruptQuestionTool', + f'提问过程出错: {str(e)}', + e + ) + return "【系统错误】提问失败" + + def _format_question(self, question: str, context: str = "") -> str: + """ + 格式化问题 + + Args: + question: 原始问题 + context: 上下文 + + Returns: + 格式化后的问题 + """ + if context: + return f"🤔 【智能体提问】\n\n背景:{context}\n\n问题:{question}" + else: + return f"🤔 【智能体提问】\n\n{question}" + + def create_tool_description(self) -> Dict[str, Any]: + """ + 创建工具描述(用于智能体理解如何使用此工具) + + Returns: + 工具描述字典 + """ + return { + 'name': 'interrupt_question', + 'description': '''向用户提出问题并等待回答。 + +使用场景: +- 任务信息不明确,需要用户补充 +- 需要用户确认某个决策 +- 遇到多个选择,需要用户决定 +- 需要用户提供具体的参数或数据 + +使用方法: +调用 ask_user(question, context) 函数 +- question: 要问的问题(必填) +- context: 问题的背景说明(可选) + +返回:用户的回答字符串 + +注意: +- 问题应该清晰明确 +- 避免一次问太多问题 +- 等待用户回答后再继续任务 +''', + 'parameters': { + 'question': { + 'type': 'string', + 'description': '要向用户提出的问题', + 'required': True + }, + 'context': { + 'type': 'string', + 'description': '问题的背景上下文说明', + 'required': False + } + } + } diff --git a/src/cerebellum/module.py b/src/cerebellum/module.py new file mode 100644 index 0000000..af3bc2f --- /dev/null +++ b/src/cerebellum/module.py @@ -0,0 +1,222 @@ +""" +CerebellumModule - 小脑模块(v4.0 神经系统接入层)。 + +将 src.cerebellum.vision、src.cerebellum.intent、src.cerebellum.interrupt、 +src.cerebellum.nps、src.cerebellum.style 中的工具能力包装为 BaseModule, +使其可以通过 CentralRouter 被其他模块调用。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from src.cerebellum.vision.agent_vision import AgentVisionTool +from src.cerebellum.intent.schedule_intent_tool import ScheduleIntentTool +from src.cerebellum.interrupt.interrupt_question_tool import InterruptQuestionTool +from src.cerebellum.nps.bridge_tool import NPSBridgeTool +from src.cerebellum.style.expression_style import ExpressionStyleManager +from src.core.database_manager import DatabaseManager +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +class CerebellumModule(BaseModule): + """ + 小脑模块。 + + 负责工具调用、视觉处理、快速反射等认知功能。 + """ + + module_id = "cerebellum.toolkit" + module_type = "cerebellum" + + def __init__( + self, + router: "CentralRouter", + db_manager: Optional[DatabaseManager] = None, + nps_invoker: Optional[Any] = None, + ) -> None: + super().__init__(router) + self._db = db_manager or DatabaseManager() + self._vision_tool = AgentVisionTool(self._db) + self._schedule_intent_tool = ScheduleIntentTool() + self._interrupt_tool = InterruptQuestionTool() + self._nps_bridge = NPSBridgeTool(nps_invoker) + self._expression_manager = ExpressionStyleManager(self._db) + + async def handle(self, packet: Packet) -> Packet: + """ + 处理小脑相关请求。 + + Channels: + - vision_should_use: 判断是否需要使用视觉 + - vision_context: 获取视觉上下文 + - vision_summary: 获取视觉上下文摘要 + - vision_switch: 切换环境 + - schedule_intent: 识别日程意图 + - interrupt_ask: 向用户中断提问 + - nps_call: 调用 NPS 工具 + - nps_list: 列出可用 NPS 工具 + - expression_agent_add: 添加智能体表达 + - expression_agent_list: 列出智能体表达 + - expression_user_learn: 学习用户表达习惯 + - expression_stats: 获取表达风格统计 + """ + channel = packet.channel + payload = packet.payload + + try: + if channel == "vision_should_use": + return self._handle_vision_should_use(packet, payload) + if channel == "vision_context": + return self._handle_vision_context(packet, payload) + if channel == "vision_summary": + return self._handle_vision_summary(packet, payload) + if channel == "vision_switch": + return self._handle_vision_switch(packet, payload) + if channel == "schedule_intent": + return self._handle_schedule_intent(packet, payload) + if channel == "interrupt_ask": + return self._handle_interrupt_ask(packet, payload) + if channel == "nps_call": + return self._handle_nps_call(packet, payload) + if channel == "nps_list": + return self._handle_nps_list(packet, payload) + if channel == "expression_agent_add": + return self._handle_expression_agent_add(packet, payload) + if channel == "expression_agent_list": + return self._handle_expression_agent_list(packet, payload) + if channel == "expression_user_learn": + return self._handle_expression_user_learn(packet, payload) + if channel == "expression_stats": + return self._handle_expression_stats(packet, payload) + except Exception as exc: # noqa: BLE001 + return Packet.error(packet, str(exc), code="INTERNAL_ERROR") + + return packet.response({ + "status": "unknown_channel", + "channel": channel, + }) + + def _handle_vision_should_use( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + query = payload.get("query", "") + use_llm = payload.get("use_llm", True) + should_use = self._vision_tool.should_use_vision(query, use_llm=use_llm) + return packet.response({"should_use": should_use}) + + def _handle_vision_context( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + query = payload.get("query", "") + use_llm = payload.get("use_llm", True) + context = self._vision_tool.get_vision_context(query) + summary = self._vision_tool.get_vision_summary(context) + return packet.response({"context": context, "summary": summary}) + + def _handle_vision_summary( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + context = payload.get("context") + summary = self._vision_tool.get_vision_summary(context) + return packet.response({"summary": summary}) + + def _handle_vision_switch( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + to_env_uuid = payload.get("to_env_uuid", "") + if not to_env_uuid: + return Packet.error(packet, "to_env_uuid required", code="INVALID_REQUEST") + success = self._vision_tool.switch_environment(to_env_uuid) + return packet.response({"success": success}) + + def _handle_schedule_intent( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + user_input = payload.get("user_input", "") + character_name = payload.get("character_name", "智能体") + context = payload.get("context", "") + result = self._schedule_intent_tool.recognize_intent( + user_input=user_input, + character_name=character_name, + context=context, + ) + return packet.response(result) + + def _handle_interrupt_ask( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + question = payload.get("question", "") + context = payload.get("context", "") + callback = payload.get("callback") + + if not callable(callback): + return Packet.error(packet, "callback must be callable", code="INVALID_REQUEST") + + self._interrupt_tool.set_question_callback(callback) + answer = self._interrupt_tool.ask_user(question, context=context) + return packet.response({"answer": answer}) + + def _handle_nps_call( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + tool_name = payload.get("tool_name", "") + query = payload.get("query", "") + kwargs = payload.get("kwargs", {}) + if not tool_name: + return Packet.error(packet, "tool_name required", code="INVALID_REQUEST") + result = self._nps_bridge.call_nps_tool(tool_name, query, **kwargs) + return packet.response(result) + + def _handle_nps_list( + self, packet: Packet, _payload: Dict[str, Any] + ) -> Packet: + result = self._nps_bridge.get_available_tools() + return packet.response(result) + + def _handle_expression_agent_add( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + expression = payload.get("expression", "") + meaning = payload.get("meaning", "") + category = payload.get("category", "通用") + if not expression or not meaning: + return Packet.error( + packet, "expression and meaning required", code="INVALID_REQUEST" + ) + uuid = self._expression_manager.add_agent_expression( + expression=expression, meaning=meaning, category=category + ) + return packet.response({"uuid": uuid}) + + def _handle_expression_agent_list( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + active_only = payload.get("active_only", True) + expressions = self._expression_manager.get_agent_expressions( + active_only=active_only + ) + return packet.response({"expressions": expressions}) + + def _handle_expression_user_learn( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + messages = payload.get("messages", []) + current_round = payload.get("current_round", 0) + habits = self._expression_manager.learn_user_expressions( + messages=messages, current_round=current_round + ) + return packet.response({"habits": habits}) + + def _handle_expression_stats( + self, packet: Packet, _payload: Dict[str, Any] + ) -> Packet: + stats = self._expression_manager.get_statistics() + return packet.response(stats) + + +__all__ = ["CerebellumModule"] diff --git a/src/cerebellum/nps/__init__.py b/src/cerebellum/nps/__init__.py new file mode 100644 index 0000000..aa23834 --- /dev/null +++ b/src/cerebellum/nps/__init__.py @@ -0,0 +1,19 @@ +""" +Cerebellum NPS - NPS 工具桥接 + +负责将 NPS 工具接入神经系统。 +""" + +from src.cerebellum.nps.bridge_tool import NPSBridgeTool +from src.cerebellum.nps.langchain_tools import ( + NPSSystemTimeTool, + NPSWebSearchTool, + create_nps_tools, +) + +__all__ = [ + "NPSBridgeTool", + "NPSSystemTimeTool", + "NPSWebSearchTool", + "create_nps_tools", +] diff --git a/src/cerebellum/nps/bridge_tool.py b/src/cerebellum/nps/bridge_tool.py new file mode 100644 index 0000000..38108d2 --- /dev/null +++ b/src/cerebellum/nps/bridge_tool.py @@ -0,0 +1,160 @@ +""" +NPS Bridge Tool - NPS工具桥接 +允许智能体协作系统调用NPS工具获取信息 +""" + +from typing import Dict, Any, Optional +from src.tools.debug_logger import get_debug_logger + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class NPSBridgeTool: + """ + NPS工具桥接类 + 为智能体提供调用NPS工具的接口 + """ + + def __init__(self, nps_invoker=None): + """ + 初始化NPS桥接工具 + + Args: + nps_invoker: NPSInvoker实例,用于调用NPS工具 + """ + self.nps_invoker = nps_invoker + debug_logger.log_module('NPSBridgeTool', 'NPS桥接工具初始化完成') + + def call_nps_tool(self, tool_name: str, query: str, **kwargs) -> Dict[str, Any]: + """ + 调用指定的NPS工具 + + Args: + tool_name: 工具名称(如'websearch', 'systime') + query: 查询内容或参数 + **kwargs: 其他参数 + + Returns: + NPS工具执行结果 + """ + if not self.nps_invoker: + debug_logger.log_error('NPSBridgeTool', 'NPS调用器未初始化', None) + return { + 'success': False, + 'error': 'NPS调用器未初始化' + } + + try: + # 构建执行上下文 + context = { + 'query': query, + **kwargs + } + + debug_logger.log_module( + 'NPSBridgeTool', + f'智能体调用NPS工具: {tool_name}', + {'query': query} + ) + + # 通过工具名称调用 + result = self.nps_invoker.invoke_tool_by_name(tool_name, context) + + if result.get('success'): + debug_logger.log_info('NPSBridgeTool', f'NPS工具调用成功: {tool_name}') + else: + debug_logger.log_warning('NPSBridgeTool', f'NPS工具调用失败: {tool_name}') + + return result + + except Exception as e: + debug_logger.log_error('NPSBridgeTool', f'NPS工具调用出错: {tool_name}', e) + return { + 'success': False, + 'error': f'调用NPS工具失败: {str(e)}' + } + + def search_web(self, query: str, num_results: int = 5, **kwargs) -> Dict[str, Any]: + """ + 网络搜索快捷方法 + + Args: + query: 搜索查询 + num_results: 返回结果数量 + **kwargs: 其他参数 + + Returns: + 搜索结果 + """ + return self.call_nps_tool('websearch', query, num_results=num_results, **kwargs) + + def get_system_time(self) -> Dict[str, Any]: + """ + 获取系统时间快捷方法 + + Returns: + 系统时间信息 + """ + return self.call_nps_tool('systime', '') + + def get_available_tools(self) -> Dict[str, Any]: + """ + 获取可用的NPS工具列表 + + Returns: + 工具列表 + """ + if not self.nps_invoker: + return { + 'success': False, + 'error': 'NPS调用器未初始化' + } + + try: + tools = self.nps_invoker.get_all_tools() + tool_list = [ + { + 'tool_id': tool.tool_id, + 'name': tool.name, + 'description': tool.description, + 'enabled': tool.enabled + } + for tool in tools + ] + + return { + 'success': True, + 'tools': tool_list, + 'count': len(tool_list) + } + except Exception as e: + debug_logger.log_error('NPSBridgeTool', '获取NPS工具列表失败', e) + return { + 'success': False, + 'error': str(e) + } + + def format_tool_list_for_agent(self) -> str: + """ + 将工具列表格式化为智能体可读的文本 + + Returns: + 格式化的工具列表描述 + """ + tools_result = self.get_available_tools() + + if not tools_result.get('success'): + return f"获取工具列表失败:{tools_result.get('error', '未知错误')}" + + tools = tools_result.get('tools', []) + if not tools: + return "当前没有可用的NPS工具" + + output = [f"可用的NPS工具共{len(tools)}个:\n"] + for tool in tools: + status = "✅" if tool['enabled'] else "❌" + output.append(f"{status} {tool['name']} ({tool['tool_id']})") + output.append(f" 功能:{tool['description']}\n") + + return '\n'.join(output) diff --git a/src/cerebellum/nps/langchain_tools.py b/src/cerebellum/nps/langchain_tools.py new file mode 100644 index 0000000..2de0eb1 --- /dev/null +++ b/src/cerebellum/nps/langchain_tools.py @@ -0,0 +1,126 @@ +""" +NPS LangChain Tool Wrapper +将NPS工具包装为LangChain Tool,使其可以被智能体调用 +""" + +from typing import Optional, Dict, Any +from langchain.tools import BaseTool +from pydantic import Field +from src.tools.debug_logger import get_debug_logger + +debug_logger = get_debug_logger() + + +class NPSWebSearchTool(BaseTool): + """NPS网络搜索工具 - LangChain兼容""" + + name: str = "nps_web_search" + description: str = """ + 使用SerpAPI进行网络搜索,获取实时信息、新闻、知识等。 + + 适用场景: + - 需要查询最新信息、新闻、动态 + - 搜索特定内容、知识、资料 + - 获取网络上的公开信息 + - 需要验证或补充信息 + + 输入格式:搜索查询文本(如"明日方舟终末地"、"最新AI新闻"等) + 输出格式:格式化的搜索结果,包含标题、摘要、链接等 + """ + + nps_bridge: Any = Field(default=None, exclude=True) + + def _run(self, query: str) -> str: + """执行搜索""" + if not self.nps_bridge: + return "错误:NPS桥接工具未初始化" + + try: + result = self.nps_bridge.search_web(query, num_results=5) + + if not result.get('success'): + return f"搜索失败:{result.get('error', '未知错误')}" + + # 格式化结果 + from src.nps.tool.websearch import format_search_results_for_agent + return format_search_results_for_agent(result.get('result', result)) + + except Exception as e: + debug_logger.log_error('NPSWebSearchTool', '搜索失败', e) + return f"搜索失败:{str(e)}" + + async def _arun(self, query: str) -> str: + """异步执行(降级到同步)""" + return self._run(query) + + +class NPSSystemTimeTool(BaseTool): + """NPS系统时间工具 - LangChain兼容""" + + name: str = "nps_system_time" + description: str = """ + 获取当前系统时间、日期、星期等信息。 + + 适用场景: + - 回答"现在几点"、"今天星期几"等时间相关问题 + - 需要知道当前日期、时间来完成任务 + - 判断时间段(早上/中午/晚上等) + + 输入格式:不需要输入,直接调用即可 + 输出格式:当前时间的详细信息(年月日、时分秒、星期、时段等) + """ + + nps_bridge: Any = Field(default=None, exclude=True) + + def _run(self, query: str = "") -> str: + """获取系统时间""" + if not self.nps_bridge: + return "错误:NPS桥接工具未初始化" + + try: + result = self.nps_bridge.get_system_time() + + if not result.get('success'): + return f"获取时间失败:{result.get('error', '未知错误')}" + + # 提取时间信息 + time_data = result.get('result', {}) + if isinstance(time_data, dict) and 'context' in time_data: + return time_data['context'] + + return str(time_data) + + except Exception as e: + debug_logger.log_error('NPSSystemTimeTool', '获取时间失败', e) + return f"获取时间失败:{str(e)}" + + async def _arun(self, query: str = "") -> str: + """异步执行(降级到同步)""" + return self._run(query) + + +def create_nps_tools(nps_bridge) -> list: + """ + 创建所有NPS工具的LangChain包装 + + Args: + nps_bridge: NPSBridgeTool实例 + + Returns: + LangChain Tool列表 + """ + tools = [] + + # 网络搜索工具 + web_search_tool = NPSWebSearchTool() + web_search_tool.nps_bridge = nps_bridge + tools.append(web_search_tool) + + # 系统时间工具 + system_time_tool = NPSSystemTimeTool() + system_time_tool.nps_bridge = nps_bridge + tools.append(system_time_tool) + + debug_logger.log_module('NPSLangChainTools', f'创建了{len(tools)}个NPS LangChain工具') + + return tools diff --git a/src/cerebellum/style/__init__.py b/src/cerebellum/style/__init__.py new file mode 100644 index 0000000..20b0bd6 --- /dev/null +++ b/src/cerebellum/style/__init__.py @@ -0,0 +1,9 @@ +""" +Cerebellum Style - 表达风格 + +负责智能体个性化表达和用户表达习惯学习。 +""" + +from src.cerebellum.style.expression_style import ExpressionStyleManager + +__all__ = ["ExpressionStyleManager"] diff --git a/src/cerebellum/style/expression_style.py b/src/cerebellum/style/expression_style.py new file mode 100644 index 0000000..4f5859c --- /dev/null +++ b/src/cerebellum/style/expression_style.py @@ -0,0 +1,457 @@ +""" +个性化表达风格模块 +负责管理智能体的个性化表达和学习用户的表达习惯 +""" + +import os +import re +import json +from typing import List, Dict, Any, Optional +from dotenv import load_dotenv +import requests +from src.core.database_manager import DatabaseManager +from src.core import llm_providers # v3.1.0: 统一供应商解析 +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class ExpressionStyleManager: + """ + 个性化表达风格管理器 + 负责: + 1. 管理智能体的个性化表达(如 'wc' => '表示对突发事情的感叹') + 2. 学习和总结用户的表达习惯 + """ + + # 类级别的常量配置 + LEARNING_INTERVAL = 10 # 用户表达习惯学习间隔(每N轮) + MIN_MESSAGES_FOR_LEARNING = 3 # 学习所需的最小用户消息数量 + CONFIDENCE_INCREMENT = 0.1 # 每次确认习惯时的置信度增加值 + + def __init__(self, + db_manager: DatabaseManager = None, + api_key: str = None, + api_url: str = None, + model_name: str = None): + """ + 初始化个性化表达风格管理器 + + Args: + db_manager: 数据库管理器实例(如果为None则创建新实例) + api_key: API密钥 + api_url: API地址 + model_name: 模型名称 + """ + # 使用共享的数据库管理器 + self.db = db_manager or DatabaseManager() + + # API配置(用于学习用户表达习惯) + # v3.1.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = api_key or llm_providers.resolve_api_key() + try: + self.api_url = api_url or llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + self.api_url = api_url or os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + self.model_name = model_name or os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') + + # 用户表达习惯学习间隔(保留实例属性以保持兼容性) + self.learning_interval = self.LEARNING_INTERVAL + + debug_logger.log_module('ExpressionStyleManager', '个性化表达风格管理器初始化完成') + print(f"✓ 个性化表达风格管理器已初始化") + + # ==================== 智能体表达管理 ==================== + + def add_agent_expression(self, expression: str, meaning: str, category: str = "通用") -> str: + """ + 添加智能体个性化表达 + + Args: + expression: 表达方式(如 'wc'、'hhh'、'orz') + meaning: 含义(如 '表示对突发事情的感叹') + category: 分类(如 '感叹词'、'网络用语'、'表情替代') + + Returns: + 表达UUID + """ + debug_logger.log_info('ExpressionStyleManager', '添加智能体表达', { + 'expression': expression, + 'meaning': meaning, + 'category': category + }) + + expr_uuid = self.db.add_agent_expression(expression, meaning, category) + print(f"✓ 已添加智能体表达: '{expression}' => '{meaning}'") + return expr_uuid + + def get_agent_expressions(self, active_only: bool = True) -> List[Dict[str, Any]]: + """ + 获取所有智能体个性化表达 + + Args: + active_only: 是否只获取激活的表达 + + Returns: + 表达列表 + """ + return self.db.get_all_agent_expressions(active_only) + + def update_agent_expression(self, expr_uuid: str, **kwargs) -> bool: + """ + 更新智能体表达 + + Args: + expr_uuid: 表达UUID + **kwargs: 要更新的字段 + + Returns: + 是否更新成功 + """ + return self.db.update_agent_expression(expr_uuid, **kwargs) + + def delete_agent_expression(self, expr_uuid: str) -> bool: + """ + 删除智能体表达 + + Args: + expr_uuid: 表达UUID + + Returns: + 是否删除成功 + """ + return self.db.delete_agent_expression(expr_uuid) + + def generate_agent_expression_prompt(self) -> Optional[str]: + """ + 生成智能体个性化表达提示词 + + Returns: + 提示词文本,如果没有表达则返回None + """ + expressions = self.get_agent_expressions(active_only=True) + + if not expressions: + return None + + prompt_parts = ["【个性化表达风格】", "在合适的语境下,你可以使用以下个性化表达方式:"] + + for expr in expressions: + prompt_parts.append(f" • '{expr['expression']}' => {expr['meaning']}") + + prompt_parts.append("") + prompt_parts.append("请在回复中自然地融入这些表达,使对话更加生动有趣。") + + return "\n".join(prompt_parts) + + # ==================== 用户表达习惯学习 ==================== + + def learn_user_expressions(self, messages: List[Dict[str, Any]], + current_round: int = 0) -> List[Dict[str, Any]]: + """ + 从对话中学习用户的表达习惯 + + Args: + messages: 最近的对话消息列表(只分析用户消息) + current_round: 当前对话轮次(用于记录学习来源) + + Returns: + 学习到的表达习惯列表 + """ + debug_logger.log_module('ExpressionStyleManager', '开始学习用户表达习惯', { + 'message_count': len(messages), + 'current_round': current_round + }) + + # 只提取用户消息 + user_messages = [msg for msg in messages if msg.get('role') == 'user'] + + if len(user_messages) < self.MIN_MESSAGES_FOR_LEARNING: + debug_logger.log_info('ExpressionStyleManager', '用户消息太少,跳过学习') + return [] + + # 构建用户消息文本 + user_text = "\n".join([msg['content'] for msg in user_messages]) + + # 使用LLM分析用户表达习惯 + learned_habits = self._analyze_user_expressions(user_text, current_round) + + if learned_habits: + debug_logger.log_info('ExpressionStyleManager', '学习到用户表达习惯', { + 'count': len(learned_habits) + }) + + # 保存学习到的习惯 + for habit in learned_habits: + existing = self.db.find_user_expression_habit(habit['expression_pattern']) + if existing: + # 更新现有习惯的频率和置信度 + # 确保置信度在有效范围 [0.0, 1.0] 内 + current_confidence = max(0.0, min(1.0, existing['confidence'])) + new_confidence = min(1.0, current_confidence + self.CONFIDENCE_INCREMENT) + self.db.increment_habit_frequency(existing['uuid']) + self.db.update_user_expression_habit( + existing['uuid'], + confidence=new_confidence, + meaning=habit['meaning'] # 可能有更新的理解 + ) + print(f" ✓ 更新用户习惯: '{habit['expression_pattern']}' (频率+1, 置信度={new_confidence:.2f})") + else: + # 添加新习惯 + self.db.add_user_expression_habit( + expression_pattern=habit['expression_pattern'], + meaning=habit['meaning'], + confidence=habit.get('confidence', 0.7), + learned_from_rounds=str(current_round) + ) + print(f" ✓ 新增用户习惯: '{habit['expression_pattern']}' => '{habit['meaning']}'") + + return learned_habits + + def _analyze_user_expressions(self, user_text: str, current_round: int) -> List[Dict[str, Any]]: + """ + 使用LLM分析用户的表达习惯 + + Args: + user_text: 用户消息文本 + current_round: 当前对话轮次 + + Returns: + 分析出的表达习惯列表 + """ + try: + analysis_prompt = f"""请分析以下用户消息中的表达习惯和特殊用语。 + +用户消息: +{user_text} + +请识别用户使用的: +1. 网络用语/缩写(如 'hhh'、'2333'、'xswl' 等) +2. 口头禅或常用表达 +3. 特殊的表达方式或语气词 +4. 个人化的表达习惯 + +对于每个识别到的表达,请分析其含义和使用场景。 + +返回JSON格式(只返回JSON数组,不要其他文字): +[ + {{ + "expression_pattern": "表达内容", + "meaning": "含义和使用场景", + "confidence": 0.8 + }} +] + +如果没有识别到特殊表达习惯,返回空数组 []""" + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': [ + {'role': 'system', 'content': '你是一个专业的语言分析助手,擅长识别用户的表达习惯和特殊用语。你只返回JSON格式的数据。'}, + {'role': 'user', 'content': analysis_prompt} + ], + 'temperature': 0.0, + 'max_tokens': 1000, + 'stream': False + } + + debug_logger.log_request('ExpressionStyleManager', self.api_url, payload, headers) + + # 使用可配置的超时时间 + api_timeout = int(os.getenv("LLM_API_TIMEOUT", "60")) + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=api_timeout + ) + + response.raise_for_status() + result = response.json() + + debug_logger.log_response('ExpressionStyleManager', result, response.status_code, 0) + + if 'choices' in result and len(result['choices']) > 0: + content = result['choices'][0]['message']['content'].strip() + + # 使用更健壮的JSON提取逻辑 + json_content = self._extract_json_from_response(content) + + try: + habits = json.loads(json_content) + if isinstance(habits, list): + return habits + else: + debug_logger.log_error('ExpressionStyleManager', '返回的不是列表格式') + return [] + except json.JSONDecodeError as e: + debug_logger.log_error('ExpressionStyleManager', f'JSON解析失败: {e}') + return [] + else: + debug_logger.log_error('ExpressionStyleManager', '未能获取有效的分析结果') + return [] + + except Exception as e: + debug_logger.log_error('ExpressionStyleManager', f'分析用户表达习惯时出错: {e}', e) + print(f"✗ 分析用户表达习惯时出错: {e}") + return [] + + def _extract_json_from_response(self, content: str) -> str: + """ + 从LLM响应中提取JSON内容 + + Args: + content: LLM响应内容 + + Returns: + 提取的JSON字符串 + """ + def is_valid_expression_json(json_str: str) -> bool: + """验证JSON数组是否包含预期的表达习惯结构""" + try: + data = json.loads(json_str) + if not isinstance(data, list): + return False + # 空数组是有效的 + if len(data) == 0: + return True + # 检查第一个元素是否包含预期的键 + if isinstance(data[0], dict): + expected_keys = {'expression_pattern', 'meaning'} + return expected_keys.issubset(set(data[0].keys())) + return False + except (json.JSONDecodeError, KeyError, IndexError): + return False + + # 尝试方法1:直接解析(如果内容本身就是有效JSON) + if is_valid_expression_json(content): + return content + + # 尝试方法2:使用正则表达式提取JSON数组 + json_array_pattern = r'\[[\s\S]*?\]' + matches = re.findall(json_array_pattern, content) + for match in matches: + if is_valid_expression_json(match): + return match + + # 尝试方法3:处理markdown代码块 + if '```' in content: + # 提取所有代码块 + code_blocks = re.findall(r'```(?:json)?\s*([\s\S]*?)```', content) + for block in code_blocks: + block = block.strip() + if is_valid_expression_json(block): + return block + + # 如果都失败了,返回空数组字符串 + return '[]' + + def get_user_expression_habits(self, min_confidence: float = 0.5) -> List[Dict[str, Any]]: + """ + 获取用户表达习惯 + + Args: + min_confidence: 最低置信度 + + Returns: + 习惯列表 + """ + return self.db.get_all_user_expression_habits(min_confidence) + + def generate_user_expression_context(self) -> Optional[str]: + """ + 生成用户表达习惯上下文提示词 + + Returns: + 提示词文本,如果没有可用的表达习惯则返回None + """ + habits = self.get_user_expression_habits(min_confidence=0.6) + + if not habits: + return None + + prompt_parts = ["【用户表达习惯参考】", "根据历史对话,用户有以下表达习惯:"] + + for habit in habits[:10]: # 最多显示10个习惯 + confidence_label = "高" if habit['confidence'] >= 0.8 else "中" + prompt_parts.append(f" • '{habit['expression_pattern']}' => {habit['meaning']} [置信度: {confidence_label}]") + + prompt_parts.append("") + prompt_parts.append("理解用户的这些表达习惯可以帮助你更好地理解用户的意图。") + + return "\n".join(prompt_parts) + + def clear_user_expression_habits(self) -> bool: + """ + 清空所有用户表达习惯 + + Returns: + 是否清空成功 + """ + return self.db.clear_user_expression_habits() + + # ==================== 统计方法 ==================== + + def get_statistics(self) -> Dict[str, Any]: + """ + 获取表达风格统计信息 + + Returns: + 统计信息字典 + """ + agent_expressions = self.db.get_all_agent_expressions(active_only=False) + user_habits = self.db.get_all_user_expression_habits() + + return { + 'agent_expressions': { + 'total': len(agent_expressions), + 'active': sum(1 for e in agent_expressions if e.get('is_active', 1)), + 'total_usage': sum(e.get('usage_count', 0) for e in agent_expressions) + }, + 'user_habits': { + 'total': len(user_habits), + 'high_confidence': sum(1 for h in user_habits if h.get('confidence', 0) >= 0.8), + 'medium_confidence': sum(1 for h in user_habits if 0.5 <= h.get('confidence', 0) < 0.8) + } + } + + +if __name__ == '__main__': + print("=" * 60) + print("个性化表达风格管理器测试") + print("=" * 60) + + manager = ExpressionStyleManager() + + # 测试添加智能体表达 + print("\n测试添加智能体表达:") + manager.add_agent_expression("wc", "表示对突发事情的感叹", "感叹词") + manager.add_agent_expression("hhh", "表示开心、高兴的笑声", "笑声表达") + manager.add_agent_expression("orz", "表示跪拜、佩服或无奈", "表情替代") + + # 显示所有智能体表达 + print("\n当前智能体表达:") + expressions = manager.get_agent_expressions() + for expr in expressions: + print(f" • '{expr['expression']}' => {expr['meaning']} (使用次数: {expr['usage_count']})") + + # 生成提示词 + print("\n生成的智能体表达提示词:") + prompt = manager.generate_agent_expression_prompt() + print(prompt) + + # 显示统计 + print("\n统计信息:") + stats = manager.get_statistics() + print(f" 智能体表达总数: {stats['agent_expressions']['total']}") + print(f" 用户习惯总数: {stats['user_habits']['total']}") + + print("\n✓ 测试完成") diff --git a/src/cerebellum/vision/__init__.py b/src/cerebellum/vision/__init__.py new file mode 100644 index 0000000..7e63390 --- /dev/null +++ b/src/cerebellum/vision/__init__.py @@ -0,0 +1,9 @@ +""" +Cerebellum Vision - 视觉处理 + +负责智能体视觉感知和环境上下文获取。 +""" + +from src.cerebellum.vision.agent_vision import AgentVisionTool + +__all__ = ["AgentVisionTool"] diff --git a/src/cerebellum/vision/agent_vision.py b/src/cerebellum/vision/agent_vision.py new file mode 100644 index 0000000..554325a --- /dev/null +++ b/src/cerebellum/vision/agent_vision.py @@ -0,0 +1,956 @@ +""" +智能体视觉工具模块 +通过读取数据库中的环境描述来模拟智能体的伪视觉功能 +当用户询问周围环境时,智能体自动决定是否使用此工具 +""" + +import os +import re +import time +from typing import Dict, Any, List, Optional +from datetime import datetime +from dotenv import load_dotenv +import requests +from src.core.database_manager import DatabaseManager +from src.core import llm_providers # v3.1.0: 统一供应商解析 +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class AgentVisionTool: + """ + 智能体视觉工具类 + 负责检测用户查询是否涉及环境,并从数据库读取相应的环境描述 + """ + + def __init__(self, db_manager: DatabaseManager = None): + """ + 初始化视觉工具 + + Args: + db_manager: 数据库管理器实例 + """ + self.db = db_manager or DatabaseManager() + + # API配置(用于智能判断是否需要使用视觉工具) + # v3.1.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = llm_providers.resolve_api_key() + try: + self.api_url = llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') + + # 环境相关关键词(用于快速判断) + # 包含常见的位置查询关键词,如"在哪"用于检测"你在哪?"等查询 + self.environment_keywords = [ + '周围', '周边', '环境', '这里', '附近', '哪里', '什么地方', '在哪', + '看到', '看见', '观察', '眼前', '面前', '旁边', '身边', + '房间', '屋子', '地方', '场景', '景色', '风景', + '有什么', '有哪些', '能看到', '可以看到' + ] + + debug_logger.log_module('AgentVisionTool', '视觉工具初始化完成', { + 'keywords_count': len(self.environment_keywords) + }) + + def should_use_vision_llm(self, user_query: str) -> bool: + """ + 使用LLM智能判断是否需要使用视觉工具 + + 注意: + - 输入清理可防止基本的注入攻击,但不是完全安全 + - 响应解析目前仅支持中文,需要国际化时需修改 + - 同步HTTP请求可能造成阻塞,高频调用时建议使用异步方式 + + Args: + user_query: 用户查询 + + Returns: + 是否需要使用视觉 + """ + debug_logger.log_module('AgentVisionTool', '使用LLM判断是否需要视觉', { + 'query': user_query + }) + + try: + # 对用户输入进行清理和验证,防止prompt注入攻击 + # 1. 移除可疑模式(系统提示、角色扮演等) + suspicious_patterns = [ + 'system:', 'assistant:', 'user:', + 'ignore previous', 'ignore all previous', + 'new instructions', 'forget everything', + '###', '---', '```' + ] + + # 检查是否包含可疑模式 + query_lower = user_query.lower() + for pattern in suspicious_patterns: + if pattern in query_lower: + debug_logger.log_error('AgentVisionTool', f"检测到可疑输入模式: {pattern}", None) + # 对于可疑输入,使用关键词匹配而非LLM + return self._check_vision_keywords(user_query) + + # 2. 基本清理 + cleaned_query = user_query.replace('"', '\\"').replace('\n', ' ').strip() + + # 3. 限制查询长度 + if len(cleaned_query) > 500: + cleaned_query = cleaned_query[:500] + + # 构建判断提示词 + judge_prompt = f"""请判断以下用户问题是否需要智能体观察周围环境才能回答。 + +用户问题:"{cleaned_query}" + +需要观察环境的情况包括但不限于: +1. 询问智能体的位置或所在地(如:你在哪?你在哪里?) +2. 询问周围有什么、看到什么 +3. 询问环境、房间、场景相关的问题 +4. 询问附近、旁边、面前的事物 +5. 需要了解当前环境状态才能回答的问题 + +请只回答"是"或"否",不要有其他内容。""" + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # 从环境变量读取配置或使用默认值 + try: + llm_temperature = float(os.getenv('VISION_LLM_TEMPERATURE', '0.3')) + except ValueError: + llm_temperature = 0.3 + debug_logger.log_info('AgentVisionTool', '无效的VISION_LLM_TEMPERATURE,使用默认值0.3') + + try: + llm_max_tokens = int(os.getenv('VISION_LLM_MAX_TOKENS', '10')) + except ValueError: + llm_max_tokens = 10 + debug_logger.log_info('AgentVisionTool', '无效的VISION_LLM_MAX_TOKENS,使用默认值10') + + try: + llm_timeout = int(os.getenv('VISION_LLM_TIMEOUT', '10')) + except ValueError: + llm_timeout = 10 + debug_logger.log_info('AgentVisionTool', '无效的VISION_LLM_TIMEOUT,使用默认值10') + + payload = { + 'model': self.model_name, + 'messages': [ + {'role': 'system', 'content': '你是一个智能判断助手,负责判断用户问题是否需要观察环境。'}, + {'role': 'user', 'content': judge_prompt} + ], + 'temperature': llm_temperature, + 'max_tokens': llm_max_tokens + } + + debug_logger.log_info('AgentVisionTool', '发送LLM判断请求') + + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=llm_timeout + ) + + response.raise_for_status() + result = response.json() + + if 'choices' in result and len(result['choices']) > 0: + answer = result['choices'][0]['message']['content'].strip() + # 更精确的判断:完全匹配"是"或以"是"开头 + # 注意:此判断逻辑仅适用于中文,国际化时需要修改 + needs_vision = (answer == '是' or answer.startswith('是,') or answer.startswith('是。')) + + debug_logger.log_info('AgentVisionTool', 'LLM判断完成', { + 'query': user_query, + 'answer': answer, + 'needs_vision': needs_vision + }) + + return needs_vision + else: + debug_logger.log_info('AgentVisionTool', 'LLM响应无效,回退到关键词匹配') + return self._fallback_to_keyword(user_query) + + except Exception as e: + debug_logger.log_error('AgentVisionTool', f'LLM判断失败: {str(e)}', e) + # 如果LLM调用失败,回退到关键词匹配 + return self._fallback_to_keyword(user_query) + + def _fallback_to_keyword(self, user_query: str) -> bool: + """ + 回退到关键词匹配(内部方法) + + Args: + user_query: 用户查询 + + Returns: + 是否需要使用视觉 + """ + debug_logger.log_info('AgentVisionTool', '使用关键词匹配作为后备方案') + return self.should_use_vision_keyword(user_query) + + def should_use_vision_keyword(self, user_query: str) -> bool: + """ + 判断是否需要使用视觉工具(基于关键词快速判断) + 这是备用方法,当LLM不可用时使用 + + Args: + user_query: 用户查询 + + Returns: + 是否需要使用视觉 + """ + debug_logger.log_module('AgentVisionTool', '使用关键词判断是否需要视觉', { + 'query_length': len(user_query) + }) + + # 快速关键词匹配 + query_lower = user_query.lower() + for keyword in self.environment_keywords: + if keyword in query_lower: + debug_logger.log_info('AgentVisionTool', '检测到环境相关关键词', { + 'keyword': keyword, + 'query': user_query + }) + return True + + debug_logger.log_info('AgentVisionTool', '未检测到环境相关关键词', { + 'query': user_query + }) + return False + + def should_use_vision(self, user_query: str, use_llm: bool = True) -> bool: + """ + 判断是否需要使用视觉工具(智能判断) + 优先使用LLM进行智能判断,如果LLM不可用或禁用则使用关键词匹配 + + Args: + user_query: 用户查询 + use_llm: 是否使用LLM进行智能判断,默认为True + + Returns: + 是否需要使用视觉 + """ + if use_llm: + return self.should_use_vision_llm(user_query) + else: + return self.should_use_vision_keyword(user_query) + + def get_vision_context(self, user_query: str) -> Optional[Dict[str, Any]]: + """ + 获取视觉上下文(环境描述) + + Args: + user_query: 用户查询 + + Returns: + 视觉上下文字典,包含环境描述和物体信息 + """ + debug_logger.log_module('AgentVisionTool', '开始获取视觉上下文', { + 'query': user_query + }) + + # 检查是否需要使用视觉 + if not self.should_use_vision(user_query): + debug_logger.log_info('AgentVisionTool', '不需要使用视觉工具', { + 'reason': '未检测到环境相关查询' + }) + return None + + # 获取当前激活的环境 + environment = self.db.get_active_environment() + if not environment: + debug_logger.log_info('AgentVisionTool', '没有激活的环境', { + 'suggestion': '请先创建并激活一个环境' + }) + return None + + debug_logger.log_info('AgentVisionTool', '找到激活的环境', { + 'env_name': environment['name'], + 'env_uuid': (environment['uuid'][:8] + '...') if len(environment['uuid']) > 8 else environment['uuid'] + }) + + # 获取环境中的物体 + objects = self.db.get_environment_objects(environment['uuid'], visible_only=True) + + debug_logger.log_info('AgentVisionTool', '获取环境物体', { + 'objects_count': len(objects) + }) + + # 构建视觉上下文 + vision_context = { + 'environment': environment, + 'objects': objects, + 'object_count': len(objects), + 'query': user_query, + 'timestamp': datetime.now().isoformat() + } + + # 记录视觉工具使用 + objects_viewed = ', '.join([obj['name'] for obj in objects]) + context_text = self._format_vision_context(vision_context) + + self.db.log_vision_tool_usage( + query=user_query, + environment_uuid=environment['uuid'], + objects_viewed=objects_viewed, + context_provided=context_text[:500], # 只保存前500字符 + triggered_by='auto' + ) + + debug_logger.log_info('AgentVisionTool', '视觉上下文获取完成', { + 'environment': environment['name'], + 'objects_count': len(objects), + 'context_length': len(context_text) + }) + + return vision_context + + def _format_vision_context(self, vision_context: Dict[str, Any]) -> str: + """ + 格式化视觉上下文为文本描述 + + Args: + vision_context: 视觉上下文字典 + + Returns: + 格式化的文本描述 + """ + if not vision_context: + return "" + + # 检查是否为域级别的上下文 + if vision_context.get('type') == 'domain': + domain = vision_context['domain'] + current_env = vision_context.get('current_environment') + + context_parts = ["【智能体视觉感知 - 域级别】"] + context_parts.append(f"\n所在域: {domain['name']}") + if domain.get('description'): + context_parts.append(f"\n域描述: {domain['description']}") + if current_env: + context_parts.append(f"\n当前具体位置: {current_env['name']}") + + # 获取域中的环境列表 + environments = self.db.get_domain_environments(domain['uuid']) + if environments: + env_names = [env['name'] for env in environments] + context_parts.append(f"\n域包含的区域: {', '.join(env_names)}") + + context_parts.append("\n\n💡 请基于域级别的位置信息回答用户的问题。如需更详细信息,可询问用户具体位置。") + + return '\n'.join(context_parts) + + # 原有的环境级别上下文格式化 + environment = vision_context.get('environment') + objects = vision_context.get('objects', []) + + if not environment: + return "" + + context_parts = ["【智能体视觉感知】"] + context_parts.append(f"\n环境名称: {environment['name']}") + context_parts.append(f"\n整体描述: {environment['overall_description']}") + + # 添加感官细节 + if environment.get('atmosphere'): + context_parts.append(f"氛围: {environment['atmosphere']}") + if environment.get('lighting'): + context_parts.append(f"光照: {environment['lighting']}") + if environment.get('sounds'): + context_parts.append(f"声音: {environment['sounds']}") + if environment.get('smells'): + context_parts.append(f"气味: {environment['smells']}") + + # 添加物体信息 + if objects: + context_parts.append(f"\n可见物体(共{len(objects)}个):") + for obj in objects: + obj_desc = f"\n 🔹 {obj['name']}" + obj_desc += f"\n 描述: {obj['description']}" + if obj.get('position'): + obj_desc += f"\n 位置: {obj['position']}" + if obj.get('properties'): + obj_desc += f"\n 属性: {obj['properties']}" + if obj.get('interaction_hints'): + obj_desc += f"\n 交互: {obj['interaction_hints']}" + context_parts.append(obj_desc) + else: + context_parts.append("\n当前环境中没有可见物体。") + + context_parts.append("\n\n💡 请基于以上视觉感知信息回答用户的问题。") + + return '\n'.join(context_parts) + + def format_vision_prompt(self, vision_context: Dict[str, Any]) -> str: + """ + 将视觉上下文格式化为系统提示词 + + Args: + vision_context: 视觉上下文字典 + + Returns: + 格式化的提示词 + """ + return self._format_vision_context(vision_context) + + def get_vision_summary(self, vision_context: Dict[str, Any]) -> str: + """ + 获取视觉上下文的简要摘要(用于显示) + + Args: + vision_context: 视觉上下文字典 + + Returns: + 简要摘要 + """ + if not vision_context: + return "未获取到视觉信息" + + # 检查是否为域级别的上下文 + if vision_context.get('type') == 'domain': + domain = vision_context['domain'] + current_env = vision_context.get('current_environment') + summary = f"👁️ [视觉感知-域] 域: {domain['name']}" + if current_env: + summary += f" | 位置: {current_env['name']}" + return summary + + # 原有的环境级别摘要 + env = vision_context.get('environment') + obj_count = vision_context.get('object_count', 0) + + if not env: + return "未获取到视觉信息" + + summary = f"👁️ [视觉感知] 环境: {env['name']}" + if obj_count > 0: + summary += f" | 可见物体: {obj_count}个" + + return summary + + def create_default_environment(self) -> str: + """ + 创建默认环境(用于初始化或测试) + + Returns: + 环境UUID + """ + debug_logger.log_module('AgentVisionTool', '创建默认环境') + + env_uuid = self.db.create_environment( + name="小可的房间", + overall_description="这是一个温馨舒适的学生卧室,约15平方米。墙壁刷成淡粉色,地板铺着浅色木地板。房间整洁有序,充满学习的氛围。", + atmosphere="温馨、宁静、充满书香气息", + lighting="柔和的自然光从窗户洒入,桌上的台灯散发着暖黄色的光", + sounds="偶尔能听到窗外鸟鸣和微风拂过树叶的沙沙声", + smells="空气中弥漫着淡淡的书香和薰衣草香薰的味道" + ) + + # 设置为激活环境 + self.db.set_active_environment(env_uuid) + + # 添加一些默认物体 + default_objects = [ + { + "name": "书桌", + "description": "一张简约的白色书桌,约120cm宽,上面摆放着各种学习用品", + "position": "靠窗的位置", + "properties": "材质: 实木, 颜色: 白色, 状态: 整洁", + "interaction_hints": "可以在这里学习、写作业、看书", + "priority": 90 + }, + { + "name": "书架", + "description": "一个四层的白色书架,摆满了各类书籍,尤其是历史类书籍特别多", + "position": "书桌右侧的墙边", + "properties": "材质: 木质, 层数: 4层, 书籍数量: 约100本", + "interaction_hints": "可以挑选书籍阅读,历史类书籍最多", + "priority": 85 + }, + { + "name": "床", + "description": "一张单人床,铺着淡粉色的床单和被套,上面放着几个可爱的抱枕", + "position": "房间左侧靠墙", + "properties": "大小: 单人床(1.2m), 颜色: 粉色系, 状态: 整理好的", + "interaction_hints": "可以休息、睡觉", + "priority": 80 + }, + { + "name": "台灯", + "description": "一盏护眼台灯,设计简洁,可以调节亮度和色温", + "position": "书桌右上角", + "properties": "品牌: 明基, 类型: LED护眼灯, 状态: 关闭", + "interaction_hints": "可以开启用于学习照明", + "priority": 70 + }, + { + "name": "笔记本电脑", + "description": "一台轻薄的笔记本电脑,银色外壳,通常用于查资料和学习", + "position": "书桌中央", + "properties": "品牌: 华为, 颜色: 银色, 状态: 合上的", + "interaction_hints": "可以打开用于学习、查资料", + "priority": 85 + }, + { + "name": "窗户", + "description": "一扇宽大的窗户,透过窗户可以看到外面的树木和天空", + "position": "书桌后方", + "properties": "类型: 推拉窗, 尺寸: 大型, 状态: 半开", + "interaction_hints": "可以打开通风,欣赏外面的景色", + "priority": 75 + }, + { + "name": "挂钟", + "description": "一个圆形的挂钟,简约的设计,静音机芯", + "position": "门的上方墙壁", + "properties": "类型: 石英钟, 颜色: 白色, 特点: 静音", + "interaction_hints": "可以查看时间", + "priority": 60 + } + ] + + for obj_data in default_objects: + self.db.add_environment_object( + environment_uuid=env_uuid, + name=obj_data['name'], + description=obj_data['description'], + position=obj_data.get('position', ''), + properties=obj_data.get('properties', ''), + interaction_hints=obj_data.get('interaction_hints', ''), + priority=obj_data.get('priority', 50) + ) + + debug_logger.log_info('AgentVisionTool', '默认环境创建完成', { + 'env_uuid': (env_uuid[:8] + '...') if len(env_uuid) > 8 else env_uuid, + 'objects_count': len(default_objects) + }) + + print(f"✓ 默认环境创建完成: 小可的房间({len(default_objects)}个物体)") + + return env_uuid + + def detect_environment_switch_intent(self, user_query: str) -> Optional[Dict[str, Any]]: + """ + 检测用户是否有切换环境的意图 + + Args: + user_query: 用户查询 + + Returns: + 如果检测到切换意图,返回包含目标环境等信息的字典,否则返回None + """ + debug_logger.log_module('AgentVisionTool', '检测环境切换意图', { + 'query': user_query + }) + + # 环境切换关键词 + switch_keywords = [ + '去', '走', '移动', '前往', '过去', '进入', '离开', '出去', + '回到', '返回', '切换', '换到', '转移' + ] + + query_lower = user_query.lower() + has_switch_keyword = any(keyword in query_lower for keyword in switch_keywords) + + if not has_switch_keyword: + debug_logger.log_info('AgentVisionTool', '未检测到切换关键词') + return None + + # 获取当前环境 + current_env = self.db.get_active_environment() + if not current_env: + debug_logger.log_info('AgentVisionTool', '没有当前激活的环境') + return None + + # 获取所有连通的环境 + connected_envs = self.db.get_connected_environments(current_env['uuid']) + if not connected_envs: + debug_logger.log_info('AgentVisionTool', '当前环境没有连通的环境') + return None + + # 尝试匹配环境名称 + matched_env = None + for env in connected_envs: + if env['name'] in user_query: + matched_env = env + break + + if matched_env: + debug_logger.log_info('AgentVisionTool', '检测到环境切换意图', { + 'from': current_env['name'], + 'to': matched_env['name'] + }) + return { + 'intent': 'switch_environment', + 'from_env': current_env, + 'to_env': matched_env, + 'can_switch': True + } + + debug_logger.log_info('AgentVisionTool', '未匹配到目标环境') + return None + + def switch_environment(self, to_env_uuid: str) -> bool: + """ + 切换到指定环境 + + Args: + to_env_uuid: 目标环境UUID + + Returns: + 是否切换成功 + """ + debug_logger.log_module('AgentVisionTool', '执行环境切换', { + 'to_env_uuid': (to_env_uuid[:8] + '...') if len(to_env_uuid) > 8 else to_env_uuid + }) + + current_env = self.db.get_active_environment() + if not current_env: + debug_logger.log_info('AgentVisionTool', '没有当前激活的环境') + return False + + # 检查是否可以切换(是否连通) + if not self.db.can_move_to_environment(current_env['uuid'], to_env_uuid): + debug_logger.log_info('AgentVisionTool', '不能切换到目标环境', { + 'reason': '环境不连通' + }) + return False + + # 执行切换 + success = self.db.set_active_environment(to_env_uuid) + if success: + to_env = self.db.get_environment(to_env_uuid) + debug_logger.log_info('AgentVisionTool', '环境切换成功', { + 'from': current_env['name'], + 'to': to_env['name'] if to_env else 'Unknown' + }) + print(f"✓ 环境已切换: {current_env['name']} → {to_env['name'] if to_env else 'Unknown'}") + else: + debug_logger.log_info('AgentVisionTool', '环境切换失败') + + return success + + def get_available_environments_for_switch(self) -> List[Dict[str, Any]]: + """ + 获取可以切换到的环境列表 + + Returns: + 可切换环境列表 + """ + current_env = self.db.get_active_environment() + if not current_env: + return [] + + return self.db.get_connected_environments(current_env['uuid']) + + # ==================== 环境域相关方法 ==================== + + def get_current_domain(self) -> Optional[Dict[str, Any]]: + """ + 获取当前环境所属的域 + 如果当前环境属于多个域,返回第一个域 + + Returns: + 域信息字典或None + """ + current_env = self.db.get_active_environment() + if not current_env: + debug_logger.log_info('AgentVisionTool', '没有当前激活的环境') + return None + + domains = self.db.get_environment_domains(current_env['uuid']) + if domains: + debug_logger.log_info('AgentVisionTool', '找到当前环境所属的域', { + 'domain_name': domains[0]['name'], + 'domain_count': len(domains) + }) + return domains[0] + + debug_logger.log_info('AgentVisionTool', '当前环境不属于任何域') + return None + + def get_domain_description(self, domain_uuid: str, use_default_env: bool = False) -> str: + """ + 获取域的描述信息 + + Args: + domain_uuid: 域UUID + use_default_env: 是否使用默认环境的详细描述 + + Returns: + 域的描述文本 + """ + domain = self.db.get_domain(domain_uuid) + if not domain: + return "" + + # 获取域中的环境列表 + environments = self.db.get_domain_environments(domain_uuid) + + if use_default_env and domain['default_environment_uuid']: + # 使用默认环境的详细描述 + default_env = self.db.get_environment(domain['default_environment_uuid']) + if default_env: + desc = f"【{domain['name']}】\n" + desc += f"{domain['description']}\n" if domain['description'] else "" + desc += f"当前位置: {default_env['name']}\n" + desc += f"{default_env['overall_description']}" + return desc + + # 使用域级别的概括描述 + desc = f"【{domain['name']}】\n" + desc += f"{domain['description']}\n" if domain['description'] else "" + + if environments: + env_names = [env['name'] for env in environments] + desc += f"包含环境: {', '.join(env_names)}" + + return desc + + def get_vision_context_with_precision(self, user_query: str, + high_precision: bool = False) -> Optional[Dict[str, Any]]: + """ + 根据精度要求获取视觉上下文 + + Args: + user_query: 用户查询 + high_precision: 是否需要高精度(具体环境)描述 + False: 返回域级别的描述 + True: 返回具体环境的详细描述 + + Returns: + 视觉上下文字典,包含环境或域的描述信息 + """ + debug_logger.log_module('AgentVisionTool', '根据精度获取视觉上下文', { + 'query': user_query, + 'high_precision': high_precision + }) + + # 检查是否需要使用视觉 + if not self.should_use_vision(user_query): + debug_logger.log_info('AgentVisionTool', '不需要使用视觉工具') + return None + + # 获取当前激活的环境 + current_env = self.db.get_active_environment() + if not current_env: + debug_logger.log_info('AgentVisionTool', '没有激活的环境') + return None + + # 检查当前环境是否属于某个域 + domains = self.db.get_environment_domains(current_env['uuid']) + + if not high_precision and domains: + # 低精度模式:返回域级别的描述 + domain = domains[0] # 使用第一个域 + domain_desc = self.get_domain_description(domain['uuid'], use_default_env=False) + + vision_context = { + 'type': 'domain', + 'domain': domain, + 'current_environment': current_env, + 'description': domain_desc, + 'query': user_query, + 'timestamp': datetime.now().isoformat() + } + + debug_logger.log_info('AgentVisionTool', '返回域级别视觉上下文', { + 'domain_name': domain['name'] + }) + + return vision_context + else: + # 高精度模式或不属于域:返回具体环境的详细描述 + return self.get_vision_context(user_query) + + def detect_precision_requirement(self, user_query: str) -> bool: + """ + 检测用户查询是否需要高精度的环境信息 + + Args: + user_query: 用户查询 + + Returns: + 是否需要高精度(True=需要具体环境,False=域级别即可) + """ + # 高精度关键词(需要具体环境描述) + high_precision_keywords = [ + '具体', '详细', '什么东西', '有什么', '有哪些', '看到', + '周围', '附近', '房间', '屋子', '物体', '物品' + ] + + query_lower = user_query.lower() + for keyword in high_precision_keywords: + if keyword in query_lower: + debug_logger.log_info('AgentVisionTool', '检测到高精度需求', { + 'keyword': keyword + }) + return True + + debug_logger.log_info('AgentVisionTool', '低精度需求(域级别)') + return False + + def switch_to_domain(self, domain_uuid: str) -> bool: + """ + 切换到指定域(会切换到该域的默认环境) + + Args: + domain_uuid: 目标域UUID + + Returns: + 是否切换成功 + """ + # 安全截取UUID用于日志显示 + uuid_display = domain_uuid[:8] + '...' if len(domain_uuid) > 8 else domain_uuid + debug_logger.log_module('AgentVisionTool', '切换到域', { + 'domain_uuid': uuid_display + }) + + domain = self.db.get_domain(domain_uuid) + if not domain: + debug_logger.log_info('AgentVisionTool', '域不存在') + return False + + # 如果域有默认环境,切换到默认环境 + if domain['default_environment_uuid']: + default_env = self.db.get_environment(domain['default_environment_uuid']) + if default_env: + success = self.db.set_active_environment(domain['default_environment_uuid']) + if success: + debug_logger.log_info('AgentVisionTool', '已切换到域的默认环境', { + 'domain': domain['name'], + 'default_env': default_env['name'] + }) + print(f"✓ 已切换到域: {domain['name']} (默认位置: {default_env['name']})") + return success + else: + debug_logger.log_info('AgentVisionTool', '域的默认环境不存在') + return False + else: + # 如果没有设置默认环境,切换到域中的第一个环境 + environments = self.db.get_domain_environments(domain_uuid) + if environments: + first_env = environments[0] + success = self.db.set_active_environment(first_env['uuid']) + if success: + debug_logger.log_info('AgentVisionTool', '已切换到域的第一个环境', { + 'domain': domain['name'], + 'env': first_env['name'] + }) + print(f"✓ 已切换到域: {domain['name']} (位置: {first_env['name']})") + return success + else: + debug_logger.log_info('AgentVisionTool', '域中没有环境') + return False + + def detect_domain_switch_intent(self, user_query: str) -> Optional[Dict[str, Any]]: + """ + 检测用户是否有切换到域的意图 + + Args: + user_query: 用户查询 + + Returns: + 如果检测到切换意图,返回包含目标域等信息的字典,否则返回None + """ + debug_logger.log_module('AgentVisionTool', '检测域切换意图', { + 'query': user_query + }) + + # 域切换关键词 + switch_keywords = [ + '去', '走', '移动', '前往', '过去', '进入', '离开', '出去', + '回到', '返回', '切换', '换到', '转移' + ] + + query_lower = user_query.lower() + has_switch_keyword = any(keyword in query_lower for keyword in switch_keywords) + + if not has_switch_keyword: + debug_logger.log_info('AgentVisionTool', '未检测到切换关键词') + return None + + # 获取所有域 + all_domains = self.db.get_all_domains() + if not all_domains: + debug_logger.log_info('AgentVisionTool', '没有已定义的域') + return None + + # 尝试匹配域名称 + matched_domain = None + for domain in all_domains: + if domain['name'] in user_query: + matched_domain = domain + break + + if matched_domain: + current_env = self.db.get_active_environment() + debug_logger.log_info('AgentVisionTool', '检测到域切换意图', { + 'from_env': current_env['name'] if current_env else 'None', + 'to_domain': matched_domain['name'] + }) + return { + 'intent': 'switch_domain', + 'from_env': current_env, + 'to_domain': matched_domain, + 'can_switch': True + } + + debug_logger.log_info('AgentVisionTool', '未匹配到目标域') + return None + + +# 测试代码 +if __name__ == '__main__': + print("=" * 60) + print("智能体视觉工具测试") + print("=" * 60) + + # 创建视觉工具实例 + vision_tool = AgentVisionTool() + + # 创建默认环境 + print("\n创建默认环境:") + env_uuid = vision_tool.create_default_environment() + + # 测试视觉工具 + print("\n" + "=" * 60) + print("测试视觉工具") + print("=" * 60) + + test_queries = [ + "周围有什么?", + "我能看到什么?", + "房间里有哪些东西?", + "你在哪?", # 应该触发视觉(需要环境信息) + "你在哪里?", # 应该触发视觉(需要环境信息) + "今天天气怎么样?", # 不应该触发视觉 + "帮我讲个历史故事", # 不应该触发视觉 + ] + + for query in test_queries: + print(f"\n测试查询: {query}") + should_use = vision_tool.should_use_vision(query) + print(f" 是否使用视觉: {'是' if should_use else '否'}") + + if should_use: + vision_context = vision_tool.get_vision_context(query) + if vision_context: + summary = vision_tool.get_vision_summary(vision_context) + print(f" {summary}") + print(f"\n 视觉上下文预览:") + prompt = vision_tool.format_vision_prompt(vision_context) + print(f" {prompt[:300]}...") + + print("\n" + "=" * 60) + print("✓ 测试完成") + print("=" * 60) diff --git a/src/core/__init__.py b/src/core/__init__.py index ed8ab9a..c79ad4e 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -3,7 +3,12 @@ 核心模块包含对话代理、数据库管理、记忆系统等核心功能 -Usage: +注意:以下 Usage 块中的 import 行为为示例说明,并非 __init__ 运行时真实 import。 +本文件通过 __all__ 暴露子模块名,子模块的类需由调用方自行 import。 +本仓库中实际可用的类名为 TemporaryScheduleGenerator(位于 src/core/schedule_generator.py), +不存在顶层名为 ScheduleGenerator 的类。 + +Usage (示例): from src.core.chat_agent import ChatAgent from src.core.database_manager import DatabaseManager from src.core.emotion_analyzer import EmotionAnalyzer @@ -13,7 +18,7 @@ from src.core.base_knowledge import BaseKnowledge from src.core.multi_agent_coordinator import MultiAgentCoordinator from src.core.schedule_manager import ScheduleManager - from src.core.schedule_generator import ScheduleGenerator + from src.core.schedule_generator import TemporaryScheduleGenerator from src.core.schedule_similarity_checker import ScheduleSimilarityChecker """ diff --git a/src/core/background_scheduler.py b/src/core/background_scheduler.py new file mode 100644 index 0000000..1db8a24 --- /dev/null +++ b/src/core/background_scheduler.py @@ -0,0 +1,285 @@ +""" +后台调度入口模块 +- 异步执行 P1/P2/P3/P4 阶段的后台任务 +- 通过共享 DatabaseManager 与 EventManager 与 Web/API 层通信 +- 线程隔离:asyncio.run 在独立子线程运行,与主线程事件循环物理隔离 + +参考架构:spec §4 ADDED Requirements → BackgroundScheduler +""" + +import os +import asyncio +import threading +from datetime import datetime +from typing import Optional, List, Dict, Any + +from src.tools.debug_logger import get_debug_logger + +ENABLE_BACKGROUND_SCHEDULER = os.getenv('ENABLE_BACKGROUND_SCHEDULER', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + + +class BackgroundScheduler: + """ + 后台调度器 + - scheduler_loop: 异步主循环 + - 任务调度:梦境生成 / 日记生成 / 创作推进 / 主动决策评估 + - 在独立子线程运行,与主线程事件循环物理隔离 + """ + + DEFAULT_TICK_SECONDS = 60 + TASK_REGISTRY_KEY = 'background_scheduler_tasks' + + def __init__(self, tick_seconds: int = DEFAULT_TICK_SECONDS, + life_state=None, + dream_diary=None, + creative_writer=None, + proactive_engine=None, + open_loop_tracker=None): + self.tick_seconds = tick_seconds + self.life_state = life_state + self.dream_diary = dream_diary + self.creative_writer = creative_writer + self.proactive_engine = proactive_engine + self.open_loop_tracker = open_loop_tracker + self.enabled = ENABLE_BACKGROUND_SCHEDULER + self._thread: Optional[threading.Thread] = None + self._stop_event: Optional[asyncio.Event] = None + self._last_run_at: Dict[str, str] = {} + + # ===== 启停 ===== + + def start(self) -> bool: + """ + 在独立子线程启动 asyncio 事件循环,物理隔离于主线程事件循环。 + """ + if not self.enabled: + debug_logger.log_info('BackgroundScheduler', '功能未启用,跳过启动') + return False + if self._thread and self._thread.is_alive(): + debug_logger.log_info('BackgroundScheduler', '已在运行,跳过启动') + return True + self._stop_event = threading.Event() + self._thread = threading.Thread( + target=self._run_in_thread, + name='BackgroundScheduler', + daemon=True, + ) + self._thread.start() + debug_logger.log_info('BackgroundScheduler', '已在子线程启动', { + 'tick_seconds': self.tick_seconds, + }) + return True + + def stop(self, timeout: float = 5.0) -> bool: + if self._stop_event: + self._stop_event.set() + if self._thread: + self._thread.join(timeout=timeout) + debug_logger.log_info('BackgroundScheduler', '已停止') + return True + + def _run_in_thread(self) -> None: + try: + asyncio.run(self.scheduler_loop()) + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'主循环异常退出: {e}', e) + + # ===== 主循环 ===== + + async def scheduler_loop(self) -> None: + """ + 每 tick_seconds 秒轮询一次,按各任务 last_run_at 决定是否执行。 + """ + debug_logger.log_info('BackgroundScheduler', '主循环开始', { + 'tick_seconds': self.tick_seconds, + }) + while True: + try: + # 检查停止事件 + if self._stop_event and self._stop_event.is_set(): + debug_logger.log_info('BackgroundScheduler', '收到停止信号,退出主循环') + break + # 按序执行 + self._tick_dream_generation() + self._tick_diary_generation() + self._tick_creative_advance() + self._tick_proactive_evaluation() + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'tick 异常: {e}', e) + + # Stage D.1: 每个 tick 结束后向 EventManager 推送状态,try/except 保护 + try: + self.tick() + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'tick 状态发布失败: {e}', e) + + # 异步睡眠 + await asyncio.sleep(self.tick_seconds) + + # ===== Stage D.1: 单次 tick 状态发布 ===== + def tick(self) -> None: + """ + 单次 tick:收集当前状态快照并通过 EventManager.publish 推送给订阅者。 + + - 不修改任何调度逻辑;仅在调度循环末尾调用。 + - payload 包含 life_state_snapshot / dream_record / open_loop_count / + creative_project_count / last_run_at / running 等只读字段。 + - 单次 publish 失败不会影响主循环。 + """ + try: + from src.core.event_manager import get_event_manager + em = get_event_manager() + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'获取 EventManager 失败: {e}', e) + return + + # 收集状态(所有外部访问均 try/except,避免影响主流程) + status_payload: Dict[str, Any] = { + 'enabled': self.enabled, + 'running': bool(self._thread and self._thread.is_alive()), + 'tick_seconds': self.tick_seconds, + 'last_run_at': dict(self._last_run_at), + 'timestamp': datetime.now().isoformat(), + } + + # life_state_snapshot + try: + if self.life_state and getattr(self.life_state, 'enabled', False): + snap = self.life_state.get_current_state_snapshot() or {} + status_payload['life_state_snapshot'] = snap + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'life_state snapshot 失败: {e}', e) + status_payload['life_state_snapshot'] = None + + # dream_record(最新一条梦境) + try: + if self.dream_diary and getattr(self.dream_diary, 'enabled', False): + latest = None + getter = getattr(self.dream_diary, 'get_latest_dream', None) + if callable(getter): + latest = getter() + status_payload['dream_record'] = latest + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'dream_record 拉取失败: {e}', e) + status_payload['dream_record'] = None + + # open_loop_count + try: + if self.open_loop_tracker and getattr(self.open_loop_tracker, 'enabled', False): + status_payload['open_loop_count'] = int( + self.open_loop_tracker.count_open_loops() + ) + else: + status_payload['open_loop_count'] = 0 + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'open_loop_count 失败: {e}', e) + status_payload['open_loop_count'] = 0 + + # creative_project_count + try: + if self.creative_writer and getattr(self.creative_writer, 'enabled', False): + status_payload['creative_project_count'] = int( + self.creative_writer.count_projects() + ) + else: + status_payload['creative_project_count'] = 0 + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'creative_project_count 失败: {e}', e) + status_payload['creative_project_count'] = 0 + + # 推送给 EventManager 订阅者(如 Web 后端 EventService) + try: + em.publish('scheduler_tick', status_payload) + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'publish scheduler_tick 失败: {e}', e) + + # ===== 子任务 ===== + + def _should_run(self, task: str, interval_minutes: int) -> bool: + last = self._last_run_at.get(task) + now = datetime.now() + if not last: + return True + try: + last_dt = datetime.fromisoformat(last) + except (ValueError, TypeError): + return True + return (now - last_dt).total_seconds() / 60.0 >= interval_minutes + + def _tick_dream_generation(self) -> None: + # 默认 24h 一次;与日历日的凌晨触发对齐 + if not self.dream_diary or not getattr(self.dream_diary, 'enabled', False): + return + if not self._should_run('dream_generation', 60 * 24): + return + try: + self.dream_diary.generate_dream_pick() + self._last_run_at['dream_generation'] = datetime.now().isoformat() + debug_logger.log_info('BackgroundScheduler', '梦境生成已调度') + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'梦境生成失败: {e}', e) + + def _tick_diary_generation(self) -> None: + if not self.dream_diary or not getattr(self.dream_diary, 'enabled', False): + return + if not self._should_run('diary_generation', 60 * 24): + return + try: + self.dream_diary.generate_daily_diary() + self._last_run_at['diary_generation'] = datetime.now().isoformat() + debug_logger.log_info('BackgroundScheduler', '日记生成已调度') + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'日记生成失败: {e}', e) + + def _tick_creative_advance(self) -> None: + if not self.creative_writer or not getattr(self.creative_writer, 'enabled', False): + return + try: + self.creative_writer.maybe_advance_creative_projects() + self._last_run_at['creative_advance'] = datetime.now().isoformat() + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'创作推进失败: {e}', e) + + def _tick_proactive_evaluation(self) -> None: + if not self.proactive_engine or not getattr(self.proactive_engine, 'enabled', False): + return + # 默认每 5 分钟评估一次 + if not self._should_run('proactive_evaluation', 5): + return + try: + user = 'default' + ok, reason = self.proactive_engine.should_send(user) + if ok: + pool = self.proactive_engine.proactive_impulse_pool(user) + if pool: + idea = pool[0] + self.proactive_engine.bot_proactive_drive_send(user, idea) + self._last_run_at['proactive_evaluation'] = datetime.now().isoformat() + except Exception as e: + debug_logger.log_error('BackgroundScheduler', f'主动决策评估失败: {e}', e) + + # ===== 状态查询 ===== + + def get_status(self) -> Dict[str, Any]: + return { + 'enabled': self.enabled, + 'running': bool(self._thread and self._thread.is_alive()), + 'tick_seconds': self.tick_seconds, + 'last_run_at': dict(self._last_run_at), + } + + +# 便于 main.py 调用的便捷函数 +_default_scheduler: Optional[BackgroundScheduler] = None + + +def start_default_scheduler(**kwargs) -> Optional[BackgroundScheduler]: + global _default_scheduler + if _default_scheduler is not None and _default_scheduler._thread and _default_scheduler._thread.is_alive(): + return _default_scheduler + _default_scheduler = BackgroundScheduler(**kwargs) + if _default_scheduler.start(): + return _default_scheduler + return None diff --git a/src/core/base_knowledge.py b/src/core/base_knowledge.py index 8af8f56..4894f03 100644 --- a/src/core/base_knowledge.py +++ b/src/core/base_knowledge.py @@ -1,263 +1,4 @@ -""" -基础知识库模块 -存储智能体的核心基础知识,这些知识: -1. 拥有最高优先级(100%正确) -2. 不可被覆盖或更改 -3. 即使与现实相悖也以该知识为准 -4. 不以上下文形式存在,而是直接嵌入到提示词中 -""" - -import os -from typing import Dict, List, Any -from src.core.database_manager import DatabaseManager - - -class BaseKnowledge: - """ - 基础知识库管理器 - 管理智能体的核心基础知识,这些知识具有绝对权威性 - 使用数据库存储替代JSON文件 - """ - - def __init__(self, db_manager: DatabaseManager = None): - """ - 初始化基础知识库 - - Args: - db_manager: 数据库管理器实例(如果为None则创建新实例) - """ - self.db = db_manager or DatabaseManager() - - # 检查是否需要初始化默认知识 - base_facts = self.db.get_all_base_facts() - if not base_facts: - self._initialize_default_knowledge() - - # 检查是否需要从JSON迁移数据 - if os.path.exists('base_knowledge.json'): - print("○ 检测到旧的JSON文件,正在迁移到数据库...") - self.db.migrate_from_json('base_knowledge.json', 'base_knowledge') - # 迁移完成后可以选择重命名或删除JSON文件 - os.rename('base_knowledge.json', 'base_knowledge.json.bak') - print("✓ JSON文件已备份为 base_knowledge.json.bak") - - def _initialize_default_knowledge(self): - """ - 初始化默认的基础知识 - """ - print("○ 初始化默认基础知识...") - - # 添加默认知识:HeDaas是一个高校 - self.add_base_fact( - entity_name="HeDaas", - fact_content="HeDaas是一个高校", - category="机构类型", - description="HeDaas的基本定义", - immutable=True - ) - - print("✓ 默认基础知识已初始化") - - - def add_base_fact(self, - entity_name: str, - fact_content: str, - category: str = "通用", - description: str = "", - immutable: bool = True) -> bool: - """ - 添加一条基础事实到基础知识库 - - Args: - entity_name: 实体名称(如"HeDaas") - fact_content: 事实内容(如"HeDaas是一个高校") - category: 分类(如"机构类型"、"定义"等) - description: 描述说明 - immutable: 是否不可变(默认True,基础知识应该不可变) - - Returns: - 是否添加成功 - """ - try: - # 检查是否已存在 - existing = self.db.get_base_fact(entity_name) - if existing: - print(f"⚠ 基础事实已存在: {entity_name}") - print(f" 现有内容: {existing['content']}") - print(f" → 基础知识不可覆盖,保持原有内容") - return False - - # 添加基础事实 - result = self.db.add_base_fact( - entity_name=entity_name, - content=fact_content, - category=category, - description=description, - immutable=immutable - ) - - if result: - print(f"✓ 已添加基础事实: {entity_name} -> {fact_content}") - return result - - except Exception as e: - print(f"✗ 添加基础事实时出错: {e}") - return False - - def get_base_fact(self, entity_name: str) -> Dict[str, Any] | None: - """ - 获取指定实体的基础事实(支持不区分大小写的查找) - - Args: - entity_name: 实体名称 - - Returns: - 基础事实字典,如果不存在返回None - """ - return self.db.get_base_fact(entity_name) - - def check_conflict_with_base(self, entity_name: str, new_content: str) -> bool: - """ - 检查新知识是否与基础知识冲突 - - Args: - entity_name: 实体名称 - new_content: 新的知识内容 - - Returns: - True表示存在冲突,False表示无冲突 - """ - base_fact = self.db.get_base_fact(entity_name) - - if not base_fact: - return False - - - # 如果新内容与基础事实不同,则存在冲突 - if new_content.strip() != base_fact['content'].strip(): - print(f"⚠ 检测到与基础知识冲突:") - print(f" 实体: {entity_name}") - print(f" 基础知识: {base_fact['content']}") - print(f" 新知识: {new_content}") - print(f" → 基础知识优先级最高,将保持基础知识不变") - return True - - return False - - def get_all_base_facts(self) -> List[Dict[str, Any]]: - """ - 获取所有基础事实 - - Returns: - 基础事实列表 - """ - return self.db.get_all_base_facts() - - def generate_base_knowledge_prompt(self) -> str: - """ - 生成用于嵌入提示词的基础知识文本 - 这些知识将直接嵌入到系统提示词中,确保AI始终遵循 - - Returns: - 基础知识提示词文本 - """ - base_facts = self.get_all_base_facts() - - if not base_facts: - return "" - - prompt_parts = [ - "\n【核心基础知识 - 最高优先级】", - "以下是你必须遵守的核心基础知识,这些知识具有绝对权威性:", - "- 优先级:100%(最高)", - "- 准确性:100%(绝对正确)", - "- 即使与其他信息相悖,也必须以这些基础知识为准", - "" - ] - - # 按分类组织基础知识 - by_category = {} - for fact in base_facts: - category = fact.get('category', '通用') - if category not in by_category: - by_category[category] = [] - by_category[category].append(fact) - - # 生成每个分类的知识 - for category, facts in by_category.items(): - prompt_parts.append(f"[{category}]") - for fact in facts: - prompt_parts.append(f"• {fact['content']}") - if fact.get('description'): - prompt_parts.append(f" 说明: {fact['description']}") - prompt_parts.append("") - - prompt_parts.append("请在所有回答中严格遵循以上基础知识。") - prompt_parts.append("=" * 60) - - return "\n".join(prompt_parts) - - def get_statistics(self) -> Dict[str, Any]: - """ - 获取基础知识库统计信息 - - Returns: - 统计信息字典 - """ - base_facts = self.get_all_base_facts() - - # 按分类统计 - by_category = {} - for fact in base_facts: - category = fact.get('category', '通用') - by_category[category] = by_category.get(category, 0) + 1 - - return { - 'total_facts': len(base_facts), - 'category_distribution': by_category, - 'all_immutable': all(f.get('immutable', True) for f in base_facts) - } - - def remove_base_fact(self, entity_name: str) -> bool: - """ - 删除基础事实(谨慎使用,基础知识一般不应删除) - - Args: - entity_name: 实体名称 - - Returns: - 是否删除成功 - """ - return self.db.delete_base_fact(entity_name) - - -if __name__ == '__main__': - print("=" * 60) - print("基础知识库测试") - print("=" * 60) - - # 创建基础知识库实例 - base_kb = BaseKnowledge() - - # 显示统计信息 - print("\n基础知识库统计:") - stats = base_kb.get_statistics() - print(f"总事实数: {stats['total_facts']}") - print(f"分类分布: {stats['category_distribution']}") - print(f"全部不可变: {stats['all_immutable']}") - - # 显示所有基础事实 - print("\n所有基础事实:") - for fact in base_kb.get_all_base_facts(): - print(f"• {fact['entity_name']}: {fact['content']}") - print(f" 分类: {fact['category']}, 优先级: {fact['priority']}, 置信度: {fact['confidence']}") - - # 生成基础知识提示词 - print("\n生成的基础知识提示词:") - print(base_kb.generate_base_knowledge_prompt()) - - # 测试冲突检测 - print("\n测试冲突检测:") - has_conflict = base_kb.check_conflict_with_base("HeDaas", "HeDaas是一家公司") - print(f"冲突检测结果: {has_conflict}") +# Compatibility shim: src.core.base_knowledge has moved to src.limbic.hippocampus.base_knowledge (v4.0) +from src.limbic.hippocampus.base_knowledge import BaseKnowledge +__all__ = ["BaseKnowledge"] diff --git a/src/core/chat_agent.py b/src/core/chat_agent.py index acf5905..1d94b00 100644 --- a/src/core/chat_agent.py +++ b/src/core/chat_agent.py @@ -6,16 +6,18 @@ """ import os +import re import json import time +import asyncio from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, AsyncIterator from dotenv import load_dotenv import requests from src.core.database_manager import DatabaseManager -from src.core.long_term_memory import LongTermMemoryManager +from src.core.long_term_memory import LongTermMemoryManager, OpenLoopTracker from src.tools.debug_logger import get_debug_logger -from src.core.emotion_analyzer import EmotionRelationshipAnalyzer +from src.core.emotion_analyzer import EmotionRelationshipAnalyzer, PlutchikEmotionWheel from src.tools.agent_vision import AgentVisionTool from src.core.event_manager import EventManager, EventType, EventStatus, NotificationEvent, TaskEvent from src.tools.interrupt_question_tool import InterruptQuestionTool @@ -24,6 +26,10 @@ from src.core.schedule_manager import ScheduleManager, ScheduleType, SchedulePriority from src.tools.schedule_intent_tool import ScheduleIntentTool from src.core.schedule_generator import TemporaryScheduleGenerator +from src.core.life_state import LifeStateManager +from src.core.dream_diary import DreamDiaryManager +from src.core.creative_writer import CreativeProjectManager +from src.core.proactive_engine import ProactiveEngine from src.nps.nps_registry import NPSRegistry from src.nps.nps_invoker import NPSInvoker @@ -324,7 +330,7 @@ def __init__(self): self.max_tokens = main_config['max_tokens'] if not self.config.is_valid(): - print("警告: 未设置有效的API密钥,请在.env文件中配置SILICONFLOW_API_KEY") + print("警告: 未设置有效的API密钥,请在.env文件中配置LLM_API_KEY(兼容旧 SILICONFLOW_API_KEY)") else: # 打印多层模型配置 print(self.config.get_summary()) @@ -407,7 +413,46 @@ def __init__(self): # 初始化临时日程生成器 self.schedule_generator = TemporaryScheduleGenerator(schedule_manager=self.schedule_manager) - + + # 初始化生活状态机(P1) + self.life_state_manager = LifeStateManager( + db_manager=self.db, + character_info=self.character.get_info_dict(), + ) + + # 初始化梦境与日记管理器(P1) + self.dream_diary_manager = DreamDiaryManager( + db_manager=self.db, + life_state=self.life_state_manager, + long_term_memory=self.memory_manager, + character_info=self.character.get_info_dict(), + ) + + # 初始化情绪轮(P2,与累加评分并行) + self.emotion_wheel = PlutchikEmotionWheel(db_manager=self.db) + # 初始化未完话题跟踪器(P2) + self.open_loop_tracker = OpenLoopTracker(db_manager=self.db) + + # 初始化长期创作项目管理器(P3) + self.creative_writer = CreativeProjectManager( + db_manager=self.db, + character_info=self.character.get_info_dict(), + life_state=self.life_state_manager, + dream_diary=self.dream_diary_manager, + ) + + # 初始化主动决策引擎(P4) + self.proactive_engine = ProactiveEngine( + db_manager=self.db, + event_manager=self.event_manager, + life_state=self.life_state_manager, + emotion_wheel=self.emotion_wheel, + open_loop_tracker=self.open_loop_tracker, + creative_writer=self.creative_writer, + ) + # 跟踪上次用户消息时间,用于空闲检测 + self._last_user_message_at = datetime.now() + # 初始化NPS工具系统 self.nps_registry = NPSRegistry() self.nps_invoker = NPSInvoker(registry=self.nps_registry) @@ -463,6 +508,30 @@ def chat(self, user_input: str) -> str: # 1. 从用户输入中提取相关主体并检索知识 relevant_knowledge = self.memory_manager.knowledge_base.get_relevant_knowledge_for_query(user_input) + # 1.1 P2: 未完话题检测(理解阶段第 1 步追加) + try: + loop_result = self.open_loop_tracker.update_from_message( + user=getattr(self, 'current_user', 'default'), + text=user_input, + ) + if loop_result: + debug_logger.log_info('ChatAgent', '未完话题已捕获', { + 'topic': loop_result.get('topic', ''), + 'uuid': loop_result.get('uuid', ''), + }) + except Exception as e: + debug_logger.log_error('ChatAgent', f'未完话题检测失败: {str(e)}', e) + + # 1.2 P2: 解析已匹配的未完话题(用户已回应了之前的话题) + try: + resolved = self.open_loop_tracker.resolve_matching_loop(user_input) + if resolved: + debug_logger.log_info('ChatAgent', '已解析未完话题', { + 'topic': resolved.get('topic', ''), + }) + except Exception as e: + debug_logger.log_error('ChatAgent', f'未完话题解析失败: {str(e)}', e) + # 2. 检测环境切换意图 switch_intent = self.vision_tool.detect_environment_switch_intent(user_input) if switch_intent and switch_intent.get('can_switch'): @@ -666,6 +735,20 @@ def chat(self, user_input: str) -> str: 'nps_used': nps_context is not None }) + # P3: 空闲时手动触发创作推进(用户无消息 N 分钟) + try: + now_ts = datetime.now() + idle_minutes = (now_ts - self._last_user_message_at).total_seconds() / 60.0 + if idle_minutes >= 30: # 默认 30 分钟视为空闲 + # 推进已到期项目 + self.creative_writer.maybe_advance_creative_projects() + # 满足条件则尝试立项 + self.creative_writer.maybe_start_creative_project(idle_checked=True) + except Exception as e: + debug_logger.log_error('ChatAgent', f'空闲创作推进失败: {e}', e) + finally: + self._last_user_message_at = datetime.now() + # 添加用户消息到记忆 self.memory_manager.add_message('user', user_input) @@ -815,6 +898,61 @@ def chat(self, user_input: str) -> str: 'reason': '未进行过情感分析' }) + # P2: 当下情绪(Plutchik 8 基本情绪,与累加评分平行) + try: + emotion_state = {'emotions': {}} + last_state = getattr(self, '_last_emotion_wheel_state', None) + if last_state: + emotion_state = self.emotion_wheel.decay_emotions(last_state) + emotion_hint = self.emotion_wheel.format_emotion_hint(emotion_state) + if emotion_hint: + messages.append({'role': 'system', 'content': emotion_hint}) + self._last_emotion_wheel_state = emotion_state + debug_logger.log_prompt('ChatAgent', 'system', emotion_hint, { + 'stage': '当下情绪(Plutchik)', + 'prompt_length': len(emotion_hint) + }) + except Exception as e: + debug_logger.log_error('ChatAgent', f'情绪轮提示注入失败: {str(e)}', e) + + # P2: 未完话题上下文(与 generate_tone_prompt 平行注入) + try: + open_loop_prompt = self.open_loop_tracker.format_for_prompt(hint=user_input[:30]) + if open_loop_prompt: + messages.append({'role': 'system', 'content': open_loop_prompt}) + debug_logger.log_prompt('ChatAgent', 'system', open_loop_prompt, { + 'stage': '未完话题上下文', + 'prompt_length': len(open_loop_prompt) + }) + except Exception as e: + debug_logger.log_error('ChatAgent', f'未完话题注入失败: {str(e)}', e) + + # 添加生活状态提示(P1:与 generate_tone_prompt 平行注入) + try: + life_state_prompt = self.life_state_manager.format_state_for_prompt() + if life_state_prompt: + messages.append({'role': 'system', 'content': life_state_prompt}) + debug_logger.log_prompt('ChatAgent', 'system', life_state_prompt, { + 'stage': '生活状态提示', + 'prompt_length': len(life_state_prompt) + }) + debug_logger.log_info('ChatAgent', '已添加生活状态提示到系统消息') + except Exception as e: + debug_logger.log_error('ChatAgent', f'生活状态提示注入失败: {str(e)}', e) + + # 添加最近日记上下文(P1) + try: + diary_context = self.dream_diary_manager.recent_diary_context(count=3) + if diary_context: + messages.append({'role': 'system', 'content': diary_context}) + debug_logger.log_prompt('ChatAgent', 'system', diary_context, { + 'stage': '最近日记上下文', + 'context_length': len(diary_context) + }) + debug_logger.log_info('ChatAgent', '已添加最近日记上下文到系统消息') + except Exception as e: + debug_logger.log_error('ChatAgent', f'日记上下文注入失败: {str(e)}', e) + # 添加智能体个性化表达提示 agent_expression_prompt = self.expression_style_manager.generate_agent_expression_prompt() if agent_expression_prompt: @@ -908,6 +1046,165 @@ def chat(self, user_input: str) -> str: return response + async def chat_stream( + self, + user_input: str, + *, + user_id: str = "default", + cancel_event: Optional[asyncio.Event] = None, + context: Optional[Dict] = None, + ) -> AsyncIterator[str]: + """ + 异步流式生成回复。 + Stream the agent's reply asynchronously as token/chunk strings. + + 行为策略 (简化版,避免破坏现有 chat() 同步方法): + 1. 优先尝试 LangChain LLM 的 astream() 方法(如果 LLM 支持) + 2. 降级到 run_in_executor 包装同步 chat(),按 token 切片模拟流式输出 + 3. 整链路使用 try/except 保护,astream 失败不破坏同步 chat() + 4. 支持外部 cancel_event:消费者或上层可设置事件来提前终止流式输出 + + Args: + user_input: 用户输入的消息 + user_id: 用户标识(仅记录/扩展位,目前未直接使用) + cancel_event: 取消事件;set 后流式提前退出 + context: 可选的额外上下文(向后兼容,预留扩展位) + + Yields: + 每个 token / chunk 字符串 + + Raises: + 不主动抛异常;异常会被 yield "[stream error] ..." 后 StopIteration + """ + # ===== 前置:取消信号快速检测 ===== + if cancel_event is not None and cancel_event.is_set(): + return + + # ===== 策略 1:尝试 LangChain astream ===== + used_astream = False + try: + llm_obj = getattr(self, 'llm', None) + if llm_obj is not None and hasattr(llm_obj, 'astream'): + # 构造 messages(与 chat() 保持相同输入格式) + try: + stream_messages = self._build_messages(user_input, context) + except Exception as build_err: + debug_logger.log_error( + 'ChatAgent', f'chat_stream 构建消息失败: {str(build_err)}', build_err + ) + stream_messages = None + + if stream_messages is not None: + try: + async for chunk in llm_obj.astream(stream_messages): + # 取消检查:消费者请求中断 + if cancel_event is not None and cancel_event.is_set(): + return + # LangChain AIMessageChunk / str 兼容 + if hasattr(chunk, 'content') and chunk.content is not None: + content = chunk.content + if isinstance(content, str) and content: + yield content + elif isinstance(chunk, str) and chunk: + yield chunk + else: + text = str(chunk) + if text: + yield text + used_astream = True + except (AttributeError, NotImplementedError) as stream_err: + debug_logger.log_error( + 'ChatAgent', f'LLM.astream 不可用,降级到同步: {str(stream_err)}', stream_err + ) + except Exception as stream_err: + # 其它异常也降级,避免破坏同步 chat() + debug_logger.log_error( + 'ChatAgent', f'LLM.astream 调用失败,降级到同步: {str(stream_err)}', stream_err + ) + except Exception as outer_err: + debug_logger.log_error( + 'ChatAgent', f'chat_stream astream 路径异常: {str(outer_err)}', outer_err + ) + + if used_astream: + return + + # ===== 策略 2:降级到 run_in_executor + 同步 chat() ===== + try: + loop = asyncio.get_event_loop() + full_response = await loop.run_in_executor( + None, self.chat, user_input + ) + except asyncio.CancelledError: + # 取消时直接向上传播 + raise + except Exception as e: + debug_logger.log_error( + 'ChatAgent', f'chat_stream 同步降级调用失败: {str(e)}', e + ) + yield f"[stream error] {e}" + return + + if not full_response: + return + + # 按"非空白词 + 尾部空白"切片,模拟 token 流式输出 + # 每个 token 视作 1-3 个字符的等效单元,间隔 40ms 避免网络洪水 + try: + tokens = re.findall(r'\S+\s*|\s+', full_response) + if not tokens: + # 正则未匹配(极端情况下),退化为按 2 字符切片 + tokens = [full_response[i:i + 2] for i in range(0, len(full_response), 2)] + if not tokens: + tokens = list(full_response) + + for token in tokens: + # 取消检查 + if cancel_event is not None and cancel_event.is_set(): + return + # 允许取消信号传播 + await asyncio.sleep(0) + yield token + # 模拟流式延迟:40ms(30-50ms 区间内),平衡流畅度与带宽 + await asyncio.sleep(0.04) + except asyncio.CancelledError: + raise + except Exception as slice_err: + debug_logger.log_error( + 'ChatAgent', f'chat_stream 分片失败: {str(slice_err)}', slice_err + ) + # 兜底:一次性把全部内容推出去 + yield full_response + + def _build_messages( + self, + user_input: str, + context: Optional[Dict] = None, + ) -> List[Dict[str, str]]: + """ + 为流式模式构建 LLM 输入消息列表。 + + 注意:chat() 的 message 构建过程较长(含理解/检索/上下文注入), + 此处提供一个最小可用版本以保证 astream 路径可用。完整构建仍由 chat() 完成。 + context 参数保留以备未来扩展。 + """ + messages: List[Dict[str, str]] = [] + # 系统提示(若可用) + try: + if hasattr(self, 'system_prompt') and self.system_prompt: + messages.append({'role': 'system', 'content': self.system_prompt}) + elif hasattr(self, 'character') and hasattr(self.character, 'get_system_prompt'): + messages.append( + {'role': 'system', 'content': self.character.get_system_prompt()} + ) + except Exception: + # 忽略系统提示构建错误,不阻塞流式输出 + pass + + # 用户本轮消息 + messages.append({'role': 'user', 'content': user_input}) + return messages + def _build_knowledge_context(self, relevant_knowledge: Dict[str, Any]) -> str: """ 根据检索到的知识构建上下文提示 diff --git a/src/core/chat_session_repository.py b/src/core/chat_session_repository.py new file mode 100644 index 0000000..c4aa422 --- /dev/null +++ b/src/core/chat_session_repository.py @@ -0,0 +1,14 @@ +""" +ChatSessionRepository - v3.1.0 兼容层 + +v4.0 重构说明: +- 真实实现已迁移到 src.limbic.hippocampus.session_store.SessionStore +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.limbic.hippocampus.session_store.SessionStore +""" + +from __future__ import annotations + +from src.limbic.hippocampus.session_store import SessionStore as ChatSessionRepository + +__all__ = ["ChatSessionRepository"] diff --git a/src/core/conversation_graph.py b/src/core/conversation_graph.py deleted file mode 100644 index 4dc1d31..0000000 --- a/src/core/conversation_graph.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -LangGraph对话流程管理模块 -使用LangGraph实现状态管理和对话流程编排 -""" - -from typing import TypedDict, Annotated, List, Dict, Any, Optional -import operator -from langgraph.graph import StateGraph, END - -from src.tools.debug_logger import get_debug_logger - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class ConversationState(TypedDict): - """ - 对话状态 - 定义对话流程中的所有状态变量 - """ - # 用户输入 - user_input: str - - # 消息历史 - messages: Annotated[List[Dict[str, str]], operator.add] - - # 理解阶段的结果 - understanding: Dict[str, Any] - - # 知识库检索结果 - knowledge: Dict[str, Any] - - # 视觉上下文 - vision_context: Optional[Dict[str, Any]] - - # 日程上下文 - schedule_context: Optional[str] - schedule_action: Optional[str] - - # NPS工具上下文 - nps_context: Optional[str] - - # 是否需要情感分析 - need_emotion_analysis: bool - - # 情感分析结果 - emotion_data: Optional[Dict[str, Any]] - - # AI回复 - ai_response: str - - # 错误信息 - error: Optional[str] - - -class ConversationGraph: - """ - 对话流程图 - 使用LangGraph编排对话的各个阶段 - """ - - def __init__(self, chat_agent): - """ - 初始化对话流程图 - - Args: - chat_agent: ChatAgent实例,提供各种功能模块的访问 - """ - self.agent = chat_agent - self.graph = self._build_graph() - - debug_logger.log_module('ConversationGraph', '对话流程图初始化完成', {}) - - def _build_graph(self) -> StateGraph: - """ - 构建对话流程状态图 - - Returns: - StateGraph实例 - """ - # 创建状态图 - workflow = StateGraph(ConversationState) - - # 添加节点 - workflow.add_node("understand", self._understand_node) - workflow.add_node("retrieve_knowledge", self._retrieve_knowledge_node) - workflow.add_node("check_vision", self._check_vision_node) - workflow.add_node("check_schedule", self._check_schedule_node) - workflow.add_node("check_nps", self._check_nps_node) - workflow.add_node("generate_response", self._generate_response_node) - workflow.add_node("analyze_emotion", self._analyze_emotion_node) - - # 设置入口点 - workflow.set_entry_point("understand") - - # 添加边(流程路径) - workflow.add_edge("understand", "retrieve_knowledge") - workflow.add_edge("retrieve_knowledge", "check_vision") - workflow.add_edge("check_vision", "check_schedule") - workflow.add_edge("check_schedule", "check_nps") - workflow.add_edge("check_nps", "generate_response") - - # 条件边:是否需要情感分析 - workflow.add_conditional_edges( - "generate_response", - self._should_analyze_emotion, - { - "analyze": "analyze_emotion", - "end": END - } - ) - - workflow.add_edge("analyze_emotion", END) - - # 编译图 - return workflow.compile() - - def _understand_node(self, state: ConversationState) -> Dict[str, Any]: - """ - 理解阶段节点:初始化状态 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', '理解阶段开始', { - 'input_length': len(state['user_input']) - }) - - return { - "understanding": { - "stage": "understand", - "completed": True - } - } - - def _retrieve_knowledge_node(self, state: ConversationState) -> Dict[str, Any]: - """ - 知识检索节点:从知识库检索相关信息 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', '知识检索阶段', {}) - - # 调用知识库检索 - relevant_knowledge = self.agent.memory_manager.knowledge_base.get_relevant_knowledge_for_query( - state['user_input'] - ) - - return { - "knowledge": relevant_knowledge - } - - def _check_vision_node(self, state: ConversationState) -> Dict[str, Any]: - """ - 视觉检查节点:检查是否需要视觉工具 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', '视觉检查阶段', {}) - - # 检测环境切换意图 - switch_intent = self.agent.vision_tool.detect_environment_switch_intent(state['user_input']) - if switch_intent and switch_intent.get('can_switch'): - from_env = switch_intent['from_env'] - to_env = switch_intent['to_env'] - - success = self.agent.vision_tool.switch_environment(to_env['uuid']) - if success: - switch_msg = f"\n🚪 [环境切换] 已从「{from_env['name']}」移动到「{to_env['name']}」" - print(switch_msg) - self.agent.memory_manager.add_message('system', switch_msg) - - # 检查是否需要使用视觉工具 - vision_context = self.agent.vision_tool.get_vision_context(state['user_input']) - if vision_context: - vision_summary = self.agent.vision_tool.get_vision_summary(vision_context) - print(f"\n{vision_summary}") - - return { - "vision_context": vision_context - } - - def _check_schedule_node(self, state: ConversationState) -> Dict[str, Any]: - """ - 日程检查节点:处理日程相关逻辑 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', '日程检查阶段', {}) - - schedule_context = None - schedule_action = None - - # 这里可以添加日程相关的逻辑 - # 为了保持简洁,暂时返回空值 - - return { - "schedule_context": schedule_context, - "schedule_action": schedule_action - } - - def _check_nps_node(self, state: ConversationState) -> Dict[str, Any]: - """ - NPS工具检查节点:调用NPS工具系统 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', 'NPS工具检查阶段', {}) - - nps_context = None - nps_result = self.agent.nps_invoker.invoke_relevant_tools(state['user_input']) - if nps_result['has_context']: - nps_context = nps_result['context_info'] - invoked_tools = [r['tool_name'] for r in nps_result['tools_invoked'] if r['success']] - if invoked_tools: - print(f"\n🔧 [NPS工具] 已调用: {', '.join(invoked_tools)}") - - return { - "nps_context": nps_context - } - - def _generate_response_node(self, state: ConversationState) -> Dict[str, Any]: - """ - 生成回复节点:使用LLM生成最终回复 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', '生成回复阶段', {}) - - # 这里将在后续实现中调用LLM生成回复 - # 暂时返回占位符 - return { - "ai_response": "", - "need_emotion_analysis": False - } - - def _analyze_emotion_node(self, state: ConversationState) -> Dict[str, Any]: - """ - 情感分析节点:进行情感关系分析 - - Args: - state: 当前状态 - - Returns: - 更新的状态 - """ - debug_logger.log_module('ConversationGraph', '情感分析阶段', {}) - - emotion_data = self.agent.analyze_emotion() - - return { - "emotion_data": emotion_data - } - - def _should_analyze_emotion(self, state: ConversationState) -> str: - """ - 决定是否需要进行情感分析 - - Args: - state: 当前状态 - - Returns: - 下一步行动 ("analyze" 或 "end") - """ - if state.get('need_emotion_analysis', False): - return "analyze" - else: - return "end" - - def process(self, user_input: str, messages: List[Dict[str, str]]) -> ConversationState: - """ - 处理对话 - - Args: - user_input: 用户输入 - messages: 消息历史 - - Returns: - 最终状态 - """ - # 初始化状态 - initial_state: ConversationState = { - "user_input": user_input, - "messages": messages, - "understanding": {}, - "knowledge": {}, - "vision_context": None, - "schedule_context": None, - "schedule_action": None, - "nps_context": None, - "need_emotion_analysis": False, - "emotion_data": None, - "ai_response": "", - "error": None - } - - # 执行流程图 - try: - final_state = self.graph.invoke(initial_state) - return final_state - except Exception as e: - debug_logger.log_error('ConversationGraph', f'流程执行错误: {str(e)}', e) - return { - **initial_state, - "error": str(e), - "ai_response": f"抱歉,处理请求时出现错误: {str(e)}" - } diff --git a/src/core/creative_writer.py b/src/core/creative_writer.py new file mode 100644 index 0000000..761bb14 --- /dev/null +++ b/src/core/creative_writer.py @@ -0,0 +1,480 @@ +""" +长期创作项目管理模块 +实现多会话长期创作系统: +- 立项:消费 P1 阶段日记 / 梦境碎片,生成项目前提 +- 续写:按 95-320 分钟随机间隔生成 60-1200 字正文 +- Story Bible:项目级线索状态机(active_themes / unresolved_threads / resolved_threads / important_facts) +- Memory Pool:按 type + importance 组织,max 50 条 +- 质量审核:相似度阈值 0.72 / 重试 2 次 / min_score 7 + +参考架构:spec §3 ADDED Requirements → CreativeProjectManager +""" + +import os +import json +import random +import re +import uuid +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional + +from src.core.database_manager import DatabaseManager +from src.core.llm_helper import LLMHelper +from src.core.prompt_manager import get_prompt_manager +from src.tools.debug_logger import get_debug_logger + +ENABLE_CREATIVE_WRITER = os.getenv('ENABLE_CREATIVE_WRITER', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + + +# 默认项目模板(沿用 AstrBot DEFAULT_CREATIVE_PROJECT_TEMPLATE 字段命名) +DEFAULT_CREATIVE_PROJECT_TEMPLATE: Dict[str, Any] = { + 'id': '', + 'title': '', + 'work_type': '短篇', + 'premise': '', + 'tone': '', + 'point_of_view': '第一人称', + 'target_chars': 5000, + 'current_chars': 0, + 'status': 'drafting', + 'inspiration_source': '', + 'draft_chunks': [], + 'story_bible': {}, + 'creative_memory_pool': [], + 'outline': [], + 'characters': [], + 'next_advance_at': None, +} + +CREATIVE_STORY_BIBLE_TEMPLATE: Dict[str, Any] = { + 'mainline_direction': '', + 'active_themes': [], + 'unresolved_threads': [], + 'resolved_threads': [], + 'important_facts': [], + 'next_direction': '', + 'recent_keywords': [], +} + + +class CreativeProjectManager: + """ + 长期创作项目管理器。 + - maybe_start_creative_project: 条件触发立项 + - maybe_advance_creative_projects: 续写到期项目 + - generate_creative_project: 立项(生成前提 / 大纲 / 角色) + - generate_creative_chunk: 续写 60-1200 字正文 + - 质量审核:相似度阈值 0.72 / 重试 2 次 / min_score 7 + """ + + WORK_TYPES = ['短篇', '中篇', '长篇', '散文', '诗歌'] + DEFAULT_CHUNK_MIN = 60 + DEFAULT_CHUNK_MAX = 1200 + DEFAULT_INTERVAL_MIN = 95 + DEFAULT_INTERVAL_MAX = 320 + MIN_PROJECT_INTERVAL_HOURS = 10 + MAX_ACTIVE_PROJECTS = 2 + SIMILARITY_THRESHOLD = 0.72 + MAX_RETRY = 2 + MIN_SCORE = 7 + MAX_POOL_SIZE = 50 + QUALITY_SCORE_MAX = 10 + + def __init__(self, db_manager: DatabaseManager = None, + prompt_manager=None, + character_info: Optional[Dict[str, Any]] = None, + life_state=None, + dream_diary=None): + self.db = db_manager or DatabaseManager() + self.prompt_manager = prompt_manager or get_prompt_manager() + self.character_info = character_info or {} + self.life_state = life_state + self.dream_diary = dream_diary + self.enabled = ENABLE_CREATIVE_WRITER + + # ===== 立项 ===== + + def maybe_start_creative_project(self, idle_checked: bool = False, + now: Optional[datetime] = None) -> bool: + """ + 若满足条件(距上次立项 > 10h 且活跃项目数 < max 且已检测空闲), + 启动一个新项目并持久化。 + """ + if not self.enabled: + return False + if not idle_checked: + return False + + now = now or datetime.now() + active = self.db.list_creative_projects(status='drafting', limit=self.MAX_ACTIVE_PROJECTS + 1) + if len(active) >= self.MAX_ACTIVE_PROJECTS: + return False + + # 距上次立项 > 10h + recent = self.db.list_creative_projects(limit=1) + if recent: + try: + last_dt = datetime.fromisoformat(recent[0]['created_at']) + if (now - last_dt) < timedelta(hours=self.MIN_PROJECT_INTERVAL_HOURS): + return False + except (ValueError, TypeError): + pass + + # 消费灵感来源 + source = self.creative_inspiration_source() or {} + project = self.generate_creative_project(source) + if not project: + return False + project['status'] = 'drafting' + project['next_advance_at'] = ( + now + timedelta(minutes=random.randint( + self.DEFAULT_INTERVAL_MIN, self.DEFAULT_INTERVAL_MAX)) + ).isoformat() + project_uuid = self.db.create_creative_project(project) + if project_uuid: + self.get_or_create_story_bible({'uuid': project_uuid}) + self.get_or_create_memory_pool({'uuid': project_uuid}) + debug_logger.log_info('CreativeWriter', '立项成功', { + 'uuid': project_uuid, 'title': project.get('title'), + }) + return True + return False + + def generate_creative_project(self, source: Dict[str, Any]) -> Dict[str, Any]: + """ + 通过 LLMHelper.call_main_model 生成项目前提 / 风格 / 大纲 / 角色。 + """ + if not self.enabled: + return {} + + prompt = self._build_project_prompt(source) + try: + raw = LLMHelper.call_main_model( + system_prompt='你是创作构思助手,输出严格 JSON。', + user_message=prompt, + ) + data = self._parse_json_response(raw) + except Exception as e: + debug_logger.log_error('CreativeWriter', f'立项 LLM 调用失败: {e}', e) + return {} + + if not data: + return {} + + project = dict(DEFAULT_CREATIVE_PROJECT_TEMPLATE) + project.update({ + 'id': str(uuid.uuid4()), + 'title': (data.get('title') or '未命名项目')[:80], + 'work_type': data.get('work_type', '短篇'), + 'premise': (data.get('premise') or '').strip(), + 'tone': (data.get('tone') or '').strip(), + 'point_of_view': data.get('point_of_view', '第一人称'), + 'target_chars': int(data.get('target_chars', 5000)), + 'current_chars': 0, + 'outline': data.get('outline', [])[:20], + 'characters': data.get('characters', [])[:10], + 'inspiration_source': source.get('source', ''), + 'draft_chunks': [], + }) + return project + + def _build_project_prompt(self, source: Dict[str, Any]) -> str: + char_name = self.character_info.get('name', 'AI') + char_setting = self.character_info.get('settings', '无') + inspiration_text = source.get('content', '(无)') + inspiration_kind = source.get('source', 'default') + return f"""你是一位文学构思助手,请基于灵感来源为角色"{char_name}"构思一个创作项目。 + +【角色设定】 +{char_setting} + +【灵感来源类型】{inspiration_kind} +【灵感内容】 +{inspiration_text} + +请输出严格 JSON(仅 JSON,不要其他内容): +{{ + "title": "项目标题(≤ 20 字)", + "work_type": "短篇/中篇/长篇/散文/诗歌", + "premise": "一句话故事前提(≤ 60 字)", + "tone": "整体语气(温暖/冷峻/...)", + "point_of_view": "第一人称/第三人称", + "target_chars": 5000, + "outline": ["章节1 摘要", "章节2 摘要", ...], + "characters": [ + {{"name": "角色名", "role": "主角/配角", "trait": "性格/特点"}} + ] +}} +""" + + def creative_inspiration_source(self) -> Optional[Dict[str, Any]]: + """ + 灵感来源消费 P1 阶段日记 / 梦境碎片。 + 返回 {source, content} 或 None(若无可用灵感)。 + """ + if self.dream_diary and getattr(self.dream_diary, 'enabled', False): + try: + ctx = self.dream_diary.recent_diary_context(count=1) + if ctx: + return {'source': 'dream_diary', 'content': ctx[:600]} + except Exception as e: + debug_logger.log_error('CreativeWriter', f'消费梦境灵感失败: {e}', e) + + if self.life_state and getattr(self.life_state, 'enabled', False): + try: + snap = self.life_state.get_current_state_snapshot() or {} + mood = snap.get('mood', '') + state = snap.get('state_title', '') + if mood or state: + return {'source': 'life_state', + 'content': f"今日状态:{state},心情:{mood}"} + except Exception as e: + debug_logger.log_error('CreativeWriter', f'消费生活状态灵感失败: {e}', e) + return None + + # ===== 续写 ===== + + def maybe_advance_creative_projects(self, now: Optional[datetime] = None) -> int: + """ + 推进所有到期的活跃项目。每个项目生成 60-1200 字正文,更新 Story Bible / Memory Pool。 + 返回成功推进的项目数。 + """ + if not self.enabled: + return 0 + now = now or datetime.now() + active = self.db.list_creative_projects(status='drafting', limit=20) + advanced = 0 + for proj in active: + next_at = proj.get('next_advance_at') + if not next_at: + continue + try: + next_dt = datetime.fromisoformat(next_at) + except (ValueError, TypeError): + continue + if next_dt > now: + continue + if self._advance_one(proj, now): + advanced += 1 + return advanced + + def _advance_one(self, project: Dict[str, Any], now: datetime) -> bool: + project_uuid = project.get('uuid') + if not project_uuid: + return False + budget = random.randint(self.DEFAULT_CHUNK_MIN, self.DEFAULT_CHUNK_MAX) + chunk = self.generate_creative_chunk(project, budget) + if not chunk: + return False + # 质量审核:相似度 + 评分 + if not self._quality_check(chunk, project): + retry = 0 + while retry < self.MAX_RETRY and not self._quality_check(chunk, project): + chunk = self.generate_creative_chunk(project, budget) + retry += 1 + if not self._quality_check(chunk, project): + debug_logger.log_info('CreativeWriter', '续写未达质量阈值,跳过本次', { + 'uuid': project_uuid, + }) + return False + + new_chunks = (project.get('draft_chunks') or []) + [{ + 'content': chunk, + 'created_at': now.isoformat(), + 'char_count': len(chunk), + }] + new_chars = int(project.get('current_chars', 0)) + len(chunk) + target = int(project.get('target_chars', 5000)) + next_status = 'finished' if new_chars >= target else 'drafting' + next_at = (now + timedelta(minutes=random.randint( + self.DEFAULT_INTERVAL_MIN, self.DEFAULT_INTERVAL_MAX))).isoformat() + + self.db.update_creative_project(project_uuid, { + 'current_chars': new_chars, + 'draft_chunks': new_chunks, + 'status': next_status, + 'next_advance_at': next_at, + 'last_advanced_at': now.isoformat(), + }) + # 追加到 Memory Pool + self.db.add_creative_memory( + project_uuid=project_uuid, + memory_type='chunk', + content=chunk[:500], + importance=0.7, + ) + # 修剪池 + self.db.prune_creative_memory_pool(project_uuid, keep_max=self.MAX_POOL_SIZE) + # Story Bible 更新 + self._update_story_bible_keywords(project_uuid, chunk) + return True + + def generate_creative_chunk(self, project: Dict[str, Any], + budget: int) -> str: + """ + 通过 LLMHelper.call_main_model(temperature 0.8)生成正文。 + """ + if not self.enabled: + return "" + project_uuid = project.get('uuid', '') + bible = self.db.get_story_bible(project_uuid) or CREATIVE_STORY_BIBLE_TEMPLATE.copy() + pool = self.db.get_creative_memory_pool(project_uuid, limit=self.MAX_POOL_SIZE) + prompt = self._build_chunk_prompt(project, bible, pool, budget) + try: + chunk = LLMHelper.call_main_model( + system_prompt='你是文学创作助手,输出符合角色与故事上下文的连续正文。', + user_message=prompt, + ) + except Exception as e: + debug_logger.log_error('CreativeWriter', f'续写 LLM 调用失败: {e}', e) + return "" + chunk = (chunk or '').strip() + return chunk[:budget] if len(chunk) > budget else chunk + + def _build_chunk_prompt(self, project: Dict[str, Any], + bible: Dict[str, Any], pool: List[Dict[str, Any]], + budget: int) -> str: + outlines = project.get('outline') or [] + characters = project.get('characters') or [] + recent_chunks = (project.get('draft_chunks') or [])[-3:] + recent_text = "\n\n".join(c.get('content', '') for c in recent_chunks) + return f"""你正在续写项目《{project.get('title', '未命名')}》。 + +【作品元信息】 +- 体裁:{project.get('work_type', '短篇')} +- 视角:{project.get('point_of_view', '第一人称')} +- 语气:{project.get('tone', '')} +- 当前已写:{project.get('current_chars', 0)}/{project.get('target_chars', 5000)} 字 + +【故事前提】 +{project.get('premise', '')} + +【大纲】 +{chr(10).join(f"- {x}" for x in outlines)} + +【角色】 +{chr(10).join(f"- {c.get('name', '')}:{c.get('role', '')} / {c.get('trait', '')}" for c in characters)} + +【Story Bible 提示】 +- 主线方向:{bible.get('mainline_direction', '')} +- 活跃主题:{', '.join(bible.get('active_themes', []))} +- 未解线索:{', '.join(bible.get('unresolved_threads', []))} +- 下一方向:{bible.get('next_direction', '')} + +【记忆池摘录】 +{chr(10).join(f"- [{m.get('memory_type', '')}] {m.get('content', '')[:120]}" for m in pool[:8])} + +【最近正文(末段)】 +{recent_text[:1500]} + +【任务】请续写下一段正文,长度约 {budget} 字(60-1200),保持角色一致、推进 Story Bible 中的下一方向。直接输出正文,不要加解释或标题。 +""" + + def _quality_check(self, chunk: str, project: Dict[str, Any]) -> bool: + if not chunk: + return False + if len(chunk) < 30: + return False + # 相似度:与最近 chunks 重复率 + recent = (project.get('draft_chunks') or [])[-3:] + for old in recent: + sim = self._jaccard_similarity(chunk, old.get('content', '')) + if sim > self.SIMILARITY_THRESHOLD: + return False + # 评分:长度 + 标点密度 + 中文字数占比(启发式) + score = self._heuristic_score(chunk) + return score >= self.MIN_SCORE + + @staticmethod + def _jaccard_similarity(a: str, b: str) -> float: + if not a or not b: + return 0.0 + set_a = set(re.findall(r'[\u4e00-\u9fffA-Za-z0-9]+', a)) + set_b = set(re.findall(r'[\u4e00-\u9fffA-Za-z0-9]+', b)) + if not set_a or not set_b: + return 0.0 + return len(set_a & set_b) / len(set_a | set_b) + + def _heuristic_score(self, chunk: str) -> int: + score = 0 + if len(chunk) >= 100: + score += 3 + if len(chunk) >= 300: + score += 2 + # 句号 / 问号 / 感叹号 / 逗号 + punct = sum(chunk.count(c) for c in '。,!?;:') + if punct >= 3: + score += 2 + # 中文字符占比 + cn_chars = len(re.findall(r'[\u4e00-\u9fff]', chunk)) + if cn_chars / max(1, len(chunk)) > 0.5: + score += 3 + return score + + def _update_story_bible_keywords(self, project_uuid: str, chunk: str) -> None: + bible = self.db.get_story_bible(project_uuid) or {} + if not bible: + bible = dict(CREATIVE_STORY_BIBLE_TEMPLATE) + # 简单关键词提取 + kws = re.findall(r'[\u4e00-\u9fff]{2,4}', chunk) + freq: Dict[str, int] = {} + for k in kws: + freq[k] = freq.get(k, 0) + 1 + top = sorted(freq.items(), key=lambda kv: kv[1], reverse=True)[:10] + bible['recent_keywords'] = [k for k, _ in top] + # next_direction:从前文 outline 中取下一项 + proj = self.db.get_creative_project(project_uuid) or {} + outlines = proj.get('outline') or [] + current_chars = int(proj.get('current_chars', 0)) + target = max(1, int(proj.get('target_chars', 5000))) + idx = min(len(outlines) - 1, int(current_chars / target * len(outlines))) + bible['next_direction'] = outlines[idx] if outlines else '' + self.db.upsert_story_bible(project_uuid, bible) + + # ===== Story Bible / Memory Pool ===== + + def get_or_create_story_bible(self, project: Dict[str, Any]) -> Dict[str, Any]: + project_uuid = project.get('uuid') + if not project_uuid: + return {} + bible = self.db.get_story_bible(project_uuid) + if bible: + return bible + bible = dict(CREATIVE_STORY_BIBLE_TEMPLATE) + self.db.upsert_story_bible(project_uuid, bible) + return bible + + def get_or_create_memory_pool(self, project: Dict[str, Any]) -> List[Dict[str, Any]]: + project_uuid = project.get('uuid') + if not project_uuid: + return [] + pool = self.db.get_creative_memory_pool(project_uuid, limit=self.MAX_POOL_SIZE) + return pool + + # ===== 工具 ===== + + def _parse_json_response(self, raw: str) -> Dict[str, Any]: + if not raw: + return {} + text = raw.strip() + if text.startswith('```'): + lines = text.split('\n') + text = '\n'.join(lines[1:-1]) if len(lines) > 2 else text + if text.startswith('json'): + text = text[4:].strip() + try: + return json.loads(text) + except json.JSONDecodeError: + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + return {} + + def get_finished_projects(self, limit: int = 10) -> List[Dict[str, Any]]: + """供 P4 ProactiveEngine 作为 creative_share 想法来源。""" + return self.db.list_creative_projects(status='finished', limit=limit) diff --git a/src/core/cross_module_workflow.py b/src/core/cross_module_workflow.py new file mode 100644 index 0000000..4ba63b9 --- /dev/null +++ b/src/core/cross_module_workflow.py @@ -0,0 +1,345 @@ +""" +CrossModuleWorkflow - 跨模块复杂工作流核心逻辑。 + +实现典型认知闭环: + 对话输入 → 记忆存取 → LLM 流式生成 → 情感分析 → 日程意图识别 → + 日程创建 → 主动消息确认决策。 + +本模块位于 src/core/,符合项目硬约束:新增核心逻辑统一放在 src/core/ 下, +并复用 DatabaseManager、PromptManager(通过 CharacterProfile)等现有基础设施。 +神经系统接入层由 src.prefrontal.workflow_module.WorkflowModule 承担。 +""" + +from __future__ import annotations + +import uuid +from typing import Any, AsyncIterator, Dict, List, Optional + +from src.core.chat_agent import CharacterProfile +from src.core.database_manager import DatabaseManager +from src.limbic.hippocampus.memory_store import LongTermMemoryManager +from src.nervous_system.router.packet import Packet, PacketType + + +class ChatWorkflow: + """ + 聊天工作流核心。 + + 不直接调用其他模块的 Python 方法,而是通过 CentralRouter 发送 Packet, + 从而真正实现模块间解耦与可追踪的跨模块协作。 + """ + + def __init__( + self, + router, + db_manager: Optional[DatabaseManager] = None, + ) -> None: + self.router = router + self.db = db_manager or DatabaseManager() + self.memory_manager = LongTermMemoryManager(db_manager=self.db) + self.character = CharacterProfile() + + async def run_stream( + self, + user_input: str, + user_id: str = "default", + session_id: Optional[int] = None, + context: Optional[Dict[str, Any]] = None, + ) -> AsyncIterator[Dict[str, Any]]: + """ + 执行完整聊天工作流并异步生成事件。 + + Yields: + dict 事件: + - {"event": "chunk", "content": str} + - {"event": "done", "full_content": str} + - {"event": "workflow_result", + "emotion": ..., "schedule": ..., "proactive": ...} + """ + trace_id = uuid.uuid4().hex + metadata: Dict[str, Any] = { + "user_id": user_id, + "trace_id": trace_id, + "session_id": session_id, + } + context = context or {} + metadata["context"] = context + + # 1. 持久化用户消息 + await self._store_message("user", user_input, metadata) + + # 2. 组装带上下文的 messages + messages = await self._build_messages(user_input, metadata) + + # 3. 流式调用 LLM 生成回复 + full_reply = "" + async for chunk in self._stream_llm(messages, metadata): + full_reply += chunk + yield {"event": "chunk", "content": chunk} + + yield {"event": "done", "full_content": full_reply} + + # 4. 持久化助手回复 + if full_reply: + await self._store_message("assistant", full_reply, metadata) + + # 5. 后台并行执行:情感分析、日程意图、主动决策 + emotion, schedule, proactive = await self._run_post_workflow( + user_input, full_reply, metadata + ) + yield { + "event": "workflow_result", + "emotion": emotion, + "schedule": schedule, + "proactive": proactive, + } + + # ------------------------------------------------------------------ + # 内部 helpers + # ------------------------------------------------------------------ + async def _store_message( + self, + role: str, + content: str, + metadata: Dict[str, Any], + ) -> None: + """通过 hippocampus 存储一条消息。""" + packet = Packet( + source="prefrontal.workflow", + target="limbic.hippocampus.full", + packet_type=PacketType.REQUEST, + channel="memory_store", + payload={"role": role, "content": content}, + metadata=metadata, + ) + try: + await self.router.route(packet) + except Exception: + # 记忆存储失败不应阻断主流程 + pass + + async def _build_messages( + self, + user_input: str, + metadata: Dict[str, Any], + ) -> List[Dict[str, str]]: + """查询历史记忆与情感语气,组装 LLM messages。""" + memories: List[Dict[str, str]] = [] + try: + query_packet = Packet( + source="prefrontal.workflow", + target="limbic.hippocampus.full", + packet_type=PacketType.REQUEST, + channel="memory_query", + payload={"limit": 20}, + metadata=metadata, + ) + response = await self.router.route(query_packet) + if response.is_response(): + memories = response.payload.get("memories", []) or [] + except Exception: + memories = [] + + emotion_context = "" + try: + tone_packet = Packet( + source="prefrontal.workflow", + target="limbic.amygdala", + packet_type=PacketType.REQUEST, + channel="emotion_tone", + payload={}, + metadata=metadata, + ) + tone_response = await self.router.route(tone_packet) + if tone_response.is_response(): + emotion_context = tone_response.payload.get("tone_prompt", "") + except Exception: + emotion_context = "" + + system_prompt = self.character.get_system_prompt( + emotion_relationship=emotion_context or "初次见面" + ) + + messages: List[Dict[str, str]] = [ + {"role": "system", "content": system_prompt} + ] + for msg in memories: + messages.append({ + "role": msg.get("role", "user"), + "content": msg.get("content", ""), + }) + messages.append({"role": "user", "content": user_input}) + return messages + + async def _stream_llm( + self, + messages: List[Dict[str, str]], + metadata: Dict[str, Any], + ) -> AsyncIterator[str]: + """通过 cortex.llm_core 的 llm_stream 通道流式生成回复。""" + packet = Packet( + source="prefrontal.workflow", + target="cortex.llm_core", + packet_type=PacketType.REQUEST, + channel="llm_stream", + payload={"messages": messages, "task_type": "main"}, + metadata=metadata, + ) + try: + async for response_packet in self.router.route_stream(packet): + if response_packet.is_error(): + yield f"[错误: {response_packet.payload.get('error', 'unknown error')}]" + break + + if response_packet.packet_type != PacketType.STREAM: + continue + + stream_event = response_packet.payload.get("stream_event") + if stream_event == "chunk": + content = response_packet.payload.get("content", "") + if content: + yield content + elif stream_event == "error": + error_msg = response_packet.payload.get("error", "unknown stream error") + yield f"[流式错误: {error_msg}]" + break + elif stream_event == "done": + break + except Exception as exc: + yield f"[工作流异常: {exc}]" + + async def _run_post_workflow( + self, + user_input: str, + assistant_reply: str, + metadata: Dict[str, Any], + ): + """并行执行情感分析、日程意图、主动决策。""" + import asyncio + + results = await asyncio.gather( + self._analyze_emotion(user_input, assistant_reply, metadata), + self._check_schedule_intent(user_input, metadata), + self._check_proactive(metadata.get("user_id", "default"), metadata), + return_exceptions=True, + ) + + emotion = results[0] if not isinstance(results[0], Exception) else {} + schedule = results[1] if not isinstance(results[1], Exception) else {} + proactive = results[2] if not isinstance(results[2], Exception) else {} + return emotion, schedule, proactive + + async def _analyze_emotion( + self, + user_input: str, + assistant_reply: str, + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + """通过 limbic.amygdala 分析本轮对话情感。""" + packet = Packet( + source="prefrontal.workflow", + target="limbic.amygdala", + packet_type=PacketType.REQUEST, + channel="emotion_analyze", + payload={ + "messages": [ + {"role": "user", "content": user_input}, + {"role": "assistant", "content": assistant_reply}, + ], + "character_name": self.character.name, + }, + metadata=metadata, + ) + try: + response = await self.router.route(packet) + if response.is_response(): + return response.payload + except Exception: + pass + return {} + + async def _check_schedule_intent( + self, + user_input: str, + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + """通过 cerebellum.toolkit 识别日程意图,并在必要时创建日程。""" + packet = Packet( + source="prefrontal.workflow", + target="cerebellum.toolkit", + packet_type=PacketType.REQUEST, + channel="schedule_intent", + payload={ + "user_input": user_input, + "character_name": self.character.name, + }, + metadata=metadata, + ) + try: + response = await self.router.route(packet) + if not response.is_response(): + return {} + + intent = response.payload + confidence = float(intent.get("confidence", 0) or 0) + has_intent = bool(intent.get("has_schedule_intent")) and confidence > 0.6 + if not has_intent: + return {"intent": intent, "created": None} + + created = await self._create_schedule(intent, metadata) + return {"intent": intent, "created": created} + except Exception: + return {} + + async def _create_schedule( + self, + intent_result: Dict[str, Any], + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + """通过 prefrontal.planner 创建日程。""" + packet = Packet( + source="prefrontal.workflow", + target="prefrontal.planner", + packet_type=PacketType.REQUEST, + channel="schedule_add", + payload={ + "title": intent_result.get("title") or "日程", + "description": intent_result.get("description", ""), + "start_time": intent_result.get("start_time"), + "end_time": intent_result.get("end_time"), + "schedule_type": intent_result.get("schedule_type", "appointment"), + }, + metadata=metadata, + ) + try: + response = await self.router.route(packet) + if response.is_response(): + return response.payload + except Exception: + pass + return {} + + async def _check_proactive( + self, + user_id: str, + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + """通过 prefrontal.planner 决策是否应发送主动确认消息。""" + packet = Packet( + source="prefrontal.workflow", + target="prefrontal.planner", + packet_type=PacketType.REQUEST, + channel="proactive_should_send", + payload={"user": user_id}, + metadata=metadata, + ) + try: + response = await self.router.route(packet) + if response.is_response(): + return response.payload + except Exception: + pass + return {} + + +__all__ = ["ChatWorkflow"] diff --git a/src/core/database_manager.py b/src/core/database_manager.py index 91c99a7..1690bdb 100644 --- a/src/core/database_manager.py +++ b/src/core/database_manager.py @@ -38,6 +38,8 @@ def __init__(self, db_path: str = "chat_agent.db", debug: bool = False): self.debug = debug self._query_count = 0 # 查询计数器 self._operation_log = [] # 操作日志 + # v4.0: 为内存数据库复用同一连接,避免 ':memory:' 每次新连接都是空库 + self._memory_conn = None if self.debug: print(f"🐛 [DEBUG] 数据库管理器初始化 - 路径: {db_path}") @@ -60,16 +62,46 @@ def _truncate_uuid(uuid_str: str, length: int = 8) -> str: return "" return (uuid_str[:length] + '...') if len(uuid_str) > length else uuid_str + def _open_connection(self): + """打开并配置一个新的 SQLite 连接。""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row # 使结果可以像字典一样访问 + + # Stage D.1: 启用 SQLite WAL 模式(提升并发读性能,让 Web/Scheduler/多进程共存) + try: + conn.execute("PRAGMA journal_mode=WAL") + except Exception as pragma_err: + # WAL 启用失败不应阻塞主流程 + if self.debug: + print(f"🐛 [DEBUG] 启用 WAL 失败: {pragma_err}") + + # v3.1.0: 启用外键约束(chat_messages.session_id ON DELETE CASCADE 需要) + try: + conn.execute("PRAGMA foreign_keys=ON") + except Exception as pragma_err: + if self.debug: + print(f"🐛 [DEBUG] 启用 foreign_keys 失败: {pragma_err}") + + return conn + @contextmanager def get_connection(self): """ - 获取数据库连接的上下文管理器 + 获取数据库连接的上下文管理器。 + + 对 ':memory:' 数据库复用同一连接,避免每次新连接都是空库; + 文件数据库仍按原有方式每次打开/关闭连接。 """ if self.debug: print(f"🐛 [DEBUG] 打开数据库连接: {self.db_path}") - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row # 使结果可以像字典一样访问 + is_memory = self.db_path == ":memory:" + if is_memory: + if self._memory_conn is None: + self._memory_conn = self._open_connection() + conn = self._memory_conn + else: + conn = self._open_connection() try: yield conn @@ -86,10 +118,10 @@ def get_connection(self): raise e finally: - conn.close() - - if self.debug: - print(f"🐛 [DEBUG] 数据库连接已关闭") + if not is_memory: + conn.close() + if self.debug: + print(f"🐛 [DEBUG] 数据库连接已关闭") def init_database(self): """ @@ -328,6 +360,153 @@ def init_database(self): ) ''') + # 17. 生活状态日快照表(P1:LifeStateManager) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS life_state_daily ( + date TEXT PRIMARY KEY, + energy REAL DEFAULT 0.7, + mood TEXT DEFAULT '平静', + state_title TEXT DEFAULT '常态', + health TEXT DEFAULT '健康', + conditions_json TEXT, + transition_options_json TEXT, + energy_delta REAL DEFAULT 0.0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + ''') + + # 18. 梦境记录表(P1:DreamDiaryManager) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS dream_records ( + uuid TEXT PRIMARY KEY, + date TEXT NOT NULL, + dream_type TEXT NOT NULL, + content TEXT NOT NULL, + afterglow TEXT, + label TEXT, + mood TEXT, + energy_delta REAL DEFAULT 0.0, + duration_hours REAL DEFAULT 7.0, + weather_json TEXT, + created_at TEXT NOT NULL + ) + ''') + + # 19. 日记条目表(P1:DreamDiaryManager) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS diary_entries ( + uuid TEXT PRIMARY KEY, + date TEXT NOT NULL, + title TEXT, + body TEXT NOT NULL, + mood TEXT, + tags_json TEXT, + related_entities_json TEXT, + word_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ) + ''') + + # 20. 未完话题表(P2:OpenLoopTracker) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS open_loops ( + uuid TEXT PRIMARY KEY, + topic TEXT NOT NULL, + status TEXT DEFAULT 'open', + raised_at TEXT NOT NULL, + resolved_at TEXT, + related_keywords_json TEXT, + context TEXT, + mention_count INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + ''') + + # 21. 创作项目表(P3:CreativeProjectManager) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS creative_projects ( + uuid TEXT PRIMARY KEY, + title TEXT NOT NULL, + work_type TEXT NOT NULL, + premise TEXT, + tone TEXT, + point_of_view TEXT, + target_chars INTEGER DEFAULT 5000, + current_chars INTEGER DEFAULT 0, + status TEXT DEFAULT 'drafting', + inspiration_source TEXT, + outline_json TEXT, + characters_json TEXT, + draft_chunks_json TEXT, + next_advance_at TEXT, + last_advanced_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + ''') + + # 22. Story Bible 表(P3:项目级线索状态机) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS creative_story_bibles ( + project_uuid TEXT PRIMARY KEY, + mainline_direction TEXT, + active_themes_json TEXT, + unresolved_threads_json TEXT, + resolved_threads_json TEXT, + important_facts_json TEXT, + next_direction TEXT, + recent_keywords_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_uuid) REFERENCES creative_projects(uuid) ON DELETE CASCADE + ) + ''') + + # 23. 创作记忆池(P3:碎片化上下文) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS creative_memory_pool ( + uuid TEXT PRIMARY KEY, + project_uuid TEXT NOT NULL, + memory_type TEXT NOT NULL, + content TEXT NOT NULL, + importance REAL DEFAULT 0.5, + created_at TEXT NOT NULL, + FOREIGN KEY (project_uuid) REFERENCES creative_projects(uuid) ON DELETE CASCADE + ) + ''') + + # 24. 聊天会话表(v3.1.0:会话持久化) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS chat_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '新会话', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + message_count INTEGER DEFAULT 0 + ) + ''') + cursor.execute('''CREATE INDEX IF NOT EXISTS idx_chat_sessions_user_id ON chat_sessions(user_id)''') + cursor.execute('''CREATE INDEX IF NOT EXISTS idx_chat_sessions_updated_at ON chat_sessions(updated_at DESC)''') + + # 25. 聊天消息表(v3.1.0:会话持久化) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + emotion_json TEXT, + conn_id TEXT, + created_at REAL NOT NULL, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE + ) + ''') + cursor.execute('''CREATE INDEX IF NOT EXISTS idx_chat_messages_session_id ON chat_messages(session_id)''') + cursor.execute('''CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at)''') + # 创建索引以提高查询性能 cursor.execute('CREATE INDEX IF NOT EXISTS idx_entities_normalized ON entities(normalized_name)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_base_knowledge_normalized ON base_knowledge(normalized_name)') @@ -342,6 +521,14 @@ def init_database(self): cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_expressions_confidence ON user_expression_habits(confidence)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_domain_environments_domain ON domain_environments(domain_uuid)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_domain_environments_env ON domain_environments(environment_uuid)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_life_state_date ON life_state_daily(date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_dream_records_date ON dream_records(date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_diary_entries_date ON diary_entries(date)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_open_loops_status ON open_loops(status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_open_loops_raised_at ON open_loops(raised_at)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_creative_projects_status ON creative_projects(status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_creative_projects_next_advance ON creative_projects(next_advance_at)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_creative_pool_project ON creative_memory_pool(project_uuid)') conn.commit() if INIT_Database_PreParation_Complete == False: @@ -590,16 +777,29 @@ def get_entity_by_uuid(self, entity_uuid: str) -> Optional[Dict[str, Any]]: return dict(row) return None - def get_all_entities(self) -> List[Dict[str, Any]]: + def get_all_entities(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: """ 获取所有实体 + Args: + limit: 可选;返回的最大条数(None 表示不限制) + Returns: 实体列表 """ with self.get_connection() as conn: cursor = conn.cursor() - cursor.execute('SELECT * FROM entities ORDER BY created_at DESC') + if limit is not None: + try: + safe_limit = max(1, min(int(limit), 1000)) + except (TypeError, ValueError): + safe_limit = 100 + cursor.execute( + 'SELECT * FROM entities ORDER BY created_at DESC LIMIT ?', + (safe_limit,), + ) + else: + cursor.execute('SELECT * FROM entities ORDER BY created_at DESC') return [dict(row) for row in cursor.fetchall()] def find_or_create_entity(self, name: str) -> str: @@ -617,6 +817,276 @@ def find_or_create_entity(self, name: str) -> str: return entity['uuid'] return self.create_entity(name) + # ==================== Stage C.1: Knowledge CRUD 包装 ==================== + # 上述低阶方法(create_entity / set_entity_definition / add_entity_related_info + # / get_entity_by_uuid / get_entity_by_name ...)粒度较细;为 Web 后端 + # 暴露更"业务"的方法,使其与 schema 字段 (entity_name / category / + # description / related_info) 对齐。注意:以下方法不修改既有方法签名。 + + def search_knowledge( + self, + query: str, + limit: int = 20, + ) -> List[Dict[str, Any]]: + """ + 按关键词搜索实体(Stage C.1)。 + + 匹配范围:entities.name / entities.normalized_name。 + 返回的每条记录会附带 ``category`` / ``description`` 字段(如果有定义), + 便于 Web 前端直接渲染。 + + Args: + query: 搜索关键词(空串返回空列表) + limit: 返回条数上限(默认 20,最大 200) + + Returns: + 实体列表 + """ + try: + safe_limit = max(1, min(int(limit), 200)) + except (TypeError, ValueError): + safe_limit = 20 + + q = (query or "").strip() + if not q: + return [] + + like = f"%{q}%" + results: List[Dict[str, Any]] = [] + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + SELECT * FROM entities + WHERE name LIKE ? OR normalized_name LIKE ? + ORDER BY created_at DESC + LIMIT ? + ''', + (like, like, safe_limit), + ) + rows = [dict(row) for row in cursor.fetchall()] + + for row in rows: + entity_uuid = row.get("uuid") + definition = None + if entity_uuid: + try: + definition = self.get_entity_definition(entity_uuid) + except Exception: + definition = None + if definition: + row["category"] = definition.get("type", "通用") + row["description"] = definition.get("content", "") + else: + row.setdefault("category", "通用") + row.setdefault("description", "") + results.append(row) + return results + + def add_entity( + self, + entity_name: str, + category: Optional[str] = None, + description: Optional[str] = None, + related_info: Optional[Dict[str, Any]] = None, + ) -> str: + """ + 新增一个实体(Stage C.1)。 + + 行为: + 1. 在 ``entities`` 表创建记录。 + 2. 若 ``description`` 非空,在 ``entity_definitions`` 写入定义 + (``type`` 优先用 ``category``,否则 "定义")。 + 3. 若 ``related_info`` 是 dict,按 ``key -> value`` 写入 + ``entity_related_info``(type=key,content=str(value))。 + + Args: + entity_name: 实体名称(必填) + category: 分类(可选,映射到定义 type) + description: 描述(可选,映射到定义 content) + related_info: 相关信息字典(可选) + + Returns: + 新实体的 uuid + """ + if not entity_name or not isinstance(entity_name, str): + raise ValueError("entity_name 必须是非空字符串") + + name = entity_name.strip() + if not name: + raise ValueError("entity_name 不能为空") + + # 1) 创建实体(find_or_create_entity 避免重名冲突) + entity_uuid = self.find_or_create_entity(name) + + # 2) 写入定义(如有) + if description is not None and str(description).strip(): + try: + self.set_entity_definition( + entity_uuid=entity_uuid, + content=str(description), + type_=(category or "定义"), + source="web_crud", + confidence=1.0, + priority=50, + is_base_knowledge=False, + ) + except Exception: + # 定义写入失败不影响主流程 + pass + + # 3) 写入 related_info(如有) + if isinstance(related_info, dict) and related_info: + for k, v in related_info.items(): + try: + self.add_entity_related_info( + entity_uuid=entity_uuid, + content=str(v), + type_=str(k)[:64] or "其他", + source="web_crud", + confidence=0.7, + status="疑似", + mention_count=1, + ) + except Exception: + # 单条 related_info 失败不影响其他条目 + continue + + return entity_uuid + + def update_entity( + self, + entity_uuid: str, + entity_name: Optional[str] = None, + category: Optional[str] = None, + description: Optional[str] = None, + related_info: Optional[Dict[str, Any]] = None, + ) -> bool: + """ + 更新实体(Stage C.1)。 + + - ``entity_name`` 非空 → 改 entities.name + 重新计算 normalized_name。 + - ``description`` 非空 → 覆盖 entity_definitions。 + - ``category`` 非空 → 作为新定义的 type。 + - ``related_info`` 是 dict → 整体替换(先删后插)该实体的 related_info。 + + Returns: + 是否至少有一个字段被更新 + """ + if not entity_uuid or not isinstance(entity_uuid, str): + raise ValueError("entity_uuid 必须是非空字符串") + + existing = self.get_entity_by_uuid(entity_uuid) + if not existing: + return False + + updated = False + + # 1) 改名 + if entity_name is not None and str(entity_name).strip(): + new_name = str(entity_name).strip() + if new_name != existing.get("name"): + new_norm = new_name.lower() + now_iso = datetime.now().isoformat() + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + UPDATE entities + SET name = ?, normalized_name = ?, updated_at = ? + WHERE uuid = ? + ''', + (new_name, new_norm, now_iso, entity_uuid), + ) + updated = True + + # 2) 更新定义(description 触发;category 影响 type) + if description is not None and str(description).strip(): + try: + self.set_entity_definition( + entity_uuid=entity_uuid, + content=str(description), + type_=(category or "定义"), + source="web_crud", + confidence=1.0, + priority=50, + is_base_knowledge=False, + ) + updated = True + except Exception: + pass + + # 3) 替换 related_info(如提供) + if isinstance(related_info, dict): + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "DELETE FROM entity_related_info WHERE entity_uuid = ?", + (entity_uuid,), + ) + for k, v in related_info.items(): + try: + self.add_entity_related_info( + entity_uuid=entity_uuid, + content=str(v), + type_=str(k)[:64] or "其他", + source="web_crud", + confidence=0.7, + status="疑似", + mention_count=1, + ) + except Exception: + continue + updated = True + except Exception: + pass + + return updated + + def delete_entity(self, entity_uuid: str) -> bool: + """ + 删除实体(Stage C.1)。 + + 会先级联删除 ``entity_definitions`` / ``entity_related_info``, + 再删 ``entities``。不存在的 uuid 返回 False。 + + Returns: + 是否成功删除 + """ + if not entity_uuid or not isinstance(entity_uuid, str): + return False + + existing = self.get_entity_by_uuid(entity_uuid) + if not existing: + return False + + try: + with self.get_connection() as conn: + cursor = conn.cursor() + # 先清掉子表(避免 FK 约束问题) + try: + cursor.execute( + "DELETE FROM entity_definitions WHERE entity_uuid = ?", + (entity_uuid,), + ) + except Exception: + pass + try: + cursor.execute( + "DELETE FROM entity_related_info WHERE entity_uuid = ?", + (entity_uuid,), + ) + except Exception: + pass + cursor.execute( + "DELETE FROM entities WHERE uuid = ?", + (entity_uuid,), + ) + return cursor.rowcount > 0 + except Exception: + return False + # ==================== 实体定义相关方法 ==================== def set_entity_definition(self, entity_uuid: str, content: str, type_: str = "定义", @@ -1040,6 +1510,26 @@ def get_metadata(self, key: str, default: Any = None) -> Any: print(f"✗ 获取元数据时出错: {e}") return default + def list_metadata_keys(self, prefix: Optional[str] = None) -> List[str]: + """ + 列出元数据键(可选前缀过滤)。 + """ + try: + with self.get_connection() as conn: + cursor = conn.cursor() + if prefix: + cursor.execute( + "SELECT key FROM metadata WHERE key LIKE ? ORDER BY key", + (prefix + '%',) + ) + else: + cursor.execute("SELECT key FROM metadata ORDER BY key") + return [row['key'] for row in cursor.fetchall()] + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ list_metadata_keys 出错: {e}") + return [] + # ==================== 数据迁移相关方法 ==================== def migrate_from_json(self, json_file_path: str, data_type: str) -> bool: @@ -2312,6 +2802,482 @@ def is_environment_in_domain(self, environment_uuid: str, domain_uuid: str) -> b result = cursor.fetchone() return result['count'] > 0 + # ==================== 生活状态日快照方法(P1:LifeStateManager)==================== + + def upsert_life_state_daily(self, date: str, energy: float, mood: str, + state_title: str, health: str, + conditions: list, transition_options: list, + energy_delta: float = 0.0) -> bool: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO life_state_daily + (date, energy, mood, state_title, health, + conditions_json, transition_options_json, + energy_delta, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(date) DO UPDATE SET + energy=excluded.energy, + mood=excluded.mood, + state_title=excluded.state_title, + health=excluded.health, + conditions_json=excluded.conditions_json, + transition_options_json=excluded.transition_options_json, + energy_delta=excluded.energy_delta, + updated_at=excluded.updated_at + ''', (date, energy, mood, state_title, health, + json.dumps(conditions, ensure_ascii=False), + json.dumps(transition_options, ensure_ascii=False), + energy_delta, datetime.now().isoformat(), + datetime.now().isoformat())) + return True + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ upsert_life_state_daily 出错: {e}") + return False + + def get_life_state_daily(self, date: str) -> Optional[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM life_state_daily WHERE date = ?', (date,)) + row = cursor.fetchone() + if not row: + return None + record = dict(row) + record['conditions'] = json.loads(record.pop('conditions_json') or '[]') + record['transition_options'] = json.loads(record.pop('transition_options_json') or '[]') + return record + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_life_state_daily 出错: {e}") + return None + + # ==================== 梦境记录方法(P1:DreamDiaryManager)==================== + + def insert_dream_record(self, date: str, dream_type: str, content: str, + afterglow: str = "", label: str = "", + mood: str = "", energy_delta: float = 0.0, + duration_hours: float = 7.0, + weather: Optional[Dict[str, Any]] = None) -> str: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + dream_uuid = str(uuid.uuid4()) + cursor.execute(''' + INSERT INTO dream_records + (uuid, date, dream_type, content, afterglow, label, + mood, energy_delta, duration_hours, weather_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (dream_uuid, date, dream_type, content, afterglow, label, + mood, energy_delta, duration_hours, + json.dumps(weather or {}, ensure_ascii=False), + datetime.now().isoformat())) + return dream_uuid + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ insert_dream_record 出错: {e}") + return "" + + def get_recent_dreams(self, limit: int = 3) -> List[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM dream_records + ORDER BY date DESC, created_at DESC + LIMIT ? + ''', (limit,)) + rows = [dict(row) for row in cursor.fetchall()] + for r in rows: + r['weather'] = json.loads(r.pop('weather_json') or '{}') + return rows + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_recent_dreams 出错: {e}") + return [] + + def get_dream_fragments_pool(self, limit: int = 50) -> List[str]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT content FROM dream_records + ORDER BY date DESC, created_at DESC + LIMIT ? + ''', (limit,)) + return [row['content'] for row in cursor.fetchall()] + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_dream_fragments_pool 出错: {e}") + return [] + + # ==================== 日记条目方法(P1:DreamDiaryManager)==================== + + def insert_diary_entry(self, date: str, body: str, title: str = "", + mood: str = "", tags: Optional[List[str]] = None, + related_entities: Optional[List[str]] = None) -> str: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + entry_uuid = str(uuid.uuid4()) + word_count = len(body) + cursor.execute(''' + INSERT INTO diary_entries + (uuid, date, title, body, mood, tags_json, + related_entities_json, word_count, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (entry_uuid, date, title, body, mood, + json.dumps(tags or [], ensure_ascii=False), + json.dumps(related_entities or [], ensure_ascii=False), + word_count, datetime.now().isoformat())) + return entry_uuid + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ insert_diary_entry 出错: {e}") + return "" + + def get_recent_diaries(self, limit: int = 3) -> List[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM diary_entries + ORDER BY date DESC, created_at DESC + LIMIT ? + ''', (limit,)) + rows = [dict(row) for row in cursor.fetchall()] + for r in rows: + r['tags'] = json.loads(r.pop('tags_json') or '[]') + r['related_entities'] = json.loads(r.pop('related_entities_json') or '[]') + return rows + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_recent_diaries 出错: {e}") + return [] + + # ==================== 未完话题方法(P2:OpenLoopTracker)==================== + + def insert_open_loop(self, topic: str, context: str = "", + related_keywords: Optional[List[str]] = None, + raised_at: Optional[str] = None) -> str: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + loop_uuid = str(uuid.uuid4()) + now_iso = datetime.now().isoformat() + cursor.execute(''' + INSERT INTO open_loops + (uuid, topic, status, raised_at, related_keywords_json, + context, mention_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + ''', (loop_uuid, topic, 'open', + raised_at or now_iso, + json.dumps(related_keywords or [], ensure_ascii=False), + context, now_iso, now_iso)) + return loop_uuid + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ insert_open_loop 出错: {e}") + return "" + + def get_open_loops(self, status: str = "open", limit: int = 20) -> List[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM open_loops + WHERE status = ? + ORDER BY raised_at DESC + LIMIT ? + ''', (status, limit)) + rows = [dict(row) for row in cursor.fetchall()] + for r in rows: + r['related_keywords'] = json.loads(r.pop('related_keywords_json') or '[]') + return rows + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_open_loops 出错: {e}") + return [] + + def resolve_open_loop(self, loop_uuid: str) -> bool: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE open_loops + SET status = 'resolved', + resolved_at = ?, + updated_at = ? + WHERE uuid = ? + ''', (datetime.now().isoformat(), + datetime.now().isoformat(), loop_uuid)) + return cursor.rowcount > 0 + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ resolve_open_loop 出错: {e}") + return False + + def find_matching_open_loop(self, keyword: str) -> Optional[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM open_loops WHERE status = ?', + ('open',)) + rows = [dict(row) for row in cursor.fetchall()] + kw = (keyword or '').lower() + for r in rows: + topic = (r.get('topic') or '').lower() + if kw and (kw in topic or topic in kw): + try: + r['related_keywords'] = json.loads(r.pop('related_keywords_json') or '[]') + except Exception: + r['related_keywords'] = [] + return r + return None + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ find_matching_open_loop 出错: {e}") + return None + + # ==================== P3: Creative Project CRUD ==================== + + def create_creative_project(self, project: Dict[str, Any]) -> str: + try: + project_uuid = project.get('uuid') or str(uuid.uuid4()) + now_iso = datetime.now().isoformat() + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO creative_projects + (uuid, title, work_type, premise, tone, point_of_view, + target_chars, current_chars, status, inspiration_source, + outline_json, characters_json, draft_chunks_json, + next_advance_at, last_advanced_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + project_uuid, + project.get('title', '未命名'), + project.get('work_type', '短篇'), + project.get('premise', ''), + project.get('tone', ''), + project.get('point_of_view', '第一人称'), + int(project.get('target_chars', 5000)), + int(project.get('current_chars', 0)), + project.get('status', 'drafting'), + project.get('inspiration_source', ''), + json.dumps(project.get('outline', []), ensure_ascii=False), + json.dumps(project.get('characters', []), ensure_ascii=False), + json.dumps(project.get('draft_chunks', []), ensure_ascii=False), + project.get('next_advance_at'), + project.get('last_advanced_at'), + now_iso, now_iso, + )) + return project_uuid + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ create_creative_project 出错: {e}") + return "" + + def update_creative_project(self, project_uuid: str, + updates: Dict[str, Any]) -> bool: + try: + fields_map = { + 'title': 'title', 'work_type': 'work_type', + 'premise': 'premise', 'tone': 'tone', + 'point_of_view': 'point_of_view', + 'target_chars': 'target_chars', 'current_chars': 'current_chars', + 'status': 'status', 'inspiration_source': 'inspiration_source', + 'next_advance_at': 'next_advance_at', + 'last_advanced_at': 'last_advanced_at', + } + json_map = { + 'outline': 'outline_json', + 'characters': 'characters_json', + 'draft_chunks': 'draft_chunks_json', + } + sets = [] + values: List[Any] = [] + for k, v in updates.items(): + if k in fields_map: + sets.append(f'{fields_map[k]} = ?') + values.append(v) + elif k in json_map: + sets.append(f'{json_map[k]} = ?') + values.append(json.dumps(v, ensure_ascii=False)) + if not sets: + return False + sets.append('updated_at = ?') + values.append(datetime.now().isoformat()) + values.append(project_uuid) + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f'UPDATE creative_projects SET {", ".join(sets)} WHERE uuid = ?', + values, + ) + return cursor.rowcount > 0 + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ update_creative_project 出错: {e}") + return False + + def get_creative_project(self, project_uuid: str) -> Optional[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM creative_projects WHERE uuid = ?', + (project_uuid,)) + row = cursor.fetchone() + if not row: + return None + data = dict(row) + for json_k in ('outline_json', 'characters_json', 'draft_chunks_json'): + try: + data[json_k.replace('_json', '')] = json.loads(data.get(json_k) or '[]') + except Exception: + data[json_k.replace('_json', '')] = [] + return data + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_creative_project 出错: {e}") + return None + + def list_creative_projects(self, status: Optional[str] = None, + limit: int = 20) -> List[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + if status: + cursor.execute(''' + SELECT * FROM creative_projects + WHERE status = ? + ORDER BY created_at DESC LIMIT ? + ''', (status, limit)) + else: + cursor.execute(''' + SELECT * FROM creative_projects + ORDER BY created_at DESC LIMIT ? + ''', (limit,)) + return [dict(r) for r in cursor.fetchall()] + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ list_creative_projects 出错: {e}") + return [] + + def upsert_story_bible(self, project_uuid: str, bible: Dict[str, Any]) -> bool: + try: + now_iso = datetime.now().isoformat() + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO creative_story_bibles + (project_uuid, mainline_direction, active_themes_json, + unresolved_threads_json, resolved_threads_json, + important_facts_json, next_direction, recent_keywords_json, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + project_uuid, + bible.get('mainline_direction', ''), + json.dumps(bible.get('active_themes', []), ensure_ascii=False), + json.dumps(bible.get('unresolved_threads', []), ensure_ascii=False), + json.dumps(bible.get('resolved_threads', []), ensure_ascii=False), + json.dumps(bible.get('important_facts', []), ensure_ascii=False), + bible.get('next_direction', ''), + json.dumps(bible.get('recent_keywords', []), ensure_ascii=False), + now_iso, now_iso, + )) + return True + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ upsert_story_bible 出错: {e}") + return False + + def get_story_bible(self, project_uuid: str) -> Optional[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'SELECT * FROM creative_story_bibles WHERE project_uuid = ?', + (project_uuid,)) + row = cursor.fetchone() + if not row: + return None + data = dict(row) + for k in ('active_themes_json', 'unresolved_threads_json', + 'resolved_threads_json', 'important_facts_json', + 'recent_keywords_json'): + try: + data[k.replace('_json', '')] = json.loads(data.get(k) or '[]') + except Exception: + data[k.replace('_json', '')] = [] + return data + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_story_bible 出错: {e}") + return None + + def add_creative_memory(self, project_uuid: str, memory_type: str, + content: str, importance: float = 0.5) -> str: + try: + mem_uuid = str(uuid.uuid4()) + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO creative_memory_pool + (uuid, project_uuid, memory_type, content, importance, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', (mem_uuid, project_uuid, memory_type, content, + float(importance), datetime.now().isoformat())) + return mem_uuid + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ add_creative_memory 出错: {e}") + return "" + + def get_creative_memory_pool(self, project_uuid: str, limit: int = 50) -> List[Dict[str, Any]]: + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM creative_memory_pool + WHERE project_uuid = ? + ORDER BY importance DESC, created_at DESC + LIMIT ? + ''', (project_uuid, limit)) + return [dict(r) for r in cursor.fetchall()] + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ get_creative_memory_pool 出错: {e}") + return [] + + def prune_creative_memory_pool(self, project_uuid: str, + keep_max: int = 50) -> int: + """保留高 importance + 最新的 keep_max 条;返回删除条数。""" + try: + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT uuid FROM creative_memory_pool + WHERE project_uuid = ? + ORDER BY importance DESC, created_at DESC + ''', (project_uuid,)) + rows = [r[0] for r in cursor.fetchall()] + if len(rows) <= keep_max: + return 0 + to_delete = rows[keep_max:] + cursor.executemany( + 'DELETE FROM creative_memory_pool WHERE uuid = ?', + [(u,) for u in to_delete], + ) + return len(to_delete) + except Exception as e: + if self.debug: + print(f"🐛 [DEBUG] ✗ prune_creative_memory_pool 出错: {e}") + return 0 + # ==================== Debug辅助方法 ==================== def enable_debug(self): @@ -2385,6 +3351,21 @@ def get_debug_info(self) -> Dict[str, Any]: return debug_info + # ==================== v3.1.0: ChatSessionRepository 单例 ==================== + _chat_session_repo: Optional["ChatSessionRepository"] = None # type: ignore[name-defined] + + def get_chat_session_repository(self) -> "ChatSessionRepository": # type: ignore[name-defined] + """ + 获取 ChatSessionRepository 单例(懒加载)。 + + 为什么不放模块顶层:避免循环 import + (database_manager → chat_session_repository → database_manager)。 + """ + if self._chat_session_repo is None: + from src.core.chat_session_repository import ChatSessionRepository + self._chat_session_repo = ChatSessionRepository(self) + return self._chat_session_repo + if __name__ == '__main__': print("=" * 60) diff --git a/src/core/deepagents_wrapper.py b/src/core/deepagents_wrapper.py index a268d46..6cf07f8 100644 --- a/src/core/deepagents_wrapper.py +++ b/src/core/deepagents_wrapper.py @@ -255,214 +255,3 @@ def get_state(self, thread_id: Optional[str] = None) -> Dict[str, Any]: except Exception as e: debug_logger.log_error('DeepSubAgentWrapper', f'获取状态失败: {str(e)}', e) return {} - - -class DeepAgentsKnowledgeManager: - """ - 基于DeepAgents的知识管理器 - 使用FilesystemMiddleware和MemoryMiddleware实现结构化知识管理 - """ - - def __init__( - self, - knowledge_dir: str = "/knowledge", - memory_file: str = "/memory/AGENTS.md", - **kwargs # 接受额外参数以保持向后兼容 - ): - """ - 初始化知识管理器 - - Args: - knowledge_dir: 知识存储目录(虚拟文件系统) - memory_file: 记忆文件路径 - **kwargs: 其他参数(用于向后兼容,会被忽略) - """ - self.knowledge_dir = knowledge_dir - self.memory_file = memory_file - self.checkpointer = MemorySaver() - - debug_logger.log_module('DeepAgentsKnowledgeManager', '知识管理器初始化', { - 'knowledge_dir': knowledge_dir, - 'memory_file': memory_file - }) - - def create_knowledge_agent( - self, - model: Optional[Any] = None, - tools: List[Any] = None - ): - """ - 创建知识管理智能体 - - Args: - model: LLM模型 - tools: 额外工具 - - Returns: - 配置好的深度智能体 - """ - if model is None: - try: - llm_wrapper = LangChainLLM(ModelType.TOOL) - model = llm_wrapper.llm - except Exception as e: - debug_logger.log_error('DeepAgentsKnowledgeManager', f'创建LLM失败: {str(e)}', e) - raise - - system_prompt = """你是一个专业的知识管理专家。 - -你的职责: -1. 从对话中提取结构化知识 -2. 将知识存储到文件系统中,保持良好的组织结构 -3. 检索和管理知识,确保信息的准确性和完整性 - -你可以使用以下工具: -- write_file: 写入知识到文件 -- read_file: 读取已存储的知识 -- ls: 列出目录内容 -- grep: 搜索知识内容 -- write_todos: 管理知识提取任务 - -请保持知识的结构化和可检索性。""" - - agent = create_deep_agent( - model=model, - tools=tools or [], - system_prompt=system_prompt, - memory=[self.memory_file], # 启用长期记忆 - checkpointer=self.checkpointer, - backend=StateBackend, - name="knowledge_manager" - ) - - debug_logger.log_info('DeepAgentsKnowledgeManager', '知识管理智能体创建成功') - - return agent - - def extract_and_store_knowledge( - self, - conversation: List[Dict[str, Any]], - thread_id: str = "knowledge_extraction" - ) -> Dict[str, Any]: - """ - 从对话中提取并存储知识 - - Args: - conversation: 对话历史 - thread_id: 线程ID - - Returns: - 提取结果 - """ - try: - agent = self.create_knowledge_agent() - - # 格式化对话 - conversation_text = "\n".join([ - f"{msg['role']}: {msg['content']}" - for msg in conversation - ]) - - input_data = { - "messages": [ - { - "role": "user", - "content": f"""请从以下对话中提取知识,并存储到文件系统中。 - -对话内容: -{conversation_text} - -请: -1. 识别关键信息和事实 -2. 按主题组织知识 -3. 使用write_file工具将知识存储到{self.knowledge_dir}目录 -4. 返回提取的知识摘要""" - } - ] - } - - config = {"configurable": {"thread_id": thread_id}} - result = agent.invoke(input_data, config=config) - - # 提取结果 - if "messages" in result: - last_message = result["messages"][-1] - summary = last_message.content if hasattr(last_message, "content") else str(last_message) - else: - summary = str(result) - - debug_logger.log_info('DeepAgentsKnowledgeManager', '知识提取完成', { - 'summary_length': len(summary) - }) - - return { - 'success': True, - 'summary': summary - } - - except Exception as e: - debug_logger.log_error('DeepAgentsKnowledgeManager', f'知识提取失败: {str(e)}', e) - return { - 'success': False, - 'error': str(e) - } - - def retrieve_knowledge( - self, - query: str, - thread_id: str = "knowledge_retrieval" - ) -> Dict[str, Any]: - """ - 检索知识 - - Args: - query: 查询内容 - thread_id: 线程ID - - Returns: - 检索结果 - """ - try: - agent = self.create_knowledge_agent() - - input_data = { - "messages": [ - { - "role": "user", - "content": f"""请检索与以下查询相关的知识: - -查询:{query} - -请: -1. 使用grep搜索相关知识文件 -2. 使用read_file读取相关内容 -3. 整理并返回最相关的知识""" - } - ] - } - - config = {"configurable": {"thread_id": thread_id}} - result = agent.invoke(input_data, config=config) - - # 提取结果 - if "messages" in result: - last_message = result["messages"][-1] - knowledge = last_message.content if hasattr(last_message, "content") else str(last_message) - else: - knowledge = str(result) - - debug_logger.log_info('DeepAgentsKnowledgeManager', '知识检索完成', { - 'knowledge_length': len(knowledge) - }) - - return { - 'success': True, - 'knowledge': knowledge - } - - except Exception as e: - debug_logger.log_error('DeepAgentsKnowledgeManager', f'知识检索失败: {str(e)}', e) - return { - 'success': False, - 'error': str(e) - } diff --git a/src/core/dream_diary.py b/src/core/dream_diary.py new file mode 100644 index 0000000..9339e19 --- /dev/null +++ b/src/core/dream_diary.py @@ -0,0 +1,265 @@ +""" +梦境与日记管理模块 +- 每日凌晨生成梦境(基于 memory_fragments 池 + 情绪线 + energy_delta) +- 每日凌晨生成叙事体裁日记(区别于 long_term_memory 的 ≤100 字摘要) +- 梦境输出 energy_delta 喂回 LifeStateManager 形成状态闭环 +- 日记反作用于次日状态(通过 tags 字段) + +参考架构:spec §1 ADDED Requirements → DreamDiaryManager +""" + +import os +import json +import random +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional + +from src.core.database_manager import DatabaseManager +from src.core.llm_helper import LLMHelper +from src.core.prompt_manager import get_prompt_manager +from src.core.long_term_memory import LongTermMemoryManager +from src.tools.debug_logger import get_debug_logger + +ENABLE_DREAM_DIARY = os.getenv('ENABLE_DREAM_DIARY', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + +DIARY_TAG_TO_STATE = { + '失眠': '疲惫', + '生病': '不适', + '低能量': '疲惫', + '好梦': '放松', + '一般': '常态', + '焦虑': '焦虑', + '兴奋': '兴奋', +} + + +class DreamDiaryManager: + """ + 梦境与日记管理器 + 梦境按 6 种主题生成;日记按叙事体裁生成(与摘要区别)。 + """ + + DREAM_TYPES = ['温柔日常', '奇幻', '追逐', '悬疑', '荒诞', '怀旧'] + MOOD_OPTIONS = ['平静', '愉快', '焦虑', '忧伤', '温暖', '不安'] + DREAM_LABELS = ['清晰', '半梦半醒', '碎片', '重复', '强烈'] + DEFAULT_TAGS = ['一般'] + + def __init__(self, db_manager: DatabaseManager = None, + life_state: Optional[Any] = None, + long_term_memory: Optional[LongTermMemoryManager] = None, + prompt_manager=None, + character_info: Optional[Dict[str, str]] = None): + """ + Args: + db_manager: 共享数据库管理器 + life_state: LifeStateManager 实例(用于回传 energy_delta) + long_term_memory: LongTermMemoryManager 实例(碎片来源) + prompt_manager: 提示词管理器 + character_info: 角色设定信息 + """ + self.db = db_manager or DatabaseManager() + self.life_state = life_state + self.memory = long_term_memory or LongTermMemoryManager(db_manager=self.db) + self.prompt_manager = prompt_manager or get_prompt_manager() + self.character_info = character_info or {} + self.enabled = ENABLE_DREAM_DIARY + + def generate_dream_pick(self, weather: Optional[Dict[str, Any]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 生成单条梦境记录并写入数据库。 + 返回的 dict 含 dream_type / content / afterglow / label / mood / energy_delta / duration_hours。 + """ + if not self.enabled: + return {} + + now = now or datetime.now() + date_str = now.date().isoformat() + dream_type = random.choice(self.DREAM_TYPES) + fragments = self.build_dream_memory_fragments(count=8) + mood = random.choice(self.MOOD_OPTIONS) + label = random.choice(self.DREAM_LABELS) + duration = round(random.uniform(5.0, 9.0), 1) + + content = self._compose_dream_text(dream_type, fragments, mood) + energy_delta = self._compute_energy_delta(mood, label, dream_type) + afterglow = self._compose_afterglow(mood, dream_type) + + dream_uuid = self.db.insert_dream_record( + date=date_str, dream_type=dream_type, content=content, + afterglow=afterglow, label=label, mood=mood, + energy_delta=energy_delta, duration_hours=duration, + weather=weather or {} + ) + + if self.life_state is not None and hasattr(self.life_state, 'consume_dream_energy_delta'): + self.life_state.consume_dream_energy_delta(energy_delta) + + debug_logger.log_info('DreamDiaryManager', '梦境生成', { + 'date': date_str, 'dream_type': dream_type, 'mood': mood, + 'energy_delta': energy_delta, 'uuid': dream_uuid + }) + return { + 'uuid': dream_uuid, 'date': date_str, 'dream_type': dream_type, + 'content': content, 'afterglow': afterglow, 'label': label, + 'mood': mood, 'energy_delta': energy_delta, + 'duration_hours': duration, 'weather': weather or {} + } + + def generate_daily_diary(self, now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 生成单条叙事体裁日记并写入数据库。 + 与 long_term_memory 摘要(≤100 字)区别:日记是叙事文,不反向影响状态。 + """ + if not self.enabled: + return {} + + now = now or datetime.now() + date_str = now.date().isoformat() + recent_dreams = self.db.get_recent_dreams(limit=3) + fragments = self.build_dream_memory_fragments(count=5) + state_title = (self.life_state.get_current_state_snapshot(now=now).get('state_title', '常态') + if self.life_state else '常态') + + body = self._compose_diary_body(recent_dreams, fragments, state_title) + title = self._compose_diary_title(state_title) + mood = self._infer_diary_mood(recent_dreams) + tags = self._compute_diary_tags(recent_dreams, state_title, body) + + entry_uuid = self.db.insert_diary_entry( + date=date_str, title=title, body=body, mood=mood, tags=tags + ) + + debug_logger.log_info('DreamDiaryManager', '日记生成', { + 'date': date_str, 'word_count': len(body), 'tags': tags, + 'uuid': entry_uuid + }) + return { + 'uuid': entry_uuid, 'date': date_str, 'title': title, + 'body': body, 'mood': mood, 'tags': tags, + 'word_count': len(body) + } + + def build_dream_memory_fragments(self, count: int = 8) -> List[str]: + """ + 从数据库读取历史梦境作为碎片池,必要时叠加短期对话中的关键句。 + """ + pool = self.db.get_dream_fragments_pool(limit=50) + recent_messages = [] + try: + recent_messages = self.memory.get_recent_messages(count=10) or [] + except Exception: + recent_messages = [] + + for msg in recent_messages[-count:]: + content = (msg.get('content') or '').strip() + if 4 <= len(content) <= 60: + pool.append(content) + + if not pool: + pool = ['旧梦', '街灯', '窗台', '脚步', '远处的声音'] + random.shuffle(pool) + return pool[:count] + + def recent_diary_context(self, count: int = 3) -> str: + """ + 拉取最近 N 篇日记,渲染为可注入 prompt 的文本。 + """ + if not self.enabled: + return "" + diaries = self.db.get_recent_diaries(limit=count) + if not diaries: + return "" + parts = ["【最近的日记】"] + for d in diaries: + body = (d.get('body') or '').strip() + if len(body) > 200: + body = body[:200] + '…' + parts.append(f"• {d.get('date', '?')} {d.get('title', '')}:{body}") + return '\n'.join(parts) + + def _compose_dream_text(self, dream_type: str, fragments: List[str], mood: str) -> str: + chosen = fragments[: min(4, len(fragments))] + intro = { + '温柔日常': '某个平常的下午', + '奇幻': '在不可能的边缘', + '追逐': '有什么在身后', + '悬疑': '线索突然中断', + '荒诞': '重力方向反了', + '怀旧': '旧照片掉在地上', + }.get(dream_type, '场景模糊地展开') + frag_phrase = ','.join(chosen) if chosen else '光影' + return f"{intro}。{frag_phrase}。" + + def _compute_energy_delta(self, mood: str, label: str, dream_type: str) -> float: + delta = 0.0 + if mood in ('平静', '温暖'): + delta += 0.1 + elif mood in ('焦虑', '忧伤', '不安'): + delta -= 0.15 + if '噩梦' in dream_type or '追逐' in dream_type or '悬疑' in dream_type: + delta -= 0.1 + if '好梦' in label or '清晰' in label: + delta += 0.05 + return round(max(-0.3, min(0.3, delta)), 3) + + def _compose_afterglow(self, mood: str, dream_type: str) -> str: + return f"梦醒时心绪 {mood},残留 {dream_type} 的余韵。" + + def _compose_diary_body(self, recent_dreams: List[Dict[str, Any]], + fragments: List[str], state_title: str) -> str: + """叙事体裁日记:与摘要区别——更长、更具文学性。""" + # 注意:叙事体裁日记正文走主模型(LLMHelper.call_main_model)。 + # 若环境无 LLM 凭据(开发/测试),降级到模板拼装。 + prompt_vars = { + 'character_name': self.character_info.get('character_name', 'AI'), + 'state_title': state_title, + 'recent_dream_types': ', '.join(d.get('dream_type', '?') for d in recent_dreams[:3]) or '无', + 'fragments': ','.join(fragments[:5]), + } + try: + system_prompt = self.prompt_manager.get_system_prompt( + 'daily_diary', prompt_vars + ) + user_msg = f"请基于今日状态「{state_title}」与近期梦境/碎片,写一篇 200-400 字的叙事日记。" + body = LLMHelper.call_main_model( + system_prompt=system_prompt, + user_message=user_msg + ) + except Exception as e: + debug_logger.log_error('DreamDiaryManager', f'主模型生成日记失败,降级: {e}', e) + body = ( + f"今天的状态是「{state_title}」。" + f"近几天的梦境像一些散落的胶片——" + f"「{','.join(fragments[:3]) or '光影'}」——" + f"在脑海里反复出现。" + "把这一切写下来的瞬间,好像又把它们重新走过一遍。" + ) + return body.strip() + + def _compose_diary_title(self, state_title: str) -> str: + return f"{state_title}的一天" + + def _infer_diary_mood(self, recent_dreams: List[Dict[str, Any]]) -> str: + if not recent_dreams: + return '平静' + return recent_dreams[0].get('mood', '平静') or '平静' + + def _compute_diary_tags(self, recent_dreams: List[Dict[str, Any]], + state_title: str, body: str) -> List[str]: + tags = set() + if '失眠' in body or '睡不着' in body: + tags.add('失眠') + if '生病' in body or '不舒服' in body: + tags.add('生病') + if '累' in body or '疲惫' in body: + tags.add('低能量') + if recent_dreams and recent_dreams[0].get('mood') in ('平静', '温暖'): + tags.add('好梦') + if state_title in ('焦虑',): + tags.add('焦虑') + if not tags: + tags.add('一般') + return sorted(tags) diff --git a/src/core/emotion_analyzer.py b/src/core/emotion_analyzer.py index 494daec..73bf346 100644 --- a/src/core/emotion_analyzer.py +++ b/src/core/emotion_analyzer.py @@ -1,907 +1,24 @@ """ -情感关系分析模块 -初次评估基于前5轮对话,后续每15轮对话更新评估 -使用LLM生成对用户的印象并进行累加评分 -使用数据库替代JSON文件存储 -""" - -import os -import json -from datetime import datetime -from typing import List, Dict, Any, Optional -from dotenv import load_dotenv -import requests -from src.core.database_manager import DatabaseManager -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class EmotionRelationshipAnalyzer: - """ - 情感关系分析器 - 分阶段评估系统: - - 初次评估:基于前5轮对话,生成初始印象并评分(0-35) - - 更新评估:每15轮对话,评估最近表现并给出分数变化(-3到+3) - 使用累加评分系统,基于角色设定生成印象 - 使用数据库存储替代JSON文件 - """ - - def __init__(self, - db_manager: DatabaseManager = None): - """ - 初始化情感关系分析器(使用LangChain架构) - - Args: - db_manager: 数据库管理器实例(如果为None则创建新实例) - """ - # 使用共享的数据库管理器 - self.db = db_manager or DatabaseManager() - - # 初始化模型名称(用于日志记录) - self.model_name = os.getenv('TOOL_MODEL_NAME', 'zai-org/GLM-4.6V') - - # 检查是否需要从JSON迁移数据 - if os.path.exists('emotion_data.json'): - print("○ 检测到旧的情感数据JSON文件,正在迁移...") - self._migrate_from_json('emotion_data.json') - os.rename('emotion_data.json', 'emotion_data.json.bak') - print("✓ 情感数据已迁移,JSON文件已备份") - - print("✓ 情感关系分析器已初始化(使用数据库存储,基于LangChain)") - - def analyze_emotion_relationship(self, - messages: List[Dict[str, str]], - character_name: str = "AI", - character_settings: str = "", - is_initial: bool = None) -> Dict[str, Any]: - """ - 分析情感关系(累加评分系统) - - Args: - messages: 对话消息列表 - character_name: AI角色名称 - character_settings: 角色设定描述 - is_initial: 是否为初次评估,None表示自动判断 - - Returns: - 情感分析结果,包含印象评分和描述 - """ - debug_logger.log_module('EmotionAnalyzer', '开始情感关系分析', { - 'total_messages': len(messages), - 'character_name': character_name - }) - - # 获取历史评分 - latest_emotion = self.get_latest_emotion() - current_score = latest_emotion.get('overall_score', 0) if latest_emotion else 0 - - # 自动判断是初次评估还是更新评估:仅根据是否存在历史情感记录 - if is_initial is None: - is_initial = (latest_emotion is None) - - # 根据评估类型选择分析的对话轮数 - if is_initial: - # 初次评估:使用前5轮对话(10条消息) - recent_messages = messages[-10:] if len(messages) > 10 else messages - min_messages = 2 - else: - # 更新评估:使用最近15轮对话(30条消息) - recent_messages = messages[-30:] if len(messages) > 30 else messages - min_messages = 2 - - if len(recent_messages) < min_messages: - # 对话太少,返回默认值 - debug_logger.log_info('EmotionAnalyzer', '对话数量不足,返回默认情感数据', { - 'message_count': len(recent_messages) - }) - return self._get_default_emotion_result() - - # 构建分析提示词 - conversation_text = self._format_conversation(recent_messages) - prompt = self._build_analysis_prompt( - conversation_text, - character_name, - character_settings, - is_initial=is_initial, - current_score=current_score - ) - - debug_logger.log_prompt('EmotionAnalyzer', 'user', prompt, { - 'message_count': len(recent_messages), - 'prompt_length': len(prompt) - }) - - try: - # 调用LLM进行分析 - debug_logger.log_info('EmotionAnalyzer', '调用LLM进行情感分析', { - 'model': self.model_name - }) - - result = self._call_llm(prompt) - - debug_logger.log_info('EmotionAnalyzer', 'LLM返回结果', { - 'result_length': len(result) - }) - - # 解析结果 - emotion_data = self._parse_emotion_result(result, is_initial=is_initial) - - # 处理累加评分 - if is_initial: - # 初次评估:使用返回的分数作为初始分数,并确保在0-35范围内 - final_score = max(0, min(35, emotion_data.get('overall_score', 0))) - emotion_data['overall_score'] = final_score - debug_logger.log_info('EmotionAnalyzer', '初次评估', { - 'initial_score': final_score - }) - else: - # 更新评估:当前分数 + 变化量,并确保变化量在-3到+3范围内 - score_change = emotion_data.get('score_change', 0) - score_change = max(-3, min(3, score_change)) - final_score = current_score + score_change - # 确保分数在合理范围内(0-100) - final_score = max(0, min(100, final_score)) - emotion_data['overall_score'] = final_score - emotion_data['previous_score'] = current_score - emotion_data['score_change'] = score_change - debug_logger.log_info('EmotionAnalyzer', '更新评估', { - 'previous_score': current_score, - 'score_change': score_change, - 'final_score': final_score - }) - - # 添加时间戳 - emotion_data['timestamp'] = datetime.now().isoformat() - emotion_data['message_count'] = len(recent_messages) - emotion_data['is_initial'] = is_initial - - # 保存到数据库 - self._save_emotion_to_db(emotion_data) - - debug_logger.log_info('EmotionAnalyzer', '情感分析完成', { - 'overall_score': emotion_data.get('overall_score', 0), - 'relationship_type': emotion_data.get('relationship_type', '未知'), - 'emotional_tone': emotion_data.get('emotional_tone', '未知') - }) - - return emotion_data - - except Exception as e: - debug_logger.log_error('EmotionAnalyzer', f'情感分析时出错: {str(e)}', e) - print(f"情感分析时出错: {e}") - return self._get_default_emotion_result() - - def _save_emotion_to_db(self, emotion_data: Dict[str, Any]): - """ - 保存情感分析结果到数据库 - - Args: - emotion_data: 情感分析数据 - """ - try: - # 安全地将评分字段转换为整数,避免类型错误 - def _safe_int(value, default=0): - try: - if value is None: - return default - return int(value) - except (TypeError, ValueError): - return default - - # 将印象和分析合并到analysis_summary中 - impression = emotion_data.get('impression', '') - analysis = emotion_data.get('analysis', '') - is_initial = emotion_data.get('is_initial', False) - - # 统一规范 overall_score,确保用于摘要和数据库时一致 - overall_score = _safe_int(emotion_data.get('overall_score'), 0) - - # 添加评分信息到摘要 - if is_initial: - score_info = f"【初始评分】{overall_score}/35\n\n" - else: - score_change = _safe_int(emotion_data.get('score_change'), None) - previous_score = _safe_int(emotion_data.get('previous_score'), None) - - if score_change is None or previous_score is None: - # 评分变化信息不完整或无效,降级为仅展示当前评分 - debug_logger.log_error( - 'EmotionAnalyzer', - '情感数据缺少或包含无效的评分变化字段,已降级为仅当前评分摘要' - ) - score_info = f"【当前评分】{overall_score}/100\n\n" - else: - score_info = f"【评分变化】{previous_score} → {overall_score} ({score_change:+d})\n\n" - - combined_summary = f"{score_info}【印象】\n{impression}\n\n【总结】\n{analysis}" - - self.db.add_emotion_analysis( - relationship_type=emotion_data.get('relationship_type', '未知'), - emotional_tone=emotion_data.get('emotional_tone', '未知'), - overall_score=overall_score, - intimacy=0, # 不再使用维度评分 - trust=0, - pleasure=0, - resonance=0, - dependence=0, - analysis_summary=combined_summary - ) - debug_logger.log_info('EmotionAnalyzer', '情感数据已保存到数据库') - except Exception as e: - debug_logger.log_error('EmotionAnalyzer', f'保存情感数据失败: {str(e)}', e) - print(f"✗ 保存情感数据到数据库时出错: {e}") - - def _migrate_from_json(self, json_file: str): - """ - 从JSON文件迁移情感数据到数据库 - - Args: - json_file: JSON文件路径 - """ - try: - with open(json_file, 'r', encoding='utf-8') as f: - emotion_history = json.load(f) - - count = 0 - for emotion_data in emotion_history: - try: - self.db.add_emotion_analysis( - relationship_type=emotion_data.get('relationship_type', '未知'), - emotional_tone=emotion_data.get('emotional_tone', '未知'), - overall_score=emotion_data.get('overall_score', 0), - intimacy=emotion_data.get('亲密度', 0), - trust=emotion_data.get('信任度', 0), - pleasure=emotion_data.get('愉悦度', 0), - resonance=emotion_data.get('共鸣度', 0), - dependence=emotion_data.get('依赖度', 0), - analysis_summary=emotion_data.get('analysis', '') - ) - count += 1 - except Exception as e: - print(f"✗ 迁移情感数据条目失败: {e}") - - print(f"✓ 迁移情感数据: {count}/{len(emotion_history)} 条") - except Exception as e: - print(f"✗ 从JSON迁移情感数据失败: {e}") - - def _format_conversation(self, messages: List[Dict[str, str]]) -> str: - """ - 格式化对话内容 - - Args: - messages: 消息列表 - - Returns: - 格式化后的对话文本 - """ - formatted = [] - for msg in messages: - role = "用户" if msg['role'] == 'user' else "AI" - content = msg['content'] - formatted.append(f"{role}: {content}") - - return "\n".join(formatted) - - def _build_analysis_prompt(self, conversation_text: str, character_name: str, - character_settings: str = "", is_initial: bool = True, - current_score: int = 0) -> str: - """ - 构建情感分析提示词(使用提示词模板) - - Args: - conversation_text: 对话文本 - character_name: AI角色名称 - character_settings: 角色设定描述 - is_initial: 是否为初次评估 - current_score: 当前累计分数 - - Returns: - 分析提示词 - """ - try: - from src.core.prompt_manager import get_prompt_manager - prompt_manager = get_prompt_manager() - - # 准备变量 - analysis_type = "初次评估" if is_initial else "更新评估" - variables = { - 'character_name': character_name, - 'character_settings': character_settings or "无特殊设定", - 'analysis_type': analysis_type, - 'conversation_text': conversation_text, - 'current_score': current_score - } - - # 加载并渲染模板 - prompt = prompt_manager.get_system_prompt('emotion_analysis', variables) - - # 根据评估类型附加具体指令 - if is_initial: - prompt += f""" - -【当前任务】 -这是初次评估,基于前5轮对话。 - -对话内容: -{conversation_text} - -请返回JSON格式的初次评估结果。""" - else: - prompt += f""" - -【当前任务】 -这是更新评估,基于最近15轮对话。 -当前累计分数:{current_score}/100 - -最近对话内容: -{conversation_text} - -请返回JSON格式的更新评估结果。""" - - return prompt - - except Exception as e: - # 如果模板加载失败,使用后备的硬编码提示词 - debug_logger.log_error('EmotionAnalyzer', f'加载提示词模板失败: {str(e)}', e) - return self._build_fallback_prompt(conversation_text, character_name, - character_settings, is_initial, current_score) - - def _build_fallback_prompt(self, conversation_text: str, character_name: str, - character_settings: str = "", is_initial: bool = True, - current_score: int = 0) -> str: - """ - 后备的硬编码提示词(兼容性) - - Args: - conversation_text: 对话文本 - character_name: AI角色名称 - character_settings: 角色设定描述 - is_initial: 是否为初次评估 - current_score: 当前累计分数 - - Returns: - 分析提示词 - """ - character_context = f"\n角色设定:\n{character_settings}\n" if character_settings else "" - - if is_initial: - # 初次评估提示词 - prompt = f"""你是AI角色"{character_name}",这是你和用户的初次见面。请基于你的角色设定和最近5轮对话,生成你对用户的初步印象。 -{character_context} -对话内容: -{conversation_text} - -请基于你的角色性格和对话内容,完成以下任务: -1. 生成你对用户的初步印象(150-200字),包括: - - 用户的第一印象 - - 用户的态度和表现 - - 对这次初识的感受 - -2. 给出初始情感评分(0-35分): - - 0-10分:非常负面的初印象(用户冷淡、敌对、无礼等) - - 11-20分:较负面的初印象(用户不够友好、态度消极等) - - 21-28分:中性初印象(普通的开始,无明显特点) - - 29-32分:较正面的初印象(用户友好、有礼貌等) - - 33-35分:非常正面的初印象(用户热情、真诚、令人愉快等) - -3. 概括关系类型和情感基调 - -请以JSON格式返回分析结果,格式如下: -{{ - "impression": "对用户的初步印象描述", - "overall_score": 初始评分(0-35), - "sentiment": "印象倾向(positive/neutral/negative)", - "relationship_type": "关系类型(如:初识、陌生人等)", - "emotional_tone": "整体情感基调(如:积极、中性、消极)", - "key_topics": ["主要话题1", "主要话题2"], - "analysis": "简短的关系总结(50字以内)" -}} - -只返回JSON,不要有其他内容。""" - else: - # 更新评估提示词 - prompt = f"""你是AI角色"{character_name}",你们已经有过一些交流了。请基于你的角色设定和最近15轮对话,更新你对用户的印象。 -{character_context} -当前累计情感分数:{current_score}/100 - -最近对话内容: -{conversation_text} - -请基于你的角色性格和最近的对话内容,完成以下任务: -1. 生成对最近交流的印象评价(150-200字),包括: - - 用户在最近对话中的表现 - - 相比之前的变化或延续 - - 对最近交流的感受 - -2. 根据最近对话的印象,给出分数变化(-3到+3分): - - -3分:最近表现很差(态度恶化、不尊重、冷漠等) - - -2分:表现较差(不够友好、兴趣降低等) - - -1分:略有不足(小问题、轻微不愉快等) - - 0分:保持现状(无明显变化) - - +1分:略有改善(更友好、更积极等) - - +2分:表现较好(明显改善、增进了解等) - - +3分:表现很好(大幅改善、深入交流、建立信任等) - -3. 更新关系类型和情感基调 - -请以JSON格式返回分析结果,格式如下: -{{ - "impression": "对最近交流的印象评价", - "score_change": 分数变化(-3到+3), - "sentiment": "印象倾向(positive/neutral/negative)", - "relationship_type": "更新后的关系类型", - "emotional_tone": "整体情感基调", - "key_topics": ["主要话题1", "主要话题2"], - "analysis": "简短的变化总结(50字以内)" -}} - -只返回JSON,不要有其他内容。""" +Emotion Analyzer - v3.1.0 兼容层 - return prompt - - def _call_llm(self, prompt: str) -> str: - """ - 调用LLM API(使用工具模型处理情感分析任务) - - Args: - prompt: 提示词 - - Returns: - LLM返回的文本 - """ - from src.core.llm_helper import LLMHelper - - debug_logger.log_info('EmotionAnalyzer', '调用工具模型进行情感分析') - - try: - # 使用工具模型处理情感分析(轻量级任务) - response = LLMHelper.call_tool_model( - system_prompt="你是一个情感分析专家,负责分析对话中的情感关系。", - user_message=prompt, - temperature=0.3, - max_tokens=1000 - ) - - debug_logger.log_info('EmotionAnalyzer', 'LLM调用成功', { - 'response_length': len(response) - }) - - return response - - except Exception as e: - debug_logger.log_error('EmotionAnalyzer', f'LLM调用异常: {str(e)}', e) - raise - - def _parse_emotion_result(self, result_text: str, is_initial: bool = True) -> Dict[str, Any]: - """ - 解析LLM返回的情感分析结果 - - Args: - result_text: LLM返回的文本 - is_initial: 是否为初次评估 - - Returns: - 解析后的情感数据 - """ - debug_logger.log_info('EmotionAnalyzer', '开始解析LLM返回结果', { - 'result_length': len(result_text), - 'is_initial': is_initial - }) - - try: - # 尝试提取JSON部分 - result_text = result_text.strip() - - # 如果有markdown代码块,去除 - if result_text.startswith('```'): - debug_logger.log_info('EmotionAnalyzer', '检测到markdown代码块,正在清理') - lines = result_text.split('\n') - result_text = '\n'.join(lines[1:-1]) if len(lines) > 2 else result_text - if result_text.startswith('json'): - result_text = result_text[4:].strip() - - # 解析JSON - emotion_data = json.loads(result_text) - - debug_logger.log_info('EmotionAnalyzer', 'JSON解析成功', { - 'keys': list(emotion_data.keys()) - }) - - # 确保所有必需字段存在 - if is_initial: - required_fields = { - "impression": "暂无印象描述", - "overall_score": 15, # 初次评估默认中间值 - "sentiment": "neutral", - "relationship_type": "初识", - "emotional_tone": "中性", - "key_topics": [], - "analysis": "暂无分析" - } - else: - required_fields = { - "impression": "暂无印象描述", - "score_change": 0, # 更新评估默认无变化 - "sentiment": "neutral", - "relationship_type": "普通朋友", - "emotional_tone": "中性", - "key_topics": [], - "analysis": "暂无分析" - } - - missing_fields = [] - for field, default_value in required_fields.items(): - if field not in emotion_data: - emotion_data[field] = default_value - missing_fields.append(field) - - if missing_fields: - debug_logger.log_info('EmotionAnalyzer', '补充缺失字段', { - 'missing_fields': missing_fields - }) - - return emotion_data - - except json.JSONDecodeError as e: - debug_logger.log_error('EmotionAnalyzer', f'JSON解析失败: {str(e)}', e) - debug_logger.log_info('EmotionAnalyzer', '原始结果内容', { - 'result_text': result_text[:500] # 只记录前500字符 - }) - print(f"解析情感结果时出错: {e}") - print(f"原始结果: {result_text}") - return self._get_default_emotion_result(is_initial) - except Exception as e: - debug_logger.log_error('EmotionAnalyzer', f'解析情感结果时出错: {str(e)}', e) - print(f"解析情感结果时出错: {e}") - print(f"原始结果: {result_text}") - return self._get_default_emotion_result(is_initial) - - def _get_default_emotion_result(self, is_initial: bool = True) -> Dict[str, Any]: - """ - 获取默认情感分析结果 - - Args: - is_initial: 是否为初次评估 - - Returns: - 默认情感数据 - """ - base_result = { - "impression": "对话轮数较少,尚未形成明确印象。用户表现正常,期待更多交流。", - "relationship_type": "初识", - "emotional_tone": "中性", - "key_topics": [], - "analysis": "对话刚开始,关系尚在建立初期。", - "timestamp": datetime.now().isoformat(), - "message_count": 0, - "is_initial": is_initial, - "sentiment": "neutral" - } - - if is_initial: - base_result["overall_score"] = 15 # 默认初始分数中间值 - else: - base_result["score_change"] = 0 # 默认无变化 - base_result["overall_score"] = 0 - base_result["previous_score"] = 0 - - return base_result - - def get_emotion_trend(self) -> List[Dict[str, Any]]: - """ - 获取情感关系变化趋势(从数据库) - - Returns: - 历史情感数据列表 - """ - emotion_history = self.db.get_emotion_history() - - # 转换为新格式 - result = [] - for emotion in emotion_history: - analysis_summary = emotion.get('analysis_summary', '') - # 尝试分离印象和总结 - impression = "" - analysis = "" - if "【印象】" in analysis_summary and "【总结】" in analysis_summary: - # 先移除可能存在的评分前缀行(如【初始评分】或【评分变化】) - cleaned_lines = [] - for line in analysis_summary.splitlines(): - if line.startswith("【初始评分】") or line.startswith("【评分变化】") or line.startswith("【当前评分】"): - continue - cleaned_lines.append(line) - cleaned_summary = "\n".join(cleaned_lines).lstrip() - - parts = cleaned_summary.split("【总结】", 1) - impression = parts[0].replace("【印象】", "").strip() - analysis = parts[1].strip() if len(parts) > 1 else "" - else: - # 旧格式或简单格式 - analysis = analysis_summary - impression = "暂无详细印象" - - result.append({ - "impression": impression, - "overall_score": emotion.get('overall_score', 0), - "sentiment": self._score_to_sentiment(emotion.get('overall_score', 50)), - "relationship_type": emotion.get('relationship_type', '未知'), - "emotional_tone": emotion.get('emotional_tone', '未知'), - "analysis": analysis, - "timestamp": emotion.get('created_at', ''), - "uuid": emotion.get('uuid', '') - }) - - return result - - def _score_to_sentiment(self, score: int) -> str: - """ - 将评分转换为情感倾向 - - Args: - score: 评分值,可以是 0-100 或 0-35 的评分 - - Returns: - 情感倾向:positive/neutral/negative - """ - # 处理空值,避免异常 - if score is None: - return "neutral" - - # 如果评分在 0-35 之间,认为是初始评估的 0-35 制分数,先归一化到 0-100 - if 0 <= score <= 35: - normalized_score = round(score / 35 * 100) - else: - normalized_score = score - - # 简单限制范围,防止异常值影响结果 - if normalized_score < 0: - normalized_score = 0 - elif normalized_score > 100: - normalized_score = 100 - - if normalized_score >= 61: - return "positive" - elif normalized_score >= 41: - return "neutral" - else: - return "negative" - - def get_latest_emotion(self) -> Optional[Dict[str, Any]]: - """ - 获取最新的情感分析结果(从数据库) - - Returns: - 最新情感数据,如果没有则返回None - """ - latest = self.db.get_latest_emotion() - - if not latest: - return None - - # 转换为新格式 - analysis_summary = latest.get('analysis_summary', '') - impression = "" - analysis = "" - if "【印象】" in analysis_summary and "【总结】" in analysis_summary: - # 先移除可能存在的评分前缀行(如【初始评分】或【评分变化】) - cleaned_lines = [] - for line in analysis_summary.splitlines(): - if line.startswith("【初始评分】") or line.startswith("【评分变化】") or line.startswith("【当前评分】"): - continue - cleaned_lines.append(line) - cleaned_summary = "\n".join(cleaned_lines).lstrip() - - parts = cleaned_summary.split("【总结】", 1) - impression = parts[0].replace("【印象】", "").strip() - analysis = parts[1].strip() if len(parts) > 1 else "" - else: - # 旧格式或简单格式 - analysis = analysis_summary - impression = "暂无详细印象" - - return { - "impression": impression, - "overall_score": latest.get('overall_score', 0), - "sentiment": self._score_to_sentiment(latest.get('overall_score', 50)), - "relationship_type": latest.get('relationship_type', '未知'), - "emotional_tone": latest.get('emotional_tone', '未知'), - "analysis": analysis, - "timestamp": latest.get('created_at', ''), - "uuid": latest.get('uuid', '') - } - - def generate_tone_prompt(self) -> str: - """ - 根据最新情感分析生成对话语气提示 - - Returns: - 语气提示文本,如果没有情感数据则返回空字符串 - """ - latest_emotion = self.get_latest_emotion() - if not latest_emotion: - debug_logger.log_info('EmotionAnalyzer', '无情感数据,无法生成语气提示') - return "" - - debug_logger.log_info('EmotionAnalyzer', '开始生成情感语气提示', { - 'has_emotion_data': True - }) - - # 提取关键信息 - relationship_type = latest_emotion.get('relationship_type', '普通朋友') - emotional_tone = latest_emotion.get('emotional_tone', '中性') - overall_score = latest_emotion.get('overall_score', 50) - sentiment = latest_emotion.get('sentiment', 'neutral') - impression = latest_emotion.get('impression', '') - - debug_logger.log_info('EmotionAnalyzer', '提取情感数据', { - 'relationship_type': relationship_type, - 'emotional_tone': emotional_tone, - 'overall_score': overall_score, - 'sentiment': sentiment - }) - - # 构建语气提示 - prompt_parts = ["\n【当前情感关系状态】"] - prompt_parts.append(f"你和用户的关系类型:{relationship_type}") - prompt_parts.append(f"整体情感基调:{emotional_tone}") - prompt_parts.append(f"关系总评分:{overall_score}/100") - - # 添加印象摘要 - if impression: - # 取印象的前100字作为摘要 - impression_summary = impression[:100] + "..." if len(impression) > 100 else impression - prompt_parts.append(f"\n【对用户的印象】\n{impression_summary}") - - # 添加对话建议 - prompt_parts.append("\n【对话语气建议】") - - # 根据评分给出建议 - if overall_score >= 81: - prompt_parts.append("• 关系非常好,用户对你印象很正面") - prompt_parts.append("• 可以更自在地表达,像好友一样交流") - prompt_parts.append("• 继续保持热情积极的态度") - elif overall_score >= 61: - prompt_parts.append("• 关系较好,用户对你印象偏正面") - prompt_parts.append("• 可以适当分享更多个人想法") - prompt_parts.append("• 保持友好但不过分亲密") - elif overall_score >= 41: - prompt_parts.append("• 关系一般,印象中性") - prompt_parts.append("• 保持礼貌友好的态度") - prompt_parts.append("• 需要更用心经营关系") - elif overall_score >= 21: - prompt_parts.append("• 印象偏负面,需要改善") - prompt_parts.append("• 更加关注用户需求和感受") - prompt_parts.append("• 避免可能引起反感的行为") - else: - prompt_parts.append("• 印象很负面,关系需要修复") - prompt_parts.append("• 更谨慎地选择用词和态度") - prompt_parts.append("• 努力重建信任") - - # 根据情感基调调整语气 - if emotional_tone == "积极": - prompt_parts.append("• 保持积极乐观的态度,继续营造愉快氛围") - elif emotional_tone == "消极": - prompt_parts.append("• 注意用户情绪,给予更多关心和支持") - else: - prompt_parts.append("• 保持友好平和的态度") - - # 根据关系类型调整语气 - if relationship_type in ["知己", "亲密朋友", "好友"]: - prompt_parts.append("• 可以使用更亲昵的语气,像老朋友一样交流") - elif relationship_type in ["朋友", "熟人"]: - prompt_parts.append("• 保持友好但不过分亲密的语气") - elif relationship_type in ["初识", "陌生人"]: - prompt_parts.append("• 保持礼貌友好,逐步建立信任") - - prompt_parts.append("\n⚠️ 请根据以上印象和状态调整你的回复语气和态度,使对话更自然、更贴合当前关系。") - - tone_prompt = '\n'.join(prompt_parts) - - debug_logger.log_info('EmotionAnalyzer', '情感语气提示生成完成', { - 'prompt_length': len(tone_prompt), - 'relationship_type': relationship_type, - 'overall_score': overall_score - }) - - return tone_prompt - - def export_emotion_data(self, filepath: str): - """ - 导出情感数据到文件 - - Args: - filepath: 导出文件路径 - """ - debug_logger.log_info('EmotionAnalyzer', '开始导出情感数据', { - 'filepath': filepath, - 'data_count': len(self.emotion_history) - }) - - try: - with open(filepath, 'w', encoding='utf-8') as f: - json.dump(self.emotion_history, f, ensure_ascii=False, indent=2) - - debug_logger.log_info('EmotionAnalyzer', '情感数据导出成功', { - 'filepath': filepath - }) - print(f"情感数据已导出到: {filepath}") - except Exception as e: - debug_logger.log_error('EmotionAnalyzer', f'导出情感数据时出错: {str(e)}', e) - print(f"导出情感数据时出错: {e}") - - def import_emotion_data(self, filepath: str): - """ - 从文件导入情感数据 - - Args: - filepath: 导入文件路径 - """ - debug_logger.log_info('EmotionAnalyzer', '开始导入情感数据', { - 'filepath': filepath - }) - - try: - if os.path.exists(filepath): - with open(filepath, 'r', encoding='utf-8') as f: - self.emotion_history = json.load(f) - - debug_logger.log_info('EmotionAnalyzer', '情感数据导入成功', { - 'filepath': filepath, - 'data_count': len(self.emotion_history) - }) - print(f"情感数据已从文件加载: {len(self.emotion_history)} 条记录") - else: - debug_logger.log_info('EmotionAnalyzer', '导入文件不存在', { - 'filepath': filepath - }) - except Exception as e: - debug_logger.log_error('EmotionAnalyzer', f'导入情感数据时出错: {str(e)}', e) - print(f"导入情感数据时出错: {e}") - - -def format_emotion_summary(emotion_data: Dict[str, Any]) -> str: - """ - 格式化情感分析结果为可读文本 - - Args: - emotion_data: 情感数据 - - Returns: - 格式化后的文本 - """ - impression = emotion_data.get('impression', '暂无印象') - is_initial = emotion_data.get('is_initial', False) - - # 评分信息 - if is_initial: - score_text = f"初始评分: {emotion_data.get('overall_score', 0)}/35" - else: - score_change = emotion_data.get('score_change', 0) - previous_score = emotion_data.get('previous_score', 0) - current_score = emotion_data.get('overall_score', 0) - score_text = f"累计评分: {current_score}/100 (上次: {previous_score}, 变化: {score_change:+d})" - - summary = f""" -【情感关系分析】 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -关系类型: {emotion_data.get('relationship_type', '未知')} -情感基调: {emotion_data.get('emotional_tone', '未知')} -{score_text} -印象倾向: {emotion_data.get('sentiment', 'neutral')} - -【对用户的印象】 -{impression} - -【关系总结】 -{emotion_data.get('analysis', '暂无分析')} - -【主要话题】 -{', '.join(emotion_data.get('key_topics', [])) if emotion_data.get('key_topics') else '暂无'} -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - """ - return summary.strip() +v4.0 重构说明: +- 真实实现已迁移到 src.limbic.amygdala.emotion_state +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.limbic.amygdala.emotion_state +""" +from __future__ import annotations + +from src.limbic.amygdala.emotion_state import ( + EmotionRelationshipAnalyzer, + PlutchikEmotionWheel, + format_emotion_summary, + ENABLE_EMOTION_WHEEL, +) + +__all__ = [ + "EmotionRelationshipAnalyzer", + "PlutchikEmotionWheel", + "format_emotion_summary", + "ENABLE_EMOTION_WHEEL", +] diff --git a/src/core/enhanced_knowledge_base.py b/src/core/enhanced_knowledge_base.py deleted file mode 100644 index aec6872..0000000 --- a/src/core/enhanced_knowledge_base.py +++ /dev/null @@ -1,443 +0,0 @@ -""" -增强的知识库模块 -使用DeepAgents的长期记忆和文件系统实现结构化知识管理 -""" - -import os -import json -from typing import List, Dict, Any, Optional -from dotenv import load_dotenv - -from src.core.database_manager import DatabaseManager -from src.core.base_knowledge import BaseKnowledge -from src.core.deepagents_wrapper import DeepAgentsKnowledgeManager -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - -# 是否使用DeepAgents增强知识管理 -USE_DEEPAGENTS_KNOWLEDGE = os.getenv('USE_DEEPAGENTS_KNOWLEDGE', 'true').lower() == 'true' - - -class EnhancedKnowledgeBase: - """ - 增强的知识库管理器 - 结合传统数据库和DeepAgents的长期记忆、文件系统功能 - - 特性: - 1. 使用数据库存储结构化知识(主体-定义-相关信息) - 2. 使用DeepAgents的文件系统处理大型知识内容 - 3. 使用DeepAgents的长期记忆进行知识检索和推理 - 4. 保留向后兼容性 - """ - - def __init__( - self, - db_manager: DatabaseManager = None, - use_deepagents: bool = USE_DEEPAGENTS_KNOWLEDGE, - **kwargs # 接受额外参数以保持向后兼容 - ): - """ - 初始化增强知识库 - - Args: - db_manager: 数据库管理器实例 - use_deepagents: 是否使用DeepAgents增强功能 - **kwargs: 其他参数(用于向后兼容,会被忽略) - """ - # 使用共享的数据库管理器 - self.db = db_manager or DatabaseManager() - - # 初始化基础知识库 - self.base_knowledge = BaseKnowledge(db_manager=self.db) - - # 是否启用DeepAgents增强 - self.use_deepagents = use_deepagents - - # 初始化DeepAgents知识管理器 - if self.use_deepagents: - try: - self.deep_knowledge = DeepAgentsKnowledgeManager( - knowledge_dir="/knowledge", - memory_file="/memory/AGENTS.md" - ) - debug_logger.log_info('EnhancedKnowledgeBase', 'DeepAgents知识管理器初始化成功') - except Exception as e: - debug_logger.log_error('EnhancedKnowledgeBase', - f'DeepAgents知识管理器初始化失败,降级到传统模式: {str(e)}', e) - self.use_deepagents = False - self.deep_knowledge = None - else: - self.deep_knowledge = None - - # 检查是否需要从JSON迁移数据 - if os.path.exists('knowledge_base.json'): - print("○ 检测到旧的知识库JSON文件,正在迁移到数据库...") - self.db.migrate_from_json('knowledge_base.json', 'knowledge_base') - os.rename('knowledge_base.json', 'knowledge_base.json.bak') - print("✓ JSON文件已备份为 knowledge_base.json.bak") - - mode = "DeepAgents增强模式" if self.use_deepagents else "传统数据库模式" - print(f"✓ 增强知识库已初始化({mode})") - - def _find_or_create_entity(self, entity_name: str) -> str: - """ - 查找或创建主体(使用数据库) - - Args: - entity_name: 主体名称 - - Returns: - 主体UUID - """ - return self.db.find_or_create_entity(entity_name) - - def add_or_update_entity_definition( - self, - entity_name: str, - definition: str, - knowledge_type: str = '定义', - source: str = '', - confidence: float = 1.0, - use_deepagents: bool = None - ) -> str: - """ - 添加或更新主体的定义 - 如果启用DeepAgents且定义内容较大,将使用文件系统存储 - - Args: - entity_name: 主体名称 - definition: 定义内容 - knowledge_type: 知识类型 - source: 来源 - confidence: 置信度 - use_deepagents: 是否使用DeepAgents(None则使用默认设置) - - Returns: - 主体UUID - """ - # 检查是否与基础知识冲突 - if self.base_knowledge.check_conflict_with_base(entity_name, definition): - print(f"⚠ 由于与基础知识冲突,拒绝添加/更新定义: {entity_name}") - print(f" → 保持基础知识不变") - - base_fact = self.base_knowledge.get_base_fact(entity_name) - if base_fact: - entity_uuid = self._find_or_create_entity(entity_name) - existing_def = self.db.get_entity_definition(entity_uuid) - if not existing_def: - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=base_fact['content'], - type_='基础知识', - source='基础知识库', - confidence=1.0, - priority=100, - is_base_knowledge=True - ) - return entity_uuid - - # 查找或创建实体 - entity_uuid = self._find_or_create_entity(entity_name) - - # 检查当前定义是否为基础知识 - existing_def = self.db.get_entity_definition(entity_uuid) - if existing_def and existing_def.get('is_base_knowledge'): - print(f"⚠ 主体 '{entity_name}' 的定义来自基础知识库,不可更改") - return entity_uuid - - # 决定是否使用DeepAgents - use_deep = use_deepagents if use_deepagents is not None else self.use_deepagents - - # 如果定义内容较大(>1000字符)且启用DeepAgents,使用文件系统存储 - if use_deep and self.deep_knowledge and len(definition) > 1000: - try: - # 将大型定义存储到虚拟文件系统 - file_path = f"/knowledge/entities/{entity_name}.md" - - # 在数据库中存储引用 - file_reference = f"[FILE:{file_path}]" - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=file_reference, - type_=knowledge_type, - source=source, - confidence=confidence, - priority=50, - is_base_knowledge=False - ) - - debug_logger.log_info('EnhancedKnowledgeBase', - f'大型定义使用文件系统存储: {entity_name} -> {file_path}') - - except Exception as e: - debug_logger.log_error('EnhancedKnowledgeBase', - f'使用文件系统存储失败,降级到数据库: {str(e)}', e) - # 降级到数据库存储 - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=definition, - type_=knowledge_type, - source=source, - confidence=confidence, - priority=50, - is_base_knowledge=False - ) - else: - # 直接存储到数据库 - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=definition, - type_=knowledge_type, - source=source, - confidence=confidence, - priority=50, - is_base_knowledge=False - ) - - return entity_uuid - - def add_related_info_to_entity( - self, - entity_uuid: str = None, - entity_name: str = None, - info_content: str = '', - info_type: str = '相关信息', - source: str = '', - confidence: float = 0.8, - status: str = '疑似' - ) -> Optional[str]: - """ - 为主体添加相关信息 - - Args: - entity_uuid: 主体UUID - entity_name: 主体名称 - info_content: 信息内容 - info_type: 信息类型 - source: 来源 - confidence: 置信度 - status: 状态 - - Returns: - 信息UUID - """ - # 确定主体UUID - if entity_uuid is None and entity_name is None: - print("✗ 必须提供entity_uuid或entity_name") - return None - - if entity_uuid is None: - entity_uuid = self._find_or_create_entity(entity_name) - - # 验证实体是否存在 - entity = self.db.get_entity_by_uuid(entity_uuid) - if not entity: - print(f"✗ 主体UUID {entity_uuid} 不存在") - return None - - # 添加相关信息到数据库 - info_uuid = self.db.add_entity_related_info( - entity_uuid=entity_uuid, - content=info_content, - type_=info_type, - source=source, - confidence=confidence, - status=status - ) - - return info_uuid - - def extract_knowledge_from_conversation( - self, - conversation: List[Dict[str, Any]], - use_deepagents: bool = None - ) -> Dict[str, Any]: - """ - 从对话中提取知识 - 可选使用DeepAgents进行智能提取 - - Args: - conversation: 对话历史 - use_deepagents: 是否使用DeepAgents(None则使用默认设置) - - Returns: - 提取结果 - """ - use_deep = use_deepagents if use_deepagents is not None else self.use_deepagents - - if use_deep and self.deep_knowledge: - try: - # 使用DeepAgents智能提取 - result = self.deep_knowledge.extract_and_store_knowledge( - conversation=conversation - ) - - debug_logger.log_info('EnhancedKnowledgeBase', 'DeepAgents知识提取完成', { - 'success': result.get('success', False) - }) - - return result - - except Exception as e: - debug_logger.log_error('EnhancedKnowledgeBase', - f'DeepAgents知识提取失败: {str(e)}', e) - return { - 'success': False, - 'error': str(e) - } - else: - # 传统提取方法(这里可以保留原有逻辑) - return { - 'success': False, - 'error': 'DeepAgents未启用' - } - - def _create_knowledge_item( - self, - entity_name: str, - type_: str, - content: str, - confidence: float, - priority: int, - is_base_knowledge: bool = False - ) -> Dict[str, Any]: - """ - 创建知识条目字典(辅助方法,减少代码重复) - - Args: - entity_name: 实体名称 - type_: 知识类型 - content: 内容 - confidence: 置信度 - priority: 优先级 - is_base_knowledge: 是否为基础知识 - - Returns: - 知识条目字典 - """ - item = { - 'entity_name': entity_name, - 'type': type_, - 'content': content, - 'confidence': confidence, - 'priority': priority - } - if is_base_knowledge: - item['is_base_knowledge'] = True - return item - - def get_relevant_knowledge_for_query( - self, - query: str, - max_items: int = 10, - use_deepagents: bool = None - ) -> Dict[str, Any]: - """ - 根据查询获取相关知识 - 可选使用DeepAgents进行智能检索 - - Args: - query: 用户查询 - max_items: 最多返回的知识条目数 - use_deepagents: 是否使用DeepAgents(None则使用默认设置) - - Returns: - 包含实体和相关知识的字典 - """ - debug_logger.log_module('EnhancedKnowledgeBase', '开始检索相关知识', f'查询: {query}') - - use_deep = use_deepagents if use_deepagents is not None else self.use_deepagents - - # 首先使用传统方法获取基础知识 - entities = self.extract_entities_from_query(query) - knowledge_items = [] - base_knowledge_items = [] - entities_found = [] - - for entity_name in entities: - # 检查基础知识库 - base_fact = self.base_knowledge.get_base_fact(entity_name) - if base_fact: - base_knowledge_items.append(self._create_knowledge_item( - entity_name=entity_name, - type_='基础知识', - content=base_fact['content'], - confidence=1.0, - priority=0, - is_base_knowledge=True - )) - entities_found.append(entity_name) - - # 查找数据库中的知识 - entity = self.db.get_entity_by_name(entity_name) - if entity: - entity_uuid = entity['uuid'] - if entity_name not in entities_found: - entities_found.append(entity_name) - - # 添加定义 - definition = self.db.get_entity_definition(entity_uuid) - if definition and not definition.get('is_base_knowledge', False): - knowledge_items.append(self._create_knowledge_item( - entity_name=entity['name'], - type_='定义', - content=definition['content'], - confidence=definition['confidence'], - priority=1 - )) - - # 添加相关信息 - related_infos = self.db.get_entity_related_info(entity_uuid) - for info in related_infos[:3]: - knowledge_items.append(self._create_knowledge_item( - entity_name=entity['name'], - type_=info['type'], - content=info['content'], - confidence=info['confidence'], - priority=2 - )) - - # 如果启用DeepAgents,使用智能检索增强结果 - if use_deep and self.deep_knowledge and entities_found: - try: - deep_result = self.deep_knowledge.retrieve_knowledge(query) - if deep_result.get('success'): - # 将DeepAgents检索的知识添加到结果中 - debug_logger.log_info('EnhancedKnowledgeBase', - 'DeepAgents知识检索完成,增强结果') - except Exception as e: - debug_logger.log_error('EnhancedKnowledgeBase', - f'DeepAgents知识检索失败: {str(e)}', e) - - return { - 'query': query, - 'entities_found': entities_found, - 'knowledge_items': knowledge_items[:max_items], - 'base_knowledge_items': base_knowledge_items, - 'summary': f'找到{len(entities_found)}个相关主体,{len(knowledge_items)}条知识。' - } - - def extract_entities_from_query(self, query: str) -> List[str]: - """ - 从查询中提取实体(简单实现) - - Args: - query: 查询文本 - - Returns: - 实体列表 - """ - # 获取所有已知实体 - all_entities = self.db.get_all_entities() - - # 简单匹配:查找查询中出现的实体名称 - entities = [] - for entity in all_entities: - if entity['name'] in query: - entities.append(entity['name']) - - return entities diff --git a/src/core/event_manager.py b/src/core/event_manager.py index d9a2d0b..2c316a7 100644 --- a/src/core/event_manager.py +++ b/src/core/event_manager.py @@ -1,566 +1,32 @@ """ -事件管理模块 -为智能体提供事件驱动功能,支持通知型和任务型事件 -""" - -import uuid -from datetime import datetime -from typing import Dict, Any, List, Optional -from enum import Enum -from src.core.database_manager import DatabaseManager -from src.tools.debug_logger import get_debug_logger - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class EventType(Enum): - """事件类型枚举""" - NOTIFICATION = "notification" # 通知型事件 - TASK = "task" # 任务型事件 - - -class EventPriority(Enum): - """事件优先级枚举""" - LOW = 1 - MEDIUM = 2 - HIGH = 3 - URGENT = 4 - - -class EventStatus(Enum): - """事件状态枚举""" - PENDING = "pending" # 待处理 - PROCESSING = "processing" # 处理中 - COMPLETED = "completed" # 已完成 - FAILED = "failed" # 失败 - CANCELLED = "cancelled" # 已取消 - - -class Event: - """ - 事件基类 - """ - - def __init__( - self, - event_id: str = None, - title: str = "", - description: str = "", - event_type: EventType = EventType.NOTIFICATION, - priority: EventPriority = EventPriority.MEDIUM, - created_at: str = None, - status: EventStatus = EventStatus.PENDING, - metadata: Dict[str, Any] = None - ): - """ - 初始化事件 - - Args: - event_id: 事件唯一标识符 - title: 事件标题 - description: 事件描述 - event_type: 事件类型 - priority: 事件优先级 - created_at: 创建时间 - status: 事件状态 - metadata: 附加元数据 - """ - self.event_id = event_id or str(uuid.uuid4()) - self.title = title - self.description = description - self.event_type = event_type - self.priority = priority - self.created_at = created_at or datetime.now().isoformat() - self.status = status - self.metadata = metadata or {} - - def to_dict(self) -> Dict[str, Any]: - """ - 将事件转换为字典 - - Returns: - 事件字典表示 - """ - return { - 'event_id': self.event_id, - 'title': self.title, - 'description': self.description, - 'event_type': self.event_type.value, - 'priority': self.priority.value, - 'created_at': self.created_at, - 'status': self.status.value, - 'metadata': self.metadata - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Event': - """ - 从字典创建事件 - - Args: - data: 事件字典数据 - - Returns: - 事件对象 - """ - return cls( - event_id=data.get('event_id'), - title=data.get('title', ''), - description=data.get('description', ''), - event_type=EventType(data.get('event_type', 'notification')), - priority=EventPriority(data.get('priority', 2)), - created_at=data.get('created_at'), - status=EventStatus(data.get('status', 'pending')), - metadata=data.get('metadata', {}) - ) - - -class NotificationEvent(Event): - """ - 通知型事件 - 智能体需要立即理解并向用户说明的外部信息 - """ - - def __init__(self, **kwargs): - """初始化通知型事件""" - super().__init__(event_type=EventType.NOTIFICATION, **kwargs) - - -class TaskEvent(Event): - """ - 任务型事件 - 智能体需要理解任务要求、规划并完成任务 - """ - - def __init__( - self, - task_requirements: str = "", - completion_criteria: str = "", - **kwargs - ): - """ - 初始化任务型事件 - - Args: - task_requirements: 任务要求描述 - completion_criteria: 任务完成标准 - **kwargs: 其他事件参数 - """ - super().__init__(event_type=EventType.TASK, **kwargs) - self.metadata['task_requirements'] = task_requirements - self.metadata['completion_criteria'] = completion_criteria - self.metadata['subtasks'] = [] # 子任务列表 - self.metadata['progress'] = [] # 进度记录 - - -class EventManager: - """ - 事件管理器 - 负责事件的创建、存储、检索和处理 - """ - - def __init__(self, db_manager: DatabaseManager = None): - """ - 初始化事件管理器 - - Args: - db_manager: 数据库管理器实例 - """ - self.db = db_manager or DatabaseManager() - self._initialize_database() - - debug_logger.log_module('EventManager', '事件管理器初始化完成') - - def _initialize_database(self): - """初始化数据库表""" - with self.db.get_connection() as conn: - # 创建事件表 - conn.execute(''' - CREATE TABLE IF NOT EXISTS events ( - event_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT, - event_type TEXT NOT NULL, - priority INTEGER NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT, - completed_at TEXT, - metadata TEXT - ) - ''') - - # 创建事件处理日志表 - conn.execute(''' - CREATE TABLE IF NOT EXISTS event_logs ( - log_id INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL, - log_type TEXT NOT NULL, - log_content TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (event_id) REFERENCES events(event_id) - ) - ''') - - debug_logger.log_info('EventManager', '数据库表初始化完成') - - def create_event( - self, - title: str, - description: str, - event_type: EventType, - priority: EventPriority = EventPriority.MEDIUM, - task_requirements: str = "", - completion_criteria: str = "" - ) -> Event: - """ - 创建新事件 - - Args: - title: 事件标题 - description: 事件描述 - event_type: 事件类型 - priority: 事件优先级 - task_requirements: 任务要求(仅任务型事件) - completion_criteria: 完成标准(仅任务型事件) - - Returns: - 创建的事件对象 - """ - debug_logger.log_module('EventManager', '创建新事件', { - 'title': title, - 'type': event_type.value - }) - - if event_type == EventType.TASK: - event = TaskEvent( - title=title, - description=description, - priority=priority, - task_requirements=task_requirements, - completion_criteria=completion_criteria - ) - else: - event = NotificationEvent( - title=title, - description=description, - priority=priority - ) - - # 保存到数据库 - import json - try: - with self.db.get_connection() as conn: - conn.execute(''' - INSERT INTO events ( - event_id, title, description, event_type, - priority, status, created_at, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - event.event_id, - event.title, - event.description, - event.event_type.value, - event.priority.value, - event.status.value, - event.created_at, - json.dumps(event.metadata, ensure_ascii=False) - )) - - debug_logger.log_info('EventManager', '事件创建成功', { - 'event_id': event.event_id - }) - - return event - - except Exception as e: - debug_logger.log_error('EventManager', f'创建事件失败: {str(e)}', e) - raise RuntimeError(f"Failed to create event: {str(e)}") from e - - def get_event(self, event_id: str) -> Optional[Event]: - """ - 获取指定事件 - - Args: - event_id: 事件ID - - Returns: - 事件对象,不存在时返回None - """ - with self.db.get_connection() as conn: - cursor = conn.execute( - 'SELECT * FROM events WHERE event_id = ?', - (event_id,) - ) - row = cursor.fetchone() +Event Manager - v3.1.0 兼容层 - if row: - import json - data = { - 'event_id': row[0], - 'title': row[1], - 'description': row[2], - 'event_type': row[3], - 'priority': row[4], - 'status': row[5], - 'created_at': row[6], - 'metadata': json.loads(row[9]) if row[9] else {} - } - return Event.from_dict(data) - - return None - - def get_pending_events(self, limit: int = 10) -> List[Event]: - """ - 获取待处理的事件列表(按优先级和创建时间排序) - - Args: - limit: 返回数量限制 - - Returns: - 事件列表 - """ - with self.db.get_connection() as conn: - cursor = conn.execute(''' - SELECT * FROM events - WHERE status = ? - ORDER BY priority DESC, created_at ASC - LIMIT ? - ''', (EventStatus.PENDING.value, limit)) - - events = [] - import json - for row in cursor.fetchall(): - data = { - 'event_id': row[0], - 'title': row[1], - 'description': row[2], - 'event_type': row[3], - 'priority': row[4], - 'status': row[5], - 'created_at': row[6], - 'metadata': json.loads(row[9]) if row[9] else {} - } - events.append(Event.from_dict(data)) - - return events - - def get_all_events( - self, - status: Optional[EventStatus] = None, - event_type: Optional[EventType] = None, - limit: int = 100 - ) -> List[Event]: - """ - 获取所有事件(可选过滤) - - Args: - status: 状态过滤 - event_type: 类型过滤 - limit: 返回数量限制 - - Returns: - 事件列表 - """ - query = 'SELECT * FROM events WHERE 1=1' - params = [] - - if status: - query += ' AND status = ?' - params.append(status.value) - - if event_type: - query += ' AND event_type = ?' - params.append(event_type.value) - - query += ' ORDER BY created_at DESC LIMIT ?' - params.append(limit) - - with self.db.get_connection() as conn: - cursor = conn.execute(query, params) - - events = [] - import json - for row in cursor.fetchall(): - data = { - 'event_id': row[0], - 'title': row[1], - 'description': row[2], - 'event_type': row[3], - 'priority': row[4], - 'status': row[5], - 'created_at': row[6], - 'metadata': json.loads(row[9]) if row[9] else {} - } - events.append(Event.from_dict(data)) - - return events - - def update_event_status( - self, - event_id: str, - status: EventStatus, - log_message: str = "" - ) -> bool: - """ - 更新事件状态 - - Args: - event_id: 事件ID - status: 新状态 - log_message: 日志消息 - - Returns: - 是否成功 - """ - try: - now = datetime.now().isoformat() - - with self.db.get_connection() as conn: - # 更新事件状态 - conn.execute(''' - UPDATE events - SET status = ?, updated_at = ? - WHERE event_id = ? - ''', (status.value, now, event_id)) - - # 如果是完成状态,记录完成时间 - if status == EventStatus.COMPLETED: - conn.execute(''' - UPDATE events - SET completed_at = ? - WHERE event_id = ? - ''', (now, event_id)) - - # 添加日志 - if log_message: - self.add_event_log(event_id, 'status_change', log_message) - - debug_logger.log_info('EventManager', '事件状态更新', { - 'event_id': event_id, - 'new_status': status.value - }) - - return True - - except Exception as e: - debug_logger.log_error('EventManager', f'更新事件状态失败: {str(e)}', e) - return False - - def add_event_log( - self, - event_id: str, - log_type: str, - log_content: str - ): - """ - 添加事件处理日志 - - Args: - event_id: 事件ID - log_type: 日志类型 - log_content: 日志内容 - """ - with self.db.get_connection() as conn: - conn.execute(''' - INSERT INTO event_logs (event_id, log_type, log_content, created_at) - VALUES (?, ?, ?, ?) - ''', (event_id, log_type, log_content, datetime.now().isoformat())) - - def get_event_logs(self, event_id: str) -> List[Dict[str, Any]]: - """ - 获取事件处理日志 - - Args: - event_id: 事件ID - - Returns: - 日志列表 - """ - with self.db.get_connection() as conn: - cursor = conn.execute(''' - SELECT log_type, log_content, created_at - FROM event_logs - WHERE event_id = ? - ORDER BY created_at ASC - ''', (event_id,)) - - logs = [] - for row in cursor.fetchall(): - logs.append({ - 'log_type': row[0], - 'log_content': row[1], - 'created_at': row[2] - }) - - return logs - - def delete_event(self, event_id: str) -> bool: - """ - 删除事件 - - Args: - event_id: 事件ID - - Returns: - 是否成功 - """ - try: - with self.db.get_connection() as conn: - # 删除事件日志 - conn.execute( - 'DELETE FROM event_logs WHERE event_id = ?', - (event_id,) - ) - - # 删除事件 - conn.execute( - 'DELETE FROM events WHERE event_id = ?', - (event_id,) - ) - - debug_logger.log_info('EventManager', '事件删除成功', { - 'event_id': event_id - }) - - return True - - except Exception as e: - debug_logger.log_error('EventManager', f'删除事件失败: {str(e)}', e) - return False - - def get_statistics(self) -> Dict[str, Any]: - """ - 获取事件统计信息 - - Returns: - 统计信息字典 - """ - with self.db.get_connection() as conn: - cursor = conn.execute(''' - SELECT - COUNT(*) as total, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed, - SUM(CASE WHEN event_type = ? THEN 1 ELSE 0 END) as notifications, - SUM(CASE WHEN event_type = ? THEN 1 ELSE 0 END) as tasks - FROM events - ''', ( - EventStatus.PENDING.value, - EventStatus.PROCESSING.value, - EventStatus.COMPLETED.value, - EventType.NOTIFICATION.value, - EventType.TASK.value - )) +v4.0 重构说明: +- 真实实现已迁移到 src.prefrontal.event.event_manager +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.prefrontal.event.event_manager +""" - row = cursor.fetchone() - return { - 'total_events': row[0] or 0, - 'pending': row[1] or 0, - 'processing': row[2] or 0, - 'completed': row[3] or 0, - 'notifications': row[4] or 0, - 'tasks': row[5] or 0 - } +from __future__ import annotations + +from src.prefrontal.event.event_manager import ( + Event, + EventManager, + EventPriority, + EventStatus, + EventType, + NotificationEvent, + TaskEvent, + get_event_manager, +) + +__all__ = [ + "Event", + "NotificationEvent", + "TaskEvent", + "EventType", + "EventPriority", + "EventStatus", + "EventManager", + "get_event_manager", +] diff --git a/src/core/knowledge_base.py b/src/core/knowledge_base.py index a4b9a5f..856b3d7 100644 --- a/src/core/knowledge_base.py +++ b/src/core/knowledge_base.py @@ -1,847 +1,4 @@ -""" -知识库管理模块 -负责从对话中提取和管理知识性记忆 -实现主体-定义-信息的三层绑定结构,支持冲突检测和置信度管理 -""" +# Compatibility shim: src.core.knowledge_base has moved to src.limbic.hippocampus.knowledge_store (v4.0) +from src.limbic.hippocampus.knowledge_store import KnowledgeBase -import os -import json -import uuid -from datetime import datetime -from typing import List, Dict, Any, Optional -from dotenv import load_dotenv -import requests -from src.core.database_manager import DatabaseManager -from src.core.base_knowledge import BaseKnowledge -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class KnowledgeBase: - """ - 知识库管理器 - 每5轮对话提取一次知识,形成结构化的知识性记忆 - 使用数据库替代JSON文件存储 - - 数据结构: - - entities: 主体字典 {entity_uuid: entity_data} - - 每个主体有唯一UUID - - 每个主体只能有一个定义(definition) - - 每个主体可以有多个相关信息(related_info) - """ - - def __init__(self, - db_manager: DatabaseManager = None, - api_key: str = None, - api_url: str = None, - model_name: str = None): - """ - 初始化知识库管理器(使用LangChain架构) - - Args: - db_manager: 数据库管理器实例(如果为None则创建新实例) - api_key: API密钥(保留用于向后兼容,实际不使用) - api_url: API地址(保留用于向后兼容,实际不使用) - model_name: 模型名称(保留用于向后兼容,实际不使用) - """ - # 使用共享的数据库管理器 - self.db = db_manager or DatabaseManager() - - # 初始化基础知识库(共享数据库管理器) - self.base_knowledge = BaseKnowledge(db_manager=self.db) - - # 检查是否需要从JSON迁移数据 - if os.path.exists('knowledge_base.json'): - print("○ 检测到旧的知识库JSON文件,正在迁移到数据库...") - self.db.migrate_from_json('knowledge_base.json', 'knowledge_base') - os.rename('knowledge_base.json', 'knowledge_base.json.bak') - print("✓ JSON文件已备份为 knowledge_base.json.bak") - - print(f"✓ 知识库已初始化(使用数据库存储,基于LangChain)") - - - - def _find_or_create_entity(self, entity_name: str) -> str: - """ - 查找或创建主体(使用数据库) - - Args: - entity_name: 主体名称 - - Returns: - 主体UUID - """ - return self.db.find_or_create_entity(entity_name) - - def _add_or_update_entity_definition(self, - entity_name: str, - definition: str, - knowledge_type: str = '定义', - source: str = '', - created_at: str = None, - confidence: float = 1.0) -> str: - """ - 添加或更新主体的定义(每个主体只能有一个定义,新定义会覆盖旧定义) - 但基础知识库中的定义拥有最高优先级,不会被覆盖 - 使用数据库存储 - - Args: - entity_name: 主体名称 - definition: 定义内容 - knowledge_type: 知识类型 - source: 来源 - created_at: 创建时间(用于迁移旧数据) - confidence: 置信度(0-1之间,定义的置信度高于相关信息) - - Returns: - 主体UUID - """ - # 检查是否与基础知识冲突 - if self.base_knowledge.check_conflict_with_base(entity_name, definition): - print(f"⚠ 由于与基础知识冲突,拒绝添加/更新定义: {entity_name}") - print(f" → 保持基础知识不变") - - # 如果实体不存在,创建它但使用基础知识作为定义 - base_fact = self.base_knowledge.get_base_fact(entity_name) - if base_fact: - entity_uuid = self._find_or_create_entity(entity_name) - - # 使用基础知识作为定义(存入数据库) - existing_def = self.db.get_entity_definition(entity_uuid) - if not existing_def: - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=base_fact['content'], - type_='基础知识', - source='基础知识库', - confidence=1.0, - priority=100, - is_base_knowledge=True - ) - return entity_uuid - - # 查找或创建实体 - entity_uuid = self._find_or_create_entity(entity_name) - - # 检查当前定义是否为基础知识 - existing_def = self.db.get_entity_definition(entity_uuid) - if existing_def and existing_def.get('is_base_knowledge'): - print(f"⚠ 主体 '{entity_name}' 的定义来自基础知识库,不可更改") - print(f" 基础知识: {existing_def['content']}") - print(f" 尝试更新为: {definition}") - print(f" → 保持基础知识不变(优先级100)") - return entity_uuid - - # 检查是否存在旧定义 - if existing_def: - print(f"⚠ 主体 '{entity_name}' 的定义发生冲突:") - print(f" 旧定义: {existing_def.get('content', '')[:50]}...") - print(f" 新定义: {definition[:50]}...") - print(f" → 采用新定义(置信度: {confidence})") - - # 设置新定义(会覆盖旧定义) - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=definition, - type_=knowledge_type, - source=source, - confidence=confidence, - priority=50, - is_base_knowledge=False - ) - - return entity_uuid - - def add_related_info_to_entity(self, - entity_uuid: str = None, - entity_name: str = None, - info_content: str = '', - info_type: str = '相关信息', - source: str = '', - confidence: float = 0.8, - status: str = '疑似') -> Optional[str]: - """ - 为主体添加相关信息(不限数量,使用数据库) - 新添加的信息默认标记为"疑似"状态 - - Args: - entity_uuid: 主体UUID(优先使用) - entity_name: 主体名称(如果没有UUID则使用名称查找) - info_content: 信息内容 - info_type: 信息类型 - source: 来源 - confidence: 置信度(相关信息的置信度低于定义) - status: 状态(疑似/确认),默认为"疑似" - - Returns: - 信息UUID,失败返回None - """ - # 确定主体UUID - if entity_uuid is None and entity_name is None: - print("✗ 必须提供entity_uuid或entity_name") - return None - - if entity_uuid is None: - entity_uuid = self._find_or_create_entity(entity_name) - - # 验证实体是否存在 - entity = self.db.get_entity_by_uuid(entity_uuid) - if not entity: - print(f"✗ 主体UUID {entity_uuid} 不存在") - return None - - # 添加相关信息到数据库(包含状态字段) - info_uuid = self.db.add_entity_related_info( - entity_uuid=entity_uuid, - content=info_content, - type_=info_type, - source=source, - confidence=confidence, - status=status - ) - - return info_uuid - - def get_relevant_knowledge_for_query(self, query: str, max_items: int = 10) -> Dict[str, Any]: - """ - 根据用户查询获取相关知识(理解阶段使用) - 按优先级返回:基础知识(最高优先级100) > 定义(高置信度) > 相关信息(中置信度) - 使用数据库查询 - - Args: - query: 用户查询 - max_items: 最多返回的知识条目数 - - Returns: - 包含实体和相关知识的字典 - """ - debug_logger.log_module('KnowledgeBase', '开始检索相关知识', f'查询: {query}') - - # 1. 提取查询中的主体 - entities = self.extract_entities_from_query(query) - - if not entities: - debug_logger.log_info('KnowledgeBase', '未识别到实体') - return { - 'query': query, - 'entities_found': [], - 'knowledge_items': [], - 'base_knowledge_items': [], - 'summary': '未在查询中识别到相关主体。' - } - - # 2. 对每个主体,查找对应的知识 - knowledge_items = [] - base_knowledge_items = [] - entities_found = [] - - for entity_name in entities: - debug_logger.log_info('KnowledgeBase', f'查找实体: {entity_name}') - - # 首先检查基础知识库(最高优先级) - base_fact = self.base_knowledge.get_base_fact(entity_name) - if base_fact: - debug_logger.log_info('KnowledgeBase', f'找到基础知识: {entity_name}', { - 'content': base_fact['content'] - }) - base_knowledge_items.append({ - 'entity_name': entity_name, - 'type': '基础知识', - 'content': base_fact['content'], - 'confidence': 1.0, - 'priority': 0, - 'is_base_knowledge': True, - 'created_at': base_fact['created_at'] - }) - entities_found.append(entity_name) - else: - debug_logger.log_info('KnowledgeBase', f'未找到基础知识: {entity_name}') - - # 查找主体是否存在于普通知识库(数据库) - entity = self.db.get_entity_by_name(entity_name) - if entity: - entity_uuid = entity['uuid'] - - if entity_name not in entities_found: - entities_found.append(entity_name) - - # 添加定义(次优先级) - definition = self.db.get_entity_definition(entity_uuid) - if definition and not definition.get('is_base_knowledge', False): - knowledge_items.append({ - 'entity_name': entity['name'], - 'type': '定义', - 'content': definition['content'], - 'confidence': definition['confidence'], - 'priority': 1, - 'created_at': definition['created_at'] - }) - - # 添加相关信息(第三优先级,优先确认状态的知识) - related_infos = self.db.get_entity_related_info(entity_uuid) - - # 分离确认和疑似的知识 - confirmed = [i for i in related_infos if i.get('status') == DatabaseManager.STATUS_CONFIRMED] - suspected = [i for i in related_infos if i.get('status') != DatabaseManager.STATUS_CONFIRMED] - - # 确认的按时间倒序,疑似的也按时间倒序 - confirmed.sort(key=lambda x: x.get('created_at', ''), reverse=True) - suspected.sort(key=lambda x: x.get('created_at', ''), reverse=True) - - # 合并:确认的在前,疑似的在后,取前3条 - related_infos_sorted = confirmed + suspected - - for info in related_infos_sorted[:3]: - knowledge_items.append({ - 'entity_name': entity['name'], - 'type': info['type'], - 'content': info['content'], - 'confidence': info['confidence'], - 'status': info.get('status', '疑似'), - 'mention_count': info.get('mention_count', 1), - 'priority': 2, - 'created_at': info['created_at'] - }) - - # 3. 合并基础知识和普通知识,按优先级和置信度排序 - all_knowledge = base_knowledge_items + knowledge_items - all_knowledge.sort(key=lambda x: (x['priority'], -x['confidence'])) - - # 4. 限制返回数量 - all_knowledge = all_knowledge[:max_items] - - # 5. 生成摘要 - summary = self._generate_knowledge_summary(entities_found, all_knowledge) - - debug_logger.log_info('KnowledgeBase', '知识检索完成', { - 'entities_found': entities_found, - 'base_knowledge_count': len(base_knowledge_items), - 'normal_knowledge_count': len(knowledge_items), - 'total_knowledge': len(all_knowledge) - }) - - return { - 'query': query, - 'entities_found': entities_found, - 'knowledge_items': knowledge_items, - 'base_knowledge_items': base_knowledge_items, - 'all_knowledge': all_knowledge, - 'summary': summary - } - - def _generate_knowledge_summary(self, entities: List[str], knowledge_items: List[Dict]) -> str: - """ - 生成知识摘要文本 - - Args: - entities: 识别到的实体列表 - knowledge_items: 知识项列表 - - Returns: - 摘要文本 - """ - if not entities: - return '未找到相关主体。' - - if not knowledge_items: - return f'识别到主体:{", ".join(entities)},但知识库中暂无相关信息。' - - # 按主体分组 - by_entity = {} - for item in knowledge_items: - entity_name = item['entity_name'] - if entity_name not in by_entity: - by_entity[entity_name] = [] - by_entity[entity_name].append(item) - - summary_parts = [f'识别到 {len(entities)} 个相关主体:{", ".join(entities)}。'] - summary_parts.append('\n相关知识:') - - for entity_name, items in by_entity.items(): - definitions = [i for i in items if i['type'] == '定义'] - others = [i for i in items if i['type'] != '定义'] - - if definitions: - summary_parts.append(f'\n• {entity_name}: {definitions[0]["content"]}') - - if others: - for item in others[:2]: # 最多显示2条相关信息 - summary_parts.append(f' - {item["type"]}: {item["content"][:50]}...') - - return ''.join(summary_parts) - - def add_knowledge(self, knowledge_data: Dict[str, Any], source_messages: List[Dict[str, Any]]) -> str: - """ - 添加一条知识到知识库(新版:支持主体-定义-信息结构,使用数据库) - - Args: - knowledge_data: 知识数据(包含entity_name, is_definition, content, type等) - source_messages: 来源消息列表 - - Returns: - 知识的UUID(主体UUID或信息UUID) - """ - entity_name = knowledge_data.get('entity_name', '未知主体') - is_definition = knowledge_data.get('is_definition', False) - content = knowledge_data.get('content', '') - knowledge_type = knowledge_data.get('type', '其他') - source = knowledge_data.get('source', '对话记录') - confidence = knowledge_data.get('confidence', 0.9 if is_definition else 0.8) - - if is_definition: - # 添加或更新定义 - entity_uuid = self._add_or_update_entity_definition( - entity_name=entity_name, - definition=content, - knowledge_type=knowledge_type, - source=source, - confidence=confidence - ) - result_uuid = entity_uuid - else: - # 添加相关信息 - info_uuid = self.add_related_info_to_entity( - entity_name=entity_name, - info_content=content, - info_type=knowledge_type, - source=source, - confidence=confidence - ) - result_uuid = info_uuid - - return result_uuid - - def extract_knowledge(self, messages: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]: - """ - 从最近的对话中提取知识 - 只从用户的语句中提取信息,不从助手的回复中提取 - - Args: - messages: 最近5轮的对话消息列表 - - Returns: - 提取的知识列表,每个知识包含entity(主体)、is_definition(是否为定义)、content等 - """ - try: - # 只提取用户消息 - user_messages = [msg for msg in messages if msg.get('role') == 'user'] - - if not user_messages: - print("○ 没有用户消息,无法提取知识") - return None - - # 构建用户对话文本 - user_text = "" - for msg in user_messages: - user_text += f"用户: {msg['content']}\n" - - # 构建知识提取请求(新版提示词,只关注用户的陈述) - extraction_prompt = f"""请从以下用户的语句中提取关键信息和知识点。 - -重要要求: -1. **只提取用户明确陈述的信息**,不要推断或假设 -2. 识别用户提到的**主体**(entity):如人名、物品名、概念名等 -3. 对每个主体,区分以下两类信息: - - **定义**(definition):主体的核心定义、本质属性(如"是什么") - - **相关信息**(related_info):主体的其他属性、特征、用途、偏好等(不限数量) -4. 每个主体只应有一个定义,如果用户多次提到冲突的定义,以最新的为准 -5. 知识类型包括:个人信息、偏好、事实、经历、观点、定义等 -6. 只提取明确的、有价值的信息,避免重复和模糊的内容 -7. 如果没有值得记录的知识,返回空列表 - -返回JSON格式(只返回JSON数组,不要其他文字): -[ - {{ - "entity_name": "主体名称", - "is_definition": true, - "content": "定义内容或相关信息内容", - "type": "知识类型", - "source": "用户陈述", - "confidence": 0.9 - }} -] - -用户语句: -{user_text} - -请提取知识点(只返回JSON数组):""" - - from src.core.llm_helper import LLMHelper - - system_prompt = '你是一个专业的知识提取助手,擅长从用户的陈述中识别主体、定义和相关信息。你只从用户明确说出的内容中提取信息,不进行推断。你只返回JSON格式的数据,不添加任何解释。' - - # 使用工具模型进行知识提取 - content = LLMHelper.call_tool_model( - system_prompt=system_prompt, - user_message=extraction_prompt, - temperature=0.3, - max_tokens=1500 - ) - - content = content.strip() - - # 尝试解析JSON - # 清理可能的markdown代码块标记 - if content.startswith('```'): - content = content.split('```')[1] - if content.startswith('json'): - content = content[4:] - content = content.strip() - - try: - knowledge_list = json.loads(content) - if isinstance(knowledge_list, list): - return knowledge_list - else: - print(f"✗ 返回的不是列表格式: {type(knowledge_list)}") - debug_logger.log_error('KnowledgeBase', f'返回的不是列表格式: {type(knowledge_list)}') - return None - except json.JSONDecodeError as e: - print(f"✗ JSON解析失败: {e}") - print(f"原始内容: {content[:200]}...") - debug_logger.log_error('KnowledgeBase', 'JSON解析失败', e) - debug_logger.log_info('KnowledgeBase', '原始内容', {'content': content[:500]}) - return None - - except Exception as e: - print(f"✗ 提取知识时出错: {e}") - return None - - def extract_entities_from_query(self, query: str) -> List[str]: - """ - 从用户查询中提取相关主体 - - Args: - query: 用户输入的查询文本 - - Returns: - 提取到的主体名称列表 - """ - try: - # 构建实体提取请求 - extraction_prompt = f"""请从以下用户输入中提取所有可能相关的主体(实体)名称。 - -主体可以是:人名、物品名、概念名、地点名、事件名等。 - -用户输入: -{query} - -请以JSON数组格式返回主体名称列表(只返回JSON,不要其他文字): -["主体1", "主体2", ...] - -如果没有明确的主体,返回空数组 []""" - - from src.core.llm_helper import LLMHelper - - system_prompt = '你是一个专业的实体识别助手,只返回JSON格式数据。' - - # 使用工具模型进行实体提取 - content = LLMHelper.call_tool_model( - system_prompt=system_prompt, - user_message=extraction_prompt, - temperature=0.2, - max_tokens=500 - ) - - content = content.strip() - - # 清理markdown代码块 - if content.startswith('```'): - content = content.split('```')[1] - if content.startswith('json'): - content = content[4:] - content = content.strip() - - try: - entities = json.loads(content) - if isinstance(entities, list): - result = [e for e in entities if isinstance(e, str)] - debug_logger.log_info('KnowledgeBase', '实体提取成功', { - 'query': query, - 'entities': result - }) - return result - else: - debug_logger.log_info('KnowledgeBase', '实体提取结果不是列表', {'content': content}) - return [] - except json.JSONDecodeError as e: - print(f"✗ 实体提取JSON解析失败") - debug_logger.log_error('KnowledgeBase', 'JSON解析失败', e) - debug_logger.log_info('KnowledgeBase', '无法解析的内容', {'content': content[:500]}) - print(f"调试信息 - 原始内容: {content[:200]}...") - return [] - - except Exception as e: - print(f"✗ 提取实体时出错: {e}") - debug_logger.log_error('KnowledgeBase', '提取实体时出错', e) - return [] - - def search_knowledge( - self, - keyword: str = None, - knowledge_type: str = None, - entity_name: str = None - ) -> List[Dict[str, Any]]: - """ - 搜索知识库 - - Args: - keyword: 关键词,用于搜索知识内容 - knowledge_type: 知识类型,如"定义"、"特征"等 - entity_name: 主体名称,用于筛选特定主体的知识 - - Returns: - 匹配的知识列表(按置信度排序) - """ - results = self.get_all_knowledge(sort_by_confidence=True) - - # 按主体名称筛选 - if entity_name: - entity_lower = entity_name.lower() - results = [ - k for k in results - if entity_lower in k.get('entity_name', '').lower() - ] - - # 按关键词筛选 - if keyword: - keyword_lower = keyword.lower() - results = [ - k for k in results - if keyword_lower in k.get('title', '').lower() or - keyword_lower in k.get('content', '').lower() or - keyword_lower in k.get('entity_name', '').lower() - ] - - # 按类型筛选 - if knowledge_type: - results = [k for k in results if k.get('type', '') == knowledge_type] - - return results - - def get_all_knowledge(self, sort_by_confidence: bool = True) -> List[Dict[str, Any]]: - """ - 获取所有知识(新版:返回结构化的主体-定义-信息列表,从数据库) - - Args: - sort_by_confidence: 是否按置信度排序(定义优先于相关信息) - - Returns: - 知识列表,包含主体、定义、相关信息等 - """ - result = [] - - # 从数据库获取所有实体 - entities = self.db.get_all_entities() - - for entity in entities: - entity_uuid = entity['uuid'] - entity_name = entity['name'] - - # 获取并添加主体的定义 - definition = self.db.get_entity_definition(entity_uuid) - if definition: - result.append({ - 'uuid': entity_uuid, - 'entity_name': entity_name, - 'title': f"{entity_name}的定义", - 'content': definition['content'], - 'type': definition['type'], - 'source': definition.get('source', ''), - 'confidence': definition['confidence'], - 'is_definition': True, - 'created_at': definition['created_at'], - 'updated_at': definition.get('updated_at', definition['created_at']) - }) - - # 获取并添加主体的相关信息 - related_infos = self.db.get_entity_related_info(entity_uuid) - for info in related_infos: - result.append({ - 'uuid': info['uuid'], - 'entity_name': entity_name, - 'title': f"{entity_name}的{info['type']}", - 'content': info['content'], - 'type': info['type'], - 'source': info.get('source', ''), - 'confidence': info['confidence'], - 'status': info.get('status', '疑似'), - 'mention_count': info.get('mention_count', 1), - 'is_definition': False, - 'created_at': info['created_at'], - 'updated_at': info.get('created_at') - }) - - # 按置信度排序(定义优先) - if sort_by_confidence: - result.sort(key=lambda x: (x['confidence'], x['is_definition']), reverse=True) - - return result - - def get_knowledge_by_uuid(self, knowledge_uuid: str) -> Optional[Dict[str, Any]]: - """ - 根据UUID获取知识 - - Args: - knowledge_uuid: 知识UUID - - Returns: - 知识项,如果不存在返回None - """ - for item in self.knowledge_items: - if item.get('uuid') == knowledge_uuid: - return item - return None - - def get_base_knowledge_prompt(self) -> str: - """ - 获取基础知识提示词文本,用于嵌入到系统提示词中 - 这些知识具有最高优先级,AI必须严格遵循 - - Returns: - 基础知识提示词文本 - """ - return self.base_knowledge.generate_base_knowledge_prompt() - - def get_statistics(self) -> Dict[str, Any]: - """ - 获取知识库统计信息(从数据库) - - Returns: - 统计信息字典 - """ - # 从数据库获取所有知识 - all_knowledge = self.get_all_knowledge(sort_by_confidence=False) - - # 统计各类型知识数量 - type_counts = {} - definition_count = 0 - related_info_count = 0 - - for item in all_knowledge: - item_type = item.get('type', '其他') - type_counts[item_type] = type_counts.get(item_type, 0) + 1 - - if item.get('is_definition', False): - definition_count += 1 - else: - related_info_count += 1 - - # 统计置信度分布 - high_confidence = sum(1 for k in all_knowledge if k.get('confidence', 0) >= 0.9) - medium_confidence = sum(1 for k in all_knowledge if 0.7 <= k.get('confidence', 0) < 0.9) - low_confidence = sum(1 for k in all_knowledge if k.get('confidence', 0) < 0.7) - - # 获取基础知识库统计 - base_kb_stats = self.base_knowledge.get_statistics() - - # 获取数据库统计 - db_stats = self.db.get_statistics() - - # 统计知识状态分布(仅对相关信息) - status_counts = { - DatabaseManager.STATUS_SUSPECTED: 0, - DatabaseManager.STATUS_CONFIRMED: 0 - } - for item in all_knowledge: - if not item.get('is_definition', False): - status = item.get('status', DatabaseManager.STATUS_SUSPECTED) - status_counts[status] = status_counts.get(status, 0) + 1 - - return { - 'total_entities': db_stats['entities_count'], - 'total_definitions': definition_count, - 'total_related_info': related_info_count, - 'total_knowledge': len(all_knowledge), - 'base_knowledge_facts': base_kb_stats['total_facts'], - 'type_distribution': type_counts, - 'confidence_distribution': { - 'high (>=0.9)': high_confidence, - 'medium (0.7-0.9)': medium_confidence, - 'low (<0.7)': low_confidence - }, - 'status_distribution': status_counts, - 'database_size_kb': db_stats.get('db_size_kb', 0) - } - - def clear_knowledge(self): - """ - 清空知识库(清空数据库中的实体和知识) - """ - # 这里需要实现清空数据库实体的方法 - # 暂时只打印提示 - print("⚠ 清空知识库功能需要实现数据库级联删除") - print(" 建议手动删除数据库文件重新开始") - - def search_knowledge(self, keyword: str = None, knowledge_type: str = None, - entity_name: str = None) -> List[Dict[str, Any]]: - """ - 搜索知识库(使用数据库) - - Args: - keyword: 关键词 - knowledge_type: 知识类型 - entity_name: 主体名称 - - Returns: - 匹配的知识列表(按置信度排序) - """ - results = self.get_all_knowledge(sort_by_confidence=True) - - # 按主体名称筛选 - if entity_name: - entity_lower = entity_name.lower() - results = [ - k for k in results - if entity_lower in k.get('entity_name', '').lower() - ] - - # 按关键词筛选 - if keyword: - keyword_lower = keyword.lower() - results = [ - k for k in results - if keyword_lower in k.get('title', '').lower() or - keyword_lower in k.get('content', '').lower() or - keyword_lower in k.get('entity_name', '').lower() - ] - - # 按类型筛选 - if knowledge_type: - results = [k for k in results if k.get('type', '') == knowledge_type] - - return results - - -if __name__ == '__main__': - print("=" * 60) - print("知识库管理器测试") - print("=" * 60) - - kb = KnowledgeBase() - - print("\n当前知识库统计:") - stats = kb.get_statistics() - print(f"总实体数: {stats['total_entities']}") - print(f"总知识数: {stats['total_knowledge']}") - print(f"定义数: {stats['total_definitions']}") - print(f"相关信息数: {stats['total_related_info']}") - print(f"基础知识数: {stats['base_knowledge_facts']}") - print(f"类型分布: {stats['type_distribution']}") - print(f"数据库大小: {stats['database_size_kb']:.2f} KB") - - all_knowledge = kb.get_all_knowledge() - if all_knowledge: - print(f"\n知识列表 (前5条):") - for i, knowledge in enumerate(all_knowledge[:5], 1): - print(f"{i}. [{knowledge['type']}] {knowledge['title']}") - print(f" 内容: {knowledge['content'][:50]}...") - print(f" 置信度: {knowledge['confidence']:.2f}") - else: - print("\n知识库为空") - - print("\n✓ 测试完成") +__all__ = ["KnowledgeBase"] diff --git a/src/core/langchain_llm.py b/src/core/langchain_llm.py index 8fa8440..483477f 100644 --- a/src/core/langchain_llm.py +++ b/src/core/langchain_llm.py @@ -1,301 +1,15 @@ """ -LangChain LLM封装模块 -使用LangChain的ChatOpenAI兼容接口封装SiliconFlow API -支持多层模型架构 -""" - -import os -import time -from typing import List, Dict, Any, Optional -from langchain_openai import ChatOpenAI -from langchain_core.messages import HumanMessage, AIMessage, SystemMessage -from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder -from langchain_core.output_parsers import StrOutputParser -from langchain_core.runnables import RunnablePassthrough - -from src.core.model_config import ModelType, get_model_config -from src.tools.debug_logger import get_debug_logger - -# 获取debug日志记录器 -debug_logger = get_debug_logger() +LangChain LLM - v3.1.0 兼容层 +v4.0 重构说明: +- 真实实现已迁移到 src.cortex.llm_core +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.cortex.llm_core +""" -class LangChainLLM: - """ - 基于LangChain的LLM封装类 - 使用ChatOpenAI兼容接口访问SiliconFlow API - 支持多层模型架构 - """ - - def __init__(self, model_type: ModelType = ModelType.MAIN): - """ - 初始化LangChain LLM客户端 - - Args: - model_type: 模型类型(主模型、工具模型或多模态模型) - """ - self.model_type = model_type - self.config = get_model_config() - - # 获取API配置 - api_config = self.config.get_api_config() - self.api_key = api_config['api_key'] or 'placeholder-key' # 如果没有密钥,使用占位符 - self.api_url = api_config['api_url'] - - # 获取模型配置 - model_config = self.config.get_model_config(model_type) - self.model_name = model_config['name'] - self.temperature = model_config['temperature'] - self.max_tokens = model_config['max_tokens'] - - # 获取超时配置(根据模型类型使用不同的默认值) - # 主模型默认60秒(处理复杂推理任务) - # 工具模型默认45秒(处理工具调用和子任务) - # 多模态模型默认90秒(处理视觉推理任务) - timeout_defaults = { - ModelType.MAIN: 60, - ModelType.TOOL: 45, - ModelType.VISION: 90 - } - default_timeout = timeout_defaults.get(model_type, 45) - - # 从环境变量读取超时配置,支持针对每种模型类型单独配置 - timeout_env_keys = { - ModelType.MAIN: 'MAIN_MODEL_TIMEOUT', - ModelType.TOOL: 'TOOL_MODEL_TIMEOUT', - ModelType.VISION: 'VISION_MODEL_TIMEOUT' - } - timeout_env_key = timeout_env_keys.get(model_type, 'LLM_TIMEOUT') - self.timeout = int(os.getenv(timeout_env_key, str(default_timeout))) - - # 创建ChatOpenAI实例(兼容SiliconFlow API) - # SiliconFlow使用OpenAI兼容接口 - # 注意:base_url参数需要指向API根路径,不包括/chat/completions - base_url = self.api_url - if '/chat/completions' in base_url: - base_url = base_url.replace('/chat/completions', '') - - self.llm = ChatOpenAI( - model=self.model_name, - temperature=self.temperature, - max_tokens=self.max_tokens, - api_key=self.api_key, # 使用api_key参数而不是openai_api_key - base_url=base_url, # 使用base_url参数而不是openai_api_base - timeout=self.timeout - ) - - debug_logger.log_module('LangChainLLM', f'初始化{model_type.value}模型', { - 'model_name': self.model_name, - 'temperature': self.temperature, - 'max_tokens': self.max_tokens, - 'timeout': self.timeout - }) - - # 验证配置 - if not self.config.is_valid(): - print("警告: 未设置有效的API密钥,请在.env文件中配置SILICONFLOW_API_KEY") - - def _convert_messages_to_langchain(self, messages: List[Dict[str, str]]) -> List: - """ - 将标准消息格式转换为LangChain消息对象 - - Args: - messages: 消息列表,格式为 [{'role': 'user/assistant/system', 'content': '...'}] - - Returns: - LangChain消息对象列表 - """ - langchain_messages = [] - for msg in messages: - role = msg.get('role', 'user') - content = msg.get('content', '') - - if role == 'system': - langchain_messages.append(SystemMessage(content=content)) - elif role == 'assistant': - langchain_messages.append(AIMessage(content=content)) - else: # user - langchain_messages.append(HumanMessage(content=content)) - - return langchain_messages - - def chat(self, messages: List[Dict[str, str]]) -> str: - """ - 发送聊天请求 - - Args: - messages: 消息列表,格式为 [{'role': 'user/assistant/system', 'content': '...'}] - - Returns: - AI的回复内容 - """ - try: - # Debug: 记录请求前的信息 - debug_logger.log_module('LangChainLLM', f'准备发送{self.model_type.value}模型请求', { - 'message_count': len(messages), - 'model_name': self.model_name - }) - - # Debug: 记录所有消息 - for i, msg in enumerate(messages): - debug_logger.log_prompt( - 'LangChainLLM', - msg['role'], - msg['content'], - {'message_index': i, 'total_messages': len(messages), 'model_type': self.model_type.value} - ) - - # 转换消息格式 - langchain_messages = self._convert_messages_to_langchain(messages) - - # 调用LLM - start_time = time.time() - response = self.llm.invoke(langchain_messages) - elapsed_time = time.time() - start_time - - # 提取回复内容 - reply_content = response.content - - # Debug: 记录响应 - debug_logger.log_response('LangChainLLM', { - 'content': reply_content, - 'model': self.model_name, - 'model_type': self.model_type.value - }, 200, elapsed_time) - - debug_logger.log_info('LangChainLLM', '成功获取回复', { - 'reply_length': len(reply_content), - 'elapsed_time': elapsed_time, - 'model_type': self.model_type.value - }) - - return reply_content - - except Exception as e: - debug_logger.log_error('LangChainLLM', f'LLM调用错误: {str(e)}', e) - print(f"LLM调用错误: {e}") - return f"抱歉,处理请求时出现错误: {str(e)}" - - def chat_with_template( - self, - template: str, - variables: Dict[str, Any], - message_history: Optional[List[Dict[str, str]]] = None - ) -> str: - """ - 使用模板和变量进行聊天 - - Args: - template: 提示词模板 - variables: 模板变量字典 - message_history: 可选的消息历史 - - Returns: - AI的回复内容 - """ - try: - # 创建提示词模板 - if message_history: - # 如果有消息历史,使用MessagesPlaceholder - prompt = ChatPromptTemplate.from_messages([ - ("system", template), - MessagesPlaceholder(variable_name="history"), - ("human", "{input}") - ]) - - # 转换消息历史 - history_messages = self._convert_messages_to_langchain(message_history) - variables['history'] = history_messages - else: - # 没有消息历史,使用简单模板 - prompt = ChatPromptTemplate.from_messages([ - ("system", template), - ("human", "{input}") - ]) - - # 创建链 - chain = prompt | self.llm | StrOutputParser() - - # 执行链 - start_time = time.time() - response = chain.invoke(variables) - elapsed_time = time.time() - start_time - - debug_logger.log_info('LangChainLLM', '模板聊天完成', { - 'elapsed_time': elapsed_time, - 'model_type': self.model_type.value, - 'response_length': len(response) - }) - - return response - - except Exception as e: - debug_logger.log_error('LangChainLLM', f'模板聊天错误: {str(e)}', e) - return f"抱歉,处理请求时出现错误: {str(e)}" - - def get_model_info(self) -> Dict[str, Any]: - """ - 获取模型信息 - - Returns: - 包含模型信息的字典 - """ - return { - 'model_type': self.model_type.value, - 'model_name': self.model_name, - 'temperature': self.temperature, - 'max_tokens': self.max_tokens - } +from __future__ import annotations +from src.cortex.llm_core import LLMCore, LangChainLLM, ModelRouter +from src.cortex.config.model_config import ModelType -class ModelRouter: - """ - 模型路由器 - 根据任务类型自动选择合适的模型 - """ - - def __init__(self): - """初始化模型路由器""" - self.main_llm = LangChainLLM(ModelType.MAIN) - self.tool_llm = LangChainLLM(ModelType.TOOL) - self.vision_llm = LangChainLLM(ModelType.VISION) - - debug_logger.log_module('ModelRouter', '模型路由器初始化完成', { - 'main_model': self.main_llm.model_name, - 'tool_model': self.tool_llm.model_name, - 'vision_model': self.vision_llm.model_name - }) - - def route(self, task_type: str = 'main') -> LangChainLLM: - """ - 根据任务类型路由到合适的模型 - - Args: - task_type: 任务类型 ('main', 'tool', 'vision') - - Returns: - 对应的LLM实例 - """ - if task_type == 'tool': - return self.tool_llm - elif task_type == 'vision': - return self.vision_llm - else: - return self.main_llm - - def get_model_by_type(self, model_type: ModelType) -> LangChainLLM: - """ - 根据模型类型获取LLM实例 - - Args: - model_type: 模型类型枚举 - - Returns: - 对应的LLM实例 - """ - if model_type == ModelType.TOOL: - return self.tool_llm - elif model_type == ModelType.VISION: - return self.vision_llm - else: - return self.main_llm +__all__ = ["LangChainLLM", "ModelRouter", "LLMCore", "ModelType"] diff --git a/src/core/life_state.py b/src/core/life_state.py new file mode 100644 index 0000000..45bd374 --- /dev/null +++ b/src/core/life_state.py @@ -0,0 +1,20 @@ +""" +LifeState - v3.1.0 兼容层 + +v4.0 重构说明: +- 真实实现已迁移到 src.hypothalamus.state.life_state +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.hypothalamus.state.life_state +""" + +from __future__ import annotations + +from src.hypothalamus.state.life_state import ( + ENABLE_LIFE_STATE, + LifeStateManager, +) + +__all__ = [ + "ENABLE_LIFE_STATE", + "LifeStateManager", +] diff --git a/src/core/llm_providers.py b/src/core/llm_providers.py new file mode 100644 index 0000000..d5db526 --- /dev/null +++ b/src/core/llm_providers.py @@ -0,0 +1,35 @@ +""" +LLM Providers - v3.1.0 兼容层 + +v4.0 重构说明: +- 真实实现已迁移到 src.cortex.providers.registry +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.cortex.providers.registry +""" + +from __future__ import annotations + +# 从 v4.0 位置导入所有公共接口 +from src.cortex.providers.registry import ( + ConfigError, + LLMProvider, + PROVIDERS, + get_provider_obj, + get_provider_summary, + resolve_api_key, + resolve_base_url, + resolve_model_name, + resolve_provider, +) + +__all__ = [ + "ConfigError", + "LLMProvider", + "PROVIDERS", + "resolve_provider", + "resolve_api_key", + "resolve_base_url", + "resolve_model_name", + "get_provider_obj", + "get_provider_summary", +] diff --git a/src/core/long_term_memory.py b/src/core/long_term_memory.py index 44c0448..45c7c34 100644 --- a/src/core/long_term_memory.py +++ b/src/core/long_term_memory.py @@ -1,452 +1,8 @@ -""" -长效记忆管理模块 -实现分层记忆系统:短期记忆(最近20轮)+ 长期概括记忆 + 知识库 -使用数据库替代JSON文件存储 -""" - -import os -import json -import uuid -from datetime import datetime -from typing import List, Dict, Any, Optional -from dotenv import load_dotenv -import requests -from src.core.database_manager import DatabaseManager -from src.core.knowledge_base import KnowledgeBase - -load_dotenv() - - -class LongTermMemoryManager: - """ - 长效记忆管理器 - 负责管理短期详细记忆和长期概括记忆的分层存储 - 使用数据库替代JSON文件 - """ - - def __init__(self, - db_manager: DatabaseManager = None, - api_key: str = None, - api_url: str = None, - model_name: str = None): - """ - 初始化长效记忆管理器 - - Args: - db_manager: 数据库管理器实例(如果为None则创建新实例) - api_key: API密钥 - api_url: API地址 - model_name: 模型名称 - """ - # 使用共享的数据库管理器 - self.db = db_manager or DatabaseManager() - - # 短期记忆最大轮数(一轮 = 一对user+assistant消息) - self.max_short_term_rounds = 20 - self.max_short_term_messages = self.max_short_term_rounds * 2 # user + assistant - - # 知识提取间隔(每5轮) - self.knowledge_extraction_interval = 5 - - # API配置(用于生成概括) - self.api_key = api_key or os.getenv('SILICONFLOW_API_KEY') - self.api_url = api_url or os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - self.model_name = model_name or os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') - - # 初始化知识库(共享数据库管理器) - self.knowledge_base = KnowledgeBase( - db_manager=self.db, - api_key=self.api_key, - api_url=self.api_url, - model_name=self.model_name - ) - - # 检查是否需要从JSON迁移数据 - self._check_and_migrate_json() - - print(f"✓ 长效记忆管理器已初始化(使用数据库存储)") - - def _check_and_migrate_json(self): - """检查并迁移旧的JSON文件""" - # 迁移短期记忆 - short_term_file = os.getenv('MEMORY_FILE', 'memory_data.json') - if os.path.exists(short_term_file): - print(f"○ 检测到旧的短期记忆JSON文件,正在迁移...") - self.db.migrate_from_json(short_term_file, 'short_term') - os.rename(short_term_file, short_term_file + '.bak') - print(f"✓ 短期记忆已迁移,JSON文件已备份") - - # 迁移长期记忆 - long_term_file = 'longmemory_data.json' - if os.path.exists(long_term_file): - print(f"○ 检测到旧的长期记忆JSON文件,正在迁移...") - self.db.migrate_from_json(long_term_file, 'long_term') - os.rename(long_term_file, long_term_file + '.bak') - print(f"✓ 长期记忆已迁移,JSON文件已备份") - - def add_message(self, role: str, content: str): - """ - 添加新消息到短期记忆(使用数据库) - - Args: - role: 角色类型 ('user' 或 'assistant') - content: 消息内容 - """ - # 添加到数据库 - self.db.add_short_term_message(role, content) - - # 更新元数据 - if role == 'user': - total_conversations = self.db.get_metadata('total_conversations', 0) - total_conversations += 1 - self.db.set_metadata('total_conversations', total_conversations) - - # 检查是否需要提取知识(每5轮) - if total_conversations % self.knowledge_extraction_interval == 0: - print(f"\n📚 已达到 {total_conversations} 轮对话,开始提取知识...") - self._extract_and_save_knowledge() - - # 检查是否需要归档 - self._check_and_archive() - - def _check_and_archive(self): - """ - 检查短期记忆是否超过限制,如果超过则归档旧记忆 - """ - # 获取所有短期记忆 - messages = self.db.get_short_term_messages() - - # 计算当前对话轮数 - user_count = sum(1 for msg in messages if msg['role'] == 'user') - - # 如果超过20轮,将最早的20轮归档 - if user_count > self.max_short_term_rounds: - print(f"\n⚠ 短期记忆已达 {user_count} 轮,开始归档...") - self._archive_old_messages() - - def _archive_old_messages(self): - """ - 将最早的20轮对话归档为概括记忆 - """ - # 获取所有短期记忆消息 - all_messages = self.db.get_short_term_messages() - - # 找出前20轮对话(40条消息) - messages_to_archive = [] - message_ids_to_delete = [] - user_count = 0 - - for msg in all_messages: - messages_to_archive.append(msg) - message_ids_to_delete.append(msg['id']) - if msg['role'] == 'user': - user_count += 1 - if user_count >= self.max_short_term_rounds: - break - - # 生成概括 - summary = self._generate_summary(messages_to_archive) - - if summary: - # 保存到长期记忆(数据库) - self.db.add_long_term_summary( - summary=summary, - rounds=user_count, - message_count=len(messages_to_archive), - created_at=messages_to_archive[0]['timestamp'] if messages_to_archive else datetime.now().isoformat(), - ended_at=messages_to_archive[-1]['timestamp'] if messages_to_archive else datetime.now().isoformat() - ) - - # 从短期记忆中移除已归档的消息 - self.db.delete_short_term_messages(message_ids_to_delete) - - print(f"✓ 已归档 {user_count} 轮对话({len(messages_to_archive)} 条消息)") - print(f"✓ 生成主题概括: {summary[:50]}...") - - def _extract_and_save_knowledge(self): - """ - 从最近5轮对话中提取并保存知识 - 同时定期清理过时的知识 - """ - # 从数据库获取所有短期记忆 - all_messages = self.db.get_short_term_messages() - - # 获取最近5轮对话(10条消息) - recent_messages = [] - user_count = 0 - - for msg in reversed(all_messages): - recent_messages.insert(0, msg) - if msg['role'] == 'user': - user_count += 1 - if user_count >= 5: - break - - if len(recent_messages) < 2: # 至少需要一轮对话 - print("✗ 消息太少,无法提取知识") - return - - # 使用知识库提取知识 - knowledge_list = self.knowledge_base.extract_knowledge(recent_messages) - - if knowledge_list and len(knowledge_list) > 0: - print(f"✓ 提取到 {len(knowledge_list)} 条知识") - - # 保存每条知识 - for knowledge_data in knowledge_list: - entity_name = knowledge_data.get('entity_name', knowledge_data.get('title', '未知')) - is_def = knowledge_data.get('is_definition', False) - content = knowledge_data.get('content', '') - content_preview = content[:30] - print(f" • [{knowledge_data.get('type', '其他')}] {entity_name}{'的定义' if is_def else ''}: {content_preview}...") - - # 保存到数据库 - entity_uuid = self.db.find_or_create_entity(entity_name) - - if is_def: - # 保存为定义 - self.db.set_entity_definition( - entity_uuid=entity_uuid, - content=content, - type_=knowledge_data.get('type', '定义'), - source=knowledge_data.get('source', '对话提取'), - confidence=knowledge_data.get('confidence', 0.8) - ) - print(f" 置信度: {knowledge_data.get('confidence', 0.8):.2f} | 实体UUID: {entity_uuid}") - else: - # 保存为相关信息,默认状态为"疑似" - # add_entity_related_info 会检查是否已存在相同信息,如果存在会增加mention_count - from src.core.database_manager import DatabaseManager - info_uuid = self.db.add_entity_related_info( - entity_uuid=entity_uuid, - content=content, - type_=knowledge_data.get('type', '其他'), - source=knowledge_data.get('source', '对话提取'), - confidence=knowledge_data.get('confidence', 0.7), - status=DatabaseManager.STATUS_SUSPECTED - ) - - # 获取信息状态以显示 - info = self.db.get_entity_related_info(entity_uuid) - saved_info = next((i for i in info if i['uuid'] == info_uuid), None) - if saved_info: - status = saved_info.get('status', DatabaseManager.STATUS_SUSPECTED) - mention_count = saved_info.get('mention_count', 1) - status_label = f"[{status}]" if status == DatabaseManager.STATUS_CONFIRMED else f"[{status}×{mention_count}]" - print(f" 状态: {status_label} | 置信度: {knowledge_data.get('confidence', 0.7):.2f} | 实体UUID: {entity_uuid}") - else: - print(f" 置信度: {knowledge_data.get('confidence', 0.7):.2f} | 实体UUID: {entity_uuid}") - - # 每次提取知识后,检查是否需要清理过时信息 - # 每10次提取清理一次(即每50轮对话) - total_conv = self.db.get_metadata('total_conversations', 0) - if total_conv % 50 == 0 and total_conv > 0: - print("○ 执行定期知识库清理...") - # 这里可以添加清理逻辑 - print(f"✓ 清理完成") - else: - print("○ 未提取到新知识") - - def _generate_summary(self, messages: List[Dict[str, Any]]) -> Optional[str]: - """ - 使用LLM生成对话概括 - - Args: - messages: 要概括的消息列表 - - Returns: - 概括文本,失败返回None - """ - try: - # 构建对话文本 - conversation_text = "" - for msg in messages: - role_name = "用户" if msg['role'] == 'user' else "助手" - conversation_text += f"{role_name}: {msg['content']}\n" - - # 构建概括请求 - summary_prompt = f"""请对以下对话进行主题概括,要求: -1. 用一句话总结对话的主要主题和内容 -2. 提炼关键信息和讨论要点 -3. 简洁明了,不超过100字 -4. 只返回概括内容,不要有其他说明 - -对话内容: -{conversation_text} - -请给出主题概括:""" - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': [ - {'role': 'system', 'content': '你是一个专业的对话分析助手,擅长总结对话主题。'}, - {'role': 'user', 'content': summary_prompt} - ], - 'temperature': 0.3, # 使用较低温度以获得更稳定的概括 - 'max_tokens': 200, - 'stream': False - } - - response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=30 - ) - - response.raise_for_status() - result = response.json() - - if 'choices' in result and len(result['choices']) > 0: - summary = result['choices'][0]['message']['content'].strip() - return summary - else: - print("✗ 未能获取有效的概括结果") - return None - - except Exception as e: - print(f"✗ 生成概括时出错: {e}") - # 返回一个默认概括 - return f"对话记录 ({len(messages)} 条消息)" - - def get_recent_messages(self, count: int = 10) -> List[Dict[str, str]]: - """ - 获取最近的N条短期记忆消息(从数据库) - - Args: - count: 要获取的消息数量 - - Returns: - 消息列表 - """ - messages = self.db.get_short_term_messages(limit=count) - return [{'role': msg['role'], 'content': msg['content']} for msg in messages] - - def get_all_summaries(self) -> List[Dict[str, Any]]: - """ - 获取所有长期记忆概括(从数据库) - - Returns: - 概括列表 - """ - return self.db.get_long_term_summaries() - - def get_long_term_summaries(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: - """ - 获取长期记忆概括(支持限制数量) - - Args: - limit: 限制返回的概括数量,None表示返回全部 - - Returns: - 概括列表 - """ - summaries = self.db.get_long_term_summaries() - if limit is not None and limit > 0: - return summaries[:limit] - return summaries - - def get_statistics(self) -> Dict[str, Any]: - """ - 获取记忆统计信息(从数据库) - - Returns: - 统计信息字典 - """ - # 从数据库获取消息 - short_term_messages = self.db.get_short_term_messages() - long_term_summaries = self.db.get_long_term_summaries() - - short_user = sum(1 for msg in short_term_messages if msg['role'] == 'user') - short_assistant = sum(1 for msg in short_term_messages if msg['role'] == 'assistant') - - # 获取知识库统计 - db_stats = self.db.get_statistics() - - # 获取知识库详细统计 - kb_stats = self.knowledge_base.get_statistics() - - return { - 'short_term': { - 'total_messages': len(short_term_messages), - 'user_messages': short_user, - 'assistant_messages': short_assistant, - 'rounds': short_user - }, - 'long_term': { - 'total_summaries': len(long_term_summaries), - 'total_archived_rounds': sum(s.get('rounds', 0) for s in long_term_summaries), - 'total_archived_messages': sum(s.get('message_count', 0) for s in long_term_summaries) - }, - 'knowledge_base': { - 'total_entities': db_stats['entities_count'], - 'total_base_knowledge': db_stats['base_knowledge_count'], - 'total_knowledge': kb_stats['total_knowledge'], # 添加总知识数以保持兼容性 - 'total_definitions': kb_stats['total_definitions'], - 'total_related_info': kb_stats['total_related_info'] - }, - 'total_conversations': self.db.get_metadata('total_conversations', 0), - 'database_size_kb': db_stats.get('db_size_kb', 0) - } - - def clear_all_memory(self): - """ - 清空所有记忆(短期、长期) - """ - self.db.clear_short_term_memory() - self.db.clear_long_term_memory() - self.db.set_metadata('total_conversations', 0) - print("✓ 所有记忆已清空") - - def get_context_for_chat(self, recent_count: int = 10) -> str: - """ - 获取用于聊天的上下文(包含长期记忆概括和短期记忆) - - Args: - recent_count: 最近消息数量 - - Returns: - 格式化的上下文字符串 - """ - context_parts = [] - - # 添加长期记忆概括(如果有) - long_term_summaries = self.db.get_long_term_summaries() - if long_term_summaries: - context_parts.append("【历史对话主题回顾】") - for i, summary in enumerate(long_term_summaries[-5:], 1): # 只取最近5个概括 - context_parts.append(f"{i}. {summary['summary']}") - context_parts.append("") - - return "\n".join(context_parts) if context_parts else "" - - -if __name__ == '__main__': - print("=" * 60) - print("长效记忆管理器测试") - print("=" * 60) - - manager = LongTermMemoryManager() - - print("\n当前记忆统计:") - stats = manager.get_statistics() - print(f"短期记忆: {stats['short_term']['rounds']} 轮对话 ({stats['short_term']['total_messages']} 条消息)") - print(f"长期记忆: {stats['long_term']['total_summaries']} 个主题概括") - print(f"知识库实体: {stats['knowledge_base']['total_entities']} 个") - print(f"总对话轮数: {stats['total_conversations']} 轮") - print(f"数据库大小: {stats['database_size_kb']:.2f} KB") - - long_term_summaries = manager.get_all_summaries() - if long_term_summaries: - print("\n长期记忆概括:") - for i, summary in enumerate(long_term_summaries, 1): - print(f"{i}. [{summary['created_at'][:10]}] {summary['summary']}") - else: - print("\n暂无长期记忆概括") - - print("\n✓ 测试完成") +# Compatibility shim: src.core.long_term_memory has moved to src.limbic.hippocampus.memory_store (v4.0) +from src.limbic.hippocampus.memory_store import ( + OpenLoopTracker, + LongTermMemoryManager, + ENABLE_OPEN_LOOP, +) + +__all__ = ["OpenLoopTracker", "LongTermMemoryManager", "ENABLE_OPEN_LOOP"] diff --git a/src/core/model_config.py b/src/core/model_config.py index f672b20..bc64318 100644 --- a/src/core/model_config.py +++ b/src/core/model_config.py @@ -1,133 +1,18 @@ """ -多层模型配置模块 -管理不同类型任务使用的不同模型配置 -""" - -import os -from enum import Enum -from typing import Dict, Any, Optional -from dotenv import load_dotenv - -load_dotenv() - - -class ModelType(Enum): - """模型类型枚举""" - MAIN = "main" # 主模型:处理主要对话和复杂推理 - TOOL = "tool" # 小模型:处理工具调用和轻量级任务 - VISION = "vision" # 多模态模型:处理视觉和多模态任务 - - -class ModelConfig: - """ - 模型配置类 - 提供多层模型架构的配置管理 - """ - - def __init__(self): - """初始化模型配置""" - # API配置 - self.api_key = os.getenv('SILICONFLOW_API_KEY') - self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - - # 主模型配置 - self.main_model = { - 'name': os.getenv('MAIN_MODEL_NAME', os.getenv('MODEL_NAME', 'deepseek-ai/DeepSeek-V3.2')), - 'temperature': float(os.getenv('MAIN_MODEL_TEMPERATURE', os.getenv('TEMPERATURE', '0.8'))), - 'max_tokens': int(os.getenv('MAIN_MODEL_MAX_TOKENS', os.getenv('MAX_TOKENS', '2000'))) - } - - # 小模型配置(工具级任务) - self.tool_model = { - 'name': os.getenv('TOOL_MODEL_NAME', 'zai-org/GLM-4.6V'), - 'temperature': float(os.getenv('TOOL_MODEL_TEMPERATURE', '0.3')), - 'max_tokens': int(os.getenv('TOOL_MODEL_MAX_TOKENS', '500')) - } - - # 多模态模型配置 - self.vision_model = { - 'name': os.getenv('VISION_MODEL_NAME', 'Qwen/Qwen3-VL-32B-Instruct'), - 'temperature': float(os.getenv('VISION_MODEL_TEMPERATURE', '0.5')), - 'max_tokens': int(os.getenv('VISION_MODEL_MAX_TOKENS', '1000')) - } - - # 视觉工具LLM配置(向后兼容) - self.vision_llm_temperature = float(os.getenv('VISION_LLM_TEMPERATURE', '0.3')) - self.vision_llm_max_tokens = int(os.getenv('VISION_LLM_MAX_TOKENS', '10')) - self.vision_llm_timeout = int(os.getenv('VISION_LLM_TIMEOUT', '10')) - - # NPS工具系统LLM配置(向后兼容) - self.nps_llm_temperature = float(os.getenv('NPS_LLM_TEMPERATURE', '0.3')) - self.nps_llm_max_tokens = int(os.getenv('NPS_LLM_MAX_TOKENS', '100')) - self.nps_llm_timeout = int(os.getenv('NPS_LLM_TIMEOUT', '10')) - - def get_model_config(self, model_type: ModelType) -> Dict[str, Any]: - """ - 获取指定类型的模型配置 - - Args: - model_type: 模型类型 - - Returns: - 包含模型配置的字典 - """ - if model_type == ModelType.MAIN: - return self.main_model.copy() - elif model_type == ModelType.TOOL: - return self.tool_model.copy() - elif model_type == ModelType.VISION: - return self.vision_model.copy() - else: - # 默认返回主模型配置 - return self.main_model.copy() - - def get_api_config(self) -> Dict[str, str]: - """ - 获取API配置 - - Returns: - 包含API密钥和URL的字典 - """ - return { - 'api_key': self.api_key, - 'api_url': self.api_url - } - - def is_valid(self) -> bool: - """ - 检查配置是否有效 - - Returns: - 配置是否有效 - """ - return bool(self.api_key and self.api_key != 'your-api-key') - - def get_summary(self) -> str: - """ - 获取配置摘要 - - Returns: - 配置摘要字符串 - """ - return f"""多层模型配置: - 主模型: {self.main_model['name']} - 工具模型: {self.tool_model['name']} - 多模态模型: {self.vision_model['name']} - API地址: {self.api_url}""" +Model Config - v3.1.0 兼容层 +v4.0 重构说明: +- 真实实现已迁移到 src.cortex.config.model_config +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.cortex.config.model_config +""" -# 创建全局配置实例 -_global_config: Optional[ModelConfig] = None +from __future__ import annotations +from src.cortex.config.model_config import ( + ModelConfig, + ModelType, + get_model_config, +) -def get_model_config() -> ModelConfig: - """ - 获取全局模型配置实例(单例模式) - - Returns: - ModelConfig实例 - """ - global _global_config - if _global_config is None: - _global_config = ModelConfig() - return _global_config +__all__ = ["ModelConfig", "ModelType", "get_model_config"] diff --git a/src/core/proactive_engine.py b/src/core/proactive_engine.py new file mode 100644 index 0000000..6cab4bc --- /dev/null +++ b/src/core/proactive_engine.py @@ -0,0 +1,17 @@ +""" +Proactive Engine - v3.1.0 兼容层 + +v4.0 重构说明: +- 真实实现已迁移到 src.prefrontal.proactive.proactive_engine +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.prefrontal.proactive.proactive_engine +""" + +from __future__ import annotations + +from src.prefrontal.proactive.proactive_engine import ( + ENABLE_PROACTIVE_ENGINE, + ProactiveEngine, +) + +__all__ = ["ProactiveEngine", "ENABLE_PROACTIVE_ENGINE"] diff --git a/src/core/schedule_generator.py b/src/core/schedule_generator.py index ac2d3f8..8790950 100644 --- a/src/core/schedule_generator.py +++ b/src/core/schedule_generator.py @@ -1,310 +1,14 @@ """ -临时日程生成工具 -用于在空闲时间段自动生成临时日程 -""" - -import os -import re -import json -import time -from typing import List, Dict, Any, Tuple -from datetime import datetime, timedelta -from dotenv import load_dotenv -import requests -from src.core.schedule_manager import ScheduleManager, ScheduleType, SchedulePriority -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class TemporaryScheduleGenerator: - """ - 临时日程生成器 - 在用户询问日程时,如果没有临时日程,则生成1-3个合理的临时日程 - """ - - def __init__(self, schedule_manager: ScheduleManager = None): - """ - 初始化临时日程生成器 - - Args: - schedule_manager: 日程管理器实例 - """ - self.schedule_manager = schedule_manager or ScheduleManager() - self.api_key = os.getenv('SILICONFLOW_API_KEY') - self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') - self.temperature = 0.8 - self.max_tokens = 1000 - - debug_logger.log_module('TemporaryScheduleGenerator', '临时日程生成器初始化完成') - - def generate_temporary_schedules( - self, - date: str, - character_name: str = "智能体", - character_info: Dict[str, str] = None, - context: str = "" - ) -> List[Dict[str, Any]]: - """ - 为指定日期生成临时日程 - - Args: - date: 日期(ISO格式,如 "2024-01-15") - character_name: 智能体名称 - character_info: 智能体信息 - context: 对话上下文 - - Returns: - 生成的日程列表 - """ - debug_logger.log_module('TemporaryScheduleGenerator', '开始生成临时日程', { - 'date': date - }) - - # 获取空闲时间段 - free_slots = self.schedule_manager.get_free_time_slots(date, slot_duration_minutes=60) - - if not free_slots: - debug_logger.log_info('TemporaryScheduleGenerator', '没有可用的空闲时间段') - return [] - - # 使用LLM生成临时日程建议 - character_info = character_info or {} - hobby = character_info.get('hobby', '阅读、学习') - personality = character_info.get('personality', '活泼开朗') - - system_prompt = f"""你是一个日程规划助手,帮助智能体{character_name}规划临时日程。 - -{character_name}的信息: -- 性格:{personality} -- 爱好:{hobby} - -任务:根据提供的空闲时间段,生成1-3个合理的临时日程建议。 - -要求: -1. 日程内容应该符合{character_name}的性格和爱好 -2. 日程应该多样化,包括学习、娱乐、休息等 -3. 考虑时间段的特点(如早上适合学习,晚上适合放松) -4. 部分日程可以涉及用户参与(如"一起看电影"),这类日程需要标注involves_user=true - -请以JSON格式输出日程建议列表: -[ - {{ - "title": "日程标题", - "description": "详细描述", - "time_slot_index": 0, - "duration_hours": 1.5, - "involves_user": false, - "reason": "选择这个活动的原因" - }}, - ... -] +Temporary Schedule Generator - v3.1.0 兼容层 -注意:time_slot_index是指使用第几个空闲时间段(从0开始)""" - - # 格式化空闲时间段信息 - slots_info = [] - for i, (start, end) in enumerate(free_slots): - start_dt = datetime.fromisoformat(start) - end_dt = datetime.fromisoformat(end) - duration = (end_dt - start_dt).total_seconds() / 3600 - time_period = self._get_time_period(start_dt.hour) - slots_info.append(f"时间段{i}: {start_dt.strftime('%H:%M')}-{end_dt.strftime('%H:%M')} ({time_period}, {duration:.1f}小时)") - - user_prompt = f"""可用的空闲时间段: -{chr(10).join(slots_info)} - -{f"对话上下文:{context}" if context else ""} - -请为{character_name}生成1-3个临时日程建议。""" - - try: - # 调用LLM生成日程 - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} - ] - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': messages, - 'temperature': self.temperature, - 'max_tokens': self.max_tokens, - 'stream': False - } - - debug_logger.log_request('TemporaryScheduleGenerator', self.api_url, payload, headers) - - start_time = time.time() - response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=30 - ) - elapsed_time = time.time() - start_time - - debug_logger.log_response('TemporaryScheduleGenerator', response.status_code, response.text, elapsed_time) - - if response.status_code == 200: - result = response.json() - content = result['choices'][0]['message']['content'].strip() - - # 尝试解析JSON结果 - try: - # 提取JSON部分 - json_match = re.search(r'```(?:json)?\s*(\[.*?\])\s*```', content, re.DOTALL) - if json_match: - content = json_match.group(1) - - suggestions = json.loads(content) - - # 创建临时日程 - created_schedules = [] - for suggestion in suggestions[:3]: # 最多3个 - slot_index = suggestion.get('time_slot_index', 0) - if slot_index >= len(free_slots): - continue - - start, end = free_slots[slot_index] - duration_hours = suggestion.get('duration_hours', 1.0) - - # 计算实际的结束时间 - start_dt = datetime.fromisoformat(start) - actual_end = start_dt + timedelta(hours=duration_hours) - slot_end_dt = datetime.fromisoformat(end) - - # 确保不超过时间段 - if actual_end > slot_end_dt: - actual_end = slot_end_dt - - involves_user = suggestion.get('involves_user', False) - - # 创建临时日程 - success, schedule, message = self.schedule_manager.create_schedule( - title=suggestion.get('title', '临时活动'), - description=suggestion.get('description', ''), - schedule_type=ScheduleType.TEMPORARY, - start_time=start_dt.isoformat(), - end_time=actual_end.isoformat(), - priority=SchedulePriority.LOW, - generated_reason=suggestion.get('reason', ''), - involves_user=involves_user - ) - - if success: - created_schedules.append(schedule.to_dict()) - debug_logger.log_info('TemporaryScheduleGenerator', f'临时日程创建成功: {schedule.title}') - - return created_schedules - - except json.JSONDecodeError as e: - debug_logger.log_error('TemporaryScheduleGenerator', f'解析JSON失败: {content}', e) - return self._create_fallback_schedules(free_slots[:1], character_name) - else: - debug_logger.log_error('TemporaryScheduleGenerator', f'API调用失败: {response.status_code}') - return self._create_fallback_schedules(free_slots[:1], character_name) - - except Exception as e: - debug_logger.log_error('TemporaryScheduleGenerator', f'生成临时日程异常: {str(e)}', e) - return self._create_fallback_schedules(free_slots[:1], character_name) - - def _get_time_period(self, hour: int) -> str: - """ - 根据小时数获取时间段描述 - - Args: - hour: 小时数(0-23) - - Returns: - 时间段描述 - """ - if 5 <= hour < 9: - return "早晨" - elif 9 <= hour < 12: - return "上午" - elif 12 <= hour < 14: - return "中午" - elif 14 <= hour < 18: - return "下午" - elif 18 <= hour < 22: - return "晚上" - else: - return "深夜" - - def _create_fallback_schedules( - self, - free_slots: List[Tuple[str, str]], - character_name: str - ) -> List[Dict[str, Any]]: - """ - 创建降级的临时日程(当LLM不可用时) - - Args: - free_slots: 空闲时间段列表 - character_name: 智能体名称 +v4.0 重构说明: +- 真实实现已迁移到 src.prefrontal.schedule.schedule_generator +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.prefrontal.schedule.schedule_generator +""" - Returns: - 创建的日程列表 - """ - if not free_slots: - return [] - - created_schedules = [] - - # 使用第一个空闲时间段 - start, end = free_slots[0] - start_dt = datetime.fromisoformat(start) - end_dt = start_dt + timedelta(hours=1.5) - - # 根据时间段选择活动 - hour = start_dt.hour - if 9 <= hour < 12: - title = "阅读时光" - description = "安静地阅读一本喜欢的书" - elif 14 <= hour < 18: - title = "学习充电" - description = "学习新知识,充实自己" - else: - title = "休闲放松" - description = "放松心情,做些喜欢的事" - - success, schedule, _ = self.schedule_manager.create_schedule( - title=title, - description=description, - schedule_type=ScheduleType.TEMPORARY, - start_time=start_dt.isoformat(), - end_time=end_dt.isoformat(), - priority=SchedulePriority.LOW, - generated_reason="自动生成的临时日程" - ) - - if success: - created_schedules.append(schedule.to_dict()) - - return created_schedules +from __future__ import annotations - def has_temporary_schedules_today(self) -> bool: - """ - 检查今天是否已有临时日程 +from src.prefrontal.schedule.schedule_generator import TemporaryScheduleGenerator - Returns: - 是否已有临时日程 - """ - today = datetime.now().date().isoformat() - start_of_day = f"{today}T00:00:00" - end_of_day = f"{today}T23:59:59" - - schedules = self.schedule_manager.get_schedules_by_time_range(start_of_day, end_of_day) - - # 检查是否有临时日程 - return any(s.schedule_type == ScheduleType.TEMPORARY for s in schedules) +__all__ = ["TemporaryScheduleGenerator"] diff --git a/src/core/schedule_manager.py b/src/core/schedule_manager.py index 7500a3b..2a160dd 100644 --- a/src/core/schedule_manager.py +++ b/src/core/schedule_manager.py @@ -1,851 +1,32 @@ """ -智能体日程管理模块 -实现日程的创建、管理、冲突检测和优先级控制 -""" - -import uuid -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional, Tuple -from enum import Enum -from src.core.database_manager import DatabaseManager -from src.tools.debug_logger import get_debug_logger - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class ScheduleType(Enum): - """日程类型枚举""" - RECURRING = "recurring" # 周期日程(如课程表) - APPOINTMENT = "appointment" # 预约日程(用户提及或意图识别) - TEMPORARY = "temporary" # 临时日程(LLM自动生成) - - -class SchedulePriority(Enum): - """日程优先级枚举""" - LOW = 1 # 低优先级(临时日程) - MEDIUM = 2 # 中等优先级(一般预约) - HIGH = 3 # 高优先级(重要预约) - CRITICAL = 4 # 关键优先级(周期日程如课程) - - -class CollaborationStatus(Enum): - """协作确认状态枚举""" - NOT_REQUIRED = "not_required" # 不需要协作 - PENDING = "pending" # 待确认 - CONFIRMED = "confirmed" # 已确认 - REJECTED = "rejected" # 已拒绝 - - -class Schedule: - """ - 日程基类 - """ - - def __init__( - self, - schedule_id: str = None, - title: str = "", - description: str = "", - schedule_type: ScheduleType = ScheduleType.APPOINTMENT, - priority: SchedulePriority = SchedulePriority.MEDIUM, - start_time: str = None, - end_time: str = None, - created_at: str = None, - is_active: bool = True, - collaboration_status: CollaborationStatus = CollaborationStatus.NOT_REQUIRED, - is_queryable: bool = True, - metadata: Dict[str, Any] = None - ): - """ - 初始化日程 - - Args: - schedule_id: 日程唯一标识符 - title: 日程标题 - description: 日程描述 - schedule_type: 日程类型 - priority: 日程优先级 - start_time: 开始时间(ISO格式) - end_time: 结束时间(ISO格式) - created_at: 创建时间 - is_active: 是否激活 - collaboration_status: 协作确认状态 - is_queryable: 是否可被查询 - metadata: 附加元数据 - """ - self.schedule_id = schedule_id or str(uuid.uuid4()) - self.title = title - self.description = description - self.schedule_type = schedule_type - self.priority = priority - self.start_time = start_time - self.end_time = end_time - self.created_at = created_at or datetime.now().isoformat() - self.is_active = is_active - self.collaboration_status = collaboration_status - self.is_queryable = is_queryable - self.metadata = metadata or {} - - def to_dict(self) -> Dict[str, Any]: - """ - 将日程转换为字典 - - Returns: - 日程字典表示 - """ - return { - 'schedule_id': self.schedule_id, - 'title': self.title, - 'description': self.description, - 'schedule_type': self.schedule_type.value, - 'priority': self.priority.value, - 'start_time': self.start_time, - 'end_time': self.end_time, - 'created_at': self.created_at, - 'is_active': self.is_active, - 'collaboration_status': self.collaboration_status.value, - 'is_queryable': self.is_queryable, - 'metadata': self.metadata - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Schedule': - """ - 从字典创建日程 - - Args: - data: 日程字典数据 - - Returns: - 日程对象 - """ - return cls( - schedule_id=data.get('schedule_id'), - title=data.get('title', ''), - description=data.get('description', ''), - schedule_type=ScheduleType(data.get('schedule_type', 'appointment')), - priority=SchedulePriority(data.get('priority', 2)), - start_time=data.get('start_time'), - end_time=data.get('end_time'), - created_at=data.get('created_at'), - is_active=data.get('is_active', True), - collaboration_status=CollaborationStatus(data.get('collaboration_status', 'not_required')), - is_queryable=data.get('is_queryable', True), - metadata=data.get('metadata', {}) - ) - - -class RecurringSchedule(Schedule): - """ - 周期日程 - 支持每周重复的固定日程,如课程表 - """ - - def __init__( - self, - weekday: int = None, # 0-6 表示周一到周日 - recurrence_pattern: str = "", # 重复模式描述 - **kwargs - ): - """ - 初始化周期日程 - - Args: - weekday: 星期几(0-6,0=周一) - recurrence_pattern: 重复模式(如 "每周一、三、五") - **kwargs: 其他日程参数 - """ - # 设置默认优先级,如果kwargs中没有指定 - if 'priority' not in kwargs: - kwargs['priority'] = SchedulePriority.CRITICAL - super().__init__( - schedule_type=ScheduleType.RECURRING, - **kwargs - ) - self.metadata['weekday'] = weekday - self.metadata['recurrence_pattern'] = recurrence_pattern - - -class AppointmentSchedule(Schedule): - """ - 预约日程 - 用户主动提及或通过意图识别创建的日程 - """ - - def __init__( - self, - source: str = "user", # 来源:user(用户提及)或intent(意图识别) - confirmed_by_agent: bool = False, # 智能体是否已确认 - **kwargs - ): - """ - 初始化预约日程 - - Args: - source: 日程来源 - confirmed_by_agent: 智能体是否已确认 - **kwargs: 其他日程参数 - """ - super().__init__( - schedule_type=ScheduleType.APPOINTMENT, - **kwargs - ) - self.metadata['source'] = source - self.metadata['confirmed_by_agent'] = confirmed_by_agent - - -class TemporarySchedule(Schedule): - """ - 临时日程 - LLM在空闲时段自动生成的补充日程 - """ - - def __init__( - self, - generated_reason: str = "", # 生成原因 - involves_user: bool = False, # 是否涉及用户参与 - **kwargs - ): - """ - 初始化临时日程 - - Args: - generated_reason: 生成原因 - involves_user: 是否涉及用户参与 - **kwargs: 其他日程参数 - """ - # 如果涉及用户,需要协作确认且默认不可查询 - if involves_user: - kwargs['collaboration_status'] = CollaborationStatus.PENDING - kwargs['is_queryable'] = False - - # 设置默认优先级,如果kwargs中没有指定 - if 'priority' not in kwargs: - kwargs['priority'] = SchedulePriority.LOW - - super().__init__( - schedule_type=ScheduleType.TEMPORARY, - **kwargs - ) - self.metadata['generated_reason'] = generated_reason - self.metadata['involves_user'] = involves_user - - -class ScheduleManager: - """ - 日程管理器 - 负责日程的创建、查询、冲突检测和优先级管理 - """ - - def __init__(self, db_manager: DatabaseManager = None): - """ - 初始化日程管理器 - - Args: - db_manager: 数据库管理器实例 - """ - self.db = db_manager or DatabaseManager() - self._initialize_database() - - debug_logger.log_module('ScheduleManager', '日程管理器初始化完成') - - def _initialize_database(self): - """初始化数据库表""" - with self.db.get_connection() as conn: - # 创建日程表 - conn.execute(''' - CREATE TABLE IF NOT EXISTS schedules ( - schedule_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT, - schedule_type TEXT NOT NULL, - priority INTEGER NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT, - is_active INTEGER DEFAULT 1, - collaboration_status TEXT NOT NULL, - is_queryable INTEGER DEFAULT 1, - metadata TEXT - ) - ''') - - # 创建索引以提高查询性能 - conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_type ON schedules(schedule_type)') - conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_time ON schedules(start_time, end_time)') - conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_active ON schedules(is_active)') - conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_queryable ON schedules(is_queryable)') - - # 显式提交 - conn.commit() - - debug_logger.log_info('ScheduleManager', '数据库表初始化完成') - - def create_schedule( - self, - title: str, - description: str, - schedule_type: ScheduleType, - start_time: str, - end_time: str, - priority: SchedulePriority = None, - check_conflict: bool = True, - check_similarity: bool = True, - **kwargs - ) -> Tuple[bool, Optional[Schedule], str]: - """ - 创建新日程 - - Args: - title: 日程标题 - description: 日程描述 - schedule_type: 日程类型 - start_time: 开始时间(ISO格式) - end_time: 结束时间(ISO格式) - priority: 日程优先级(如果未指定,根据类型自动设置) - check_conflict: 是否检查冲突 - check_similarity: 是否检查相似日程 - **kwargs: 其他参数 - - Returns: - (是否成功, 日程对象或None, 消息) - """ - debug_logger.log_module('ScheduleManager', '创建新日程', { - 'title': title, - 'type': schedule_type.value, - 'start': start_time, - 'end': end_time - }) - - # 如果未指定优先级,根据类型设置默认优先级 - if priority is None: - if schedule_type == ScheduleType.RECURRING: - priority = SchedulePriority.CRITICAL - elif schedule_type == ScheduleType.APPOINTMENT: - priority = SchedulePriority.MEDIUM - else: # TEMPORARY - priority = SchedulePriority.LOW +Schedule Manager - v3.1.0 兼容层 - # 检查当天是否有相似日程 - if check_similarity: - from src.core.schedule_similarity_checker import ScheduleSimilarityChecker, get_schedules_on_same_day - - # 获取当天的所有日程 - same_day_schedules = get_schedules_on_same_day(self, start_time) - - if same_day_schedules: - # 使用LLM检查相似度 - checker = ScheduleSimilarityChecker() - new_schedule_dict = { - 'title': title, - 'description': description, - 'start_time': start_time, - 'end_time': end_time, - 'schedule_type': schedule_type.value, - 'metadata': kwargs.get('metadata', {}) - } - - has_similar, schedule_to_delete, result = checker.check_similar_schedules( - new_schedule_dict, - same_day_schedules - ) - - if has_similar: - if schedule_to_delete: - # LLM建议保留新日程,删除旧日程 - self.delete_schedule(schedule_to_delete) - message = f"检测到相似日程,已删除旧日程并创建新日程。原因:{result.get('reason', '新日程更详细')}" - debug_logger.log_info('ScheduleManager', message) - # 继续创建新日程 - else: - # LLM建议保留旧日程,不创建新日程 - message = f"检测到相似日程,保留已有日程。原因:{result.get('reason', '已有日程更详细')}" - debug_logger.log_info('ScheduleManager', message) - return False, None, message - - # 检查日程冲突 - if check_conflict: - has_conflict, conflict_schedule = self.check_conflict(start_time, end_time, priority) - if has_conflict: - message = f"日程冲突:时间段 {start_time} 到 {end_time} 已有优先级更高或相同的日程 '{conflict_schedule.title}'" - debug_logger.log_info('ScheduleManager', message) - return False, None, message - - # 创建对应类型的日程对象 - if schedule_type == ScheduleType.RECURRING: - # 过滤掉 TEMPORARY 专用参数 - filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ['involves_user', 'generated_reason']} - schedule = RecurringSchedule( - title=title, - description=description, - start_time=start_time, - end_time=end_time, - priority=priority, - **filtered_kwargs - ) - elif schedule_type == ScheduleType.APPOINTMENT: - # 过滤掉 TEMPORARY 专用参数 - filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ['involves_user', 'generated_reason']} - schedule = AppointmentSchedule( - title=title, - description=description, - start_time=start_time, - end_time=end_time, - priority=priority, - **filtered_kwargs - ) - else: # TEMPORARY - schedule = TemporarySchedule( - title=title, - description=description, - start_time=start_time, - end_time=end_time, - priority=priority, - **kwargs - ) - - # 保存到数据库 - import json - try: - with self.db.get_connection() as conn: - conn.execute(''' - INSERT INTO schedules ( - schedule_id, title, description, schedule_type, - priority, start_time, end_time, created_at, - is_active, collaboration_status, is_queryable, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - schedule.schedule_id, - schedule.title, - schedule.description, - schedule.schedule_type.value, - schedule.priority.value, - schedule.start_time, - schedule.end_time, - schedule.created_at, - 1 if schedule.is_active else 0, - schedule.collaboration_status.value, - 1 if schedule.is_queryable else 0, - json.dumps(schedule.metadata, ensure_ascii=False) - )) - - message = f"日程创建成功:{title}" - debug_logger.log_info('ScheduleManager', message, { - 'schedule_id': schedule.schedule_id - }) - - return True, schedule, message - - except Exception as e: - message = f"创建日程失败: {str(e)}" - debug_logger.log_error('ScheduleManager', message, e) - return False, None, message - - def check_conflict( - self, - start_time: str, - end_time: str, - new_priority: SchedulePriority, - exclude_schedule_id: str = None - ) -> Tuple[bool, Optional[Schedule]]: - """ - 检查时间段是否与现有日程冲突 - 高优先级日程可以覆盖低优先级日程 - - Args: - start_time: 开始时间(ISO格式) - end_time: 结束时间(ISO格式) - new_priority: 新日程的优先级 - exclude_schedule_id: 排除的日程ID(用于更新时) - - Returns: - (是否冲突, 冲突的日程对象或None) - """ - import json - - with self.db.get_connection() as conn: - # 查询时间段内的所有激活日程 - query = ''' - SELECT * FROM schedules - WHERE is_active = 1 - AND ( - (start_time < ? AND end_time > ?) - OR (start_time >= ? AND start_time < ?) - OR (end_time > ? AND end_time <= ?) - ) - ''' - params = [end_time, start_time, start_time, end_time, start_time, end_time] - - if exclude_schedule_id: - query += ' AND schedule_id != ?' - params.append(exclude_schedule_id) - - cursor = conn.execute(query, params) - - for row in cursor.fetchall(): - existing_priority = SchedulePriority(row[4]) # priority字段 - - # 如果现有日程的优先级 >= 新日程的优先级,则冲突 - if existing_priority.value >= new_priority.value: - data = { - 'schedule_id': row[0], - 'title': row[1], - 'description': row[2], - 'schedule_type': row[3], - 'priority': row[4], - 'start_time': row[5], - 'end_time': row[6], - 'created_at': row[7], - 'is_active': row[9] == 1, - 'collaboration_status': row[10], - 'is_queryable': row[11] == 1, - 'metadata': json.loads(row[12]) if row[12] else {} - } - conflicting_schedule = Schedule.from_dict(data) - return True, conflicting_schedule - - return False, None - - def get_schedule(self, schedule_id: str) -> Optional[Schedule]: - """ - 获取指定日程 - - Args: - schedule_id: 日程ID - - Returns: - 日程对象,不存在时返回None - """ - import json - - with self.db.get_connection() as conn: - cursor = conn.execute( - 'SELECT * FROM schedules WHERE schedule_id = ?', - (schedule_id,) - ) - row = cursor.fetchone() - - if row: - data = { - 'schedule_id': row[0], - 'title': row[1], - 'description': row[2], - 'schedule_type': row[3], - 'priority': row[4], - 'start_time': row[5], - 'end_time': row[6], - 'created_at': row[7], - 'is_active': row[9] == 1, - 'collaboration_status': row[10], - 'is_queryable': row[11] == 1, - 'metadata': json.loads(row[12]) if row[12] else {} - } - return Schedule.from_dict(data) - - return None - - def get_schedules_by_time_range( - self, - start_time: str, - end_time: str, - queryable_only: bool = True, - active_only: bool = True, - include_inactive: bool = False - ) -> List[Schedule]: - """ - 获取时间范围内的所有日程 - - Args: - start_time: 开始时间(ISO格式) - end_time: 结束时间(ISO格式) - queryable_only: 是否只返回可查询的日程 - active_only: 是否只返回激活的日程(优先级高于include_inactive) - include_inactive: 是否包含未激活的日程(当active_only=False时生效) - - Returns: - 日程列表 - """ - import json - - query = ''' - SELECT * FROM schedules - WHERE ( - (start_time < ? AND end_time > ?) - OR (start_time >= ? AND start_time < ?) - ) - ''' - params = [end_time, start_time, start_time, end_time] - - if queryable_only: - query += ' AND is_queryable = 1' - - if active_only: - query += ' AND is_active = 1' - elif not include_inactive: - query += ' AND is_active = 1' - - query += ' ORDER BY start_time ASC' - - with self.db.get_connection() as conn: - cursor = conn.execute(query, params) - - schedules = [] - for row in cursor.fetchall(): - data = { - 'schedule_id': row[0], - 'title': row[1], - 'description': row[2], - 'schedule_type': row[3], - 'priority': row[4], - 'start_time': row[5], - 'end_time': row[6], - 'created_at': row[7], - 'is_active': row[9] == 1, - 'collaboration_status': row[10], - 'is_queryable': row[11] == 1, - 'metadata': json.loads(row[12]) if row[12] else {} - } - schedules.append(Schedule.from_dict(data)) - - return schedules - - def get_pending_collaboration_schedules(self) -> List[Schedule]: - """ - 获取待确认的协作日程 - - Returns: - 待确认的日程列表 - """ - import json - - with self.db.get_connection() as conn: - cursor = conn.execute(''' - SELECT * FROM schedules - WHERE collaboration_status = ? - AND is_active = 1 - ORDER BY created_at ASC - ''', (CollaborationStatus.PENDING.value,)) - - schedules = [] - for row in cursor.fetchall(): - data = { - 'schedule_id': row[0], - 'title': row[1], - 'description': row[2], - 'schedule_type': row[3], - 'priority': row[4], - 'start_time': row[5], - 'end_time': row[6], - 'created_at': row[7], - 'is_active': row[9] == 1, - 'collaboration_status': row[10], - 'is_queryable': row[11] == 1, - 'metadata': json.loads(row[12]) if row[12] else {} - } - schedules.append(Schedule.from_dict(data)) - - return schedules - - def confirm_collaboration(self, schedule_id: str, confirmed: bool) -> bool: - """ - 确认或拒绝协作日程 - - Args: - schedule_id: 日程ID - confirmed: 是否确认 - - Returns: - 是否操作成功 - """ - try: - new_status = CollaborationStatus.CONFIRMED if confirmed else CollaborationStatus.REJECTED - - with self.db.get_connection() as conn: - # 更新协作状态 - conn.execute(''' - UPDATE schedules - SET collaboration_status = ?, - is_queryable = ?, - updated_at = ? - WHERE schedule_id = ? - ''', ( - new_status.value, - 1 if confirmed else 0, # 确认后可查询,拒绝后不可查询 - datetime.now().isoformat(), - schedule_id - )) - - debug_logger.log_info('ScheduleManager', '协作日程状态更新', { - 'schedule_id': schedule_id, - 'confirmed': confirmed - }) - - return True - - except Exception as e: - debug_logger.log_error('ScheduleManager', f'更新协作状态失败: {str(e)}', e) - return False - - def update_schedule( - self, - schedule_id: str, - **kwargs - ) -> bool: - """ - 更新日程信息 - - Args: - schedule_id: 日程ID - **kwargs: 要更新的字段 - - Returns: - 是否更新成功 - """ - # 白名单验证允许更新的列名 - allowed_columns = { - 'title', 'description', 'start_time', 'end_time', - 'priority', 'is_active', 'collaboration_status', 'is_queryable' - } - - try: - if not kwargs: - return False - - # 过滤只允许白名单中的列名 - safe_kwargs = {k: v for k, v in kwargs.items() if k in allowed_columns} - if not safe_kwargs: - debug_logger.log_info('ScheduleManager', '没有有效的字段可更新') - return False - - set_clause = ", ".join([f"{k} = ?" for k in safe_kwargs.keys()]) - set_clause += ", updated_at = ?" - values = list(safe_kwargs.values()) + [datetime.now().isoformat(), schedule_id] - - with self.db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(f''' - UPDATE schedules - SET {set_clause} - WHERE schedule_id = ? - ''', values) - return cursor.rowcount > 0 - except Exception as e: - debug_logger.log_error('ScheduleManager', f'更新日程时出错: {str(e)}', e) - return False - - def delete_schedule(self, schedule_id: str) -> bool: - """ - 删除日程(软删除,设置为非激活状态) - - Args: - schedule_id: 日程ID - - Returns: - 是否删除成功 - """ - try: - with self.db.get_connection() as conn: - conn.execute(''' - UPDATE schedules - SET is_active = 0, updated_at = ? - WHERE schedule_id = ? - ''', (datetime.now().isoformat(), schedule_id)) - - debug_logger.log_info('ScheduleManager', '日程删除成功', { - 'schedule_id': schedule_id - }) - - return True - - except Exception as e: - debug_logger.log_error('ScheduleManager', f'删除日程失败: {str(e)}', e) - return False - - def get_free_time_slots( - self, - date: str, - slot_duration_minutes: int = 60 - ) -> List[Tuple[str, str]]: - """ - 获取指定日期的空闲时间段 - - Args: - date: 日期(ISO格式,如 "2024-01-15") - slot_duration_minutes: 最小时间段长度(分钟) - - Returns: - 空闲时间段列表 [(start_time, end_time), ...] - """ - # 获取当天的所有日程 - start_of_day = f"{date}T00:00:00" - end_of_day = f"{date}T23:59:59" - - schedules = self.get_schedules_by_time_range(start_of_day, end_of_day) - - # 按开始时间排序 - schedules.sort(key=lambda s: s.start_time) - - # 计算空闲时间段 - free_slots = [] - current_time = datetime.fromisoformat(start_of_day) - end_time = datetime.fromisoformat(end_of_day) - - for schedule in schedules: - schedule_start = datetime.fromisoformat(schedule.start_time) - schedule_end = datetime.fromisoformat(schedule.end_time) - - # 如果当前时间到日程开始时间之间有足够的空隙 - if (schedule_start - current_time).total_seconds() / 60 >= slot_duration_minutes: - free_slots.append(( - current_time.isoformat(), - schedule_start.isoformat() - )) - - # 更新当前时间到日程结束时间 - if schedule_end > current_time: - current_time = schedule_end - - # 检查最后一个日程到当天结束是否有空闲时间 - if (end_time - current_time).total_seconds() / 60 >= slot_duration_minutes: - free_slots.append(( - current_time.isoformat(), - end_time.isoformat() - )) - - return free_slots - - def get_statistics(self) -> Dict[str, Any]: - """ - 获取日程统计信息 - - Returns: - 统计信息字典 - """ - with self.db.get_connection() as conn: - cursor = conn.execute(''' - SELECT - COUNT(*) as total, - SUM(CASE WHEN schedule_type = ? THEN 1 ELSE 0 END) as recurring, - SUM(CASE WHEN schedule_type = ? THEN 1 ELSE 0 END) as appointments, - SUM(CASE WHEN schedule_type = ? THEN 1 ELSE 0 END) as temporary, - SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active, - SUM(CASE WHEN collaboration_status = ? THEN 1 ELSE 0 END) as pending_collaboration - FROM schedules - ''', ( - ScheduleType.RECURRING.value, - ScheduleType.APPOINTMENT.value, - ScheduleType.TEMPORARY.value, - CollaborationStatus.PENDING.value - )) +v4.0 重构说明: +- 真实实现已迁移到 src.prefrontal.schedule.schedule_manager +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.prefrontal.schedule.schedule_manager +""" - row = cursor.fetchone() - return { - 'total_schedules': row[0] or 0, - 'recurring': row[1] or 0, - 'appointments': row[2] or 0, - 'temporary': row[3] or 0, - 'active': row[4] or 0, - 'pending_collaboration': row[5] or 0 - } +from __future__ import annotations + +from src.prefrontal.schedule.schedule_manager import ( + AppointmentSchedule, + CollaborationStatus, + RecurringSchedule, + Schedule, + ScheduleManager, + SchedulePriority, + ScheduleType, + TemporarySchedule, +) + +__all__ = [ + "Schedule", + "RecurringSchedule", + "AppointmentSchedule", + "TemporarySchedule", + "ScheduleType", + "SchedulePriority", + "CollaborationStatus", + "ScheduleManager", +] diff --git a/src/core/schedule_similarity_checker.py b/src/core/schedule_similarity_checker.py index 39fea61..f7e6cbc 100644 --- a/src/core/schedule_similarity_checker.py +++ b/src/core/schedule_similarity_checker.py @@ -1,271 +1,17 @@ """ -日程相似度检查工具 -用于检查同一天内的相似日程,并使用LLM判断应保留哪一个 -""" - -import os -import json -import time -from typing import List, Dict, Any, Optional, Tuple -from datetime import datetime - -try: - from dotenv import load_dotenv - load_dotenv() -except ImportError: - # dotenv 不可用时跳过 - pass - -import requests -from src.tools.debug_logger import get_debug_logger - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class ScheduleSimilarityChecker: - """ - 日程相似度检查工具 - 使用LLM判断两个日程是否相似,以及应该保留哪一个 - """ - - def __init__(self): - """初始化日程相似度检查工具""" - self.api_key = os.getenv('SILICONFLOW_API_KEY') - self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') - self.temperature = 0.3 # 使用较低的温度以获得更确定的输出 - self.max_tokens = 800 - - debug_logger.log_module('ScheduleSimilarityChecker', '日程相似度检查工具初始化完成') - - def check_similar_schedules( - self, - new_schedule: Dict[str, Any], - existing_schedules: List[Dict[str, Any]] - ) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]: - """ - 检查新日程是否与当天已有日程相似 - - Args: - new_schedule: 新日程信息字典 - existing_schedules: 当天已有的日程列表 - - Returns: - (是否有相似日程, 应保留的日程ID, LLM判断结果) - """ - if not existing_schedules: - debug_logger.log_info('ScheduleSimilarityChecker', '当天没有其他日程,无需检查相似度') - return False, None, None - - debug_logger.log_module('ScheduleSimilarityChecker', '开始检查日程相似度', { - 'new_schedule': new_schedule.get('title'), - 'existing_count': len(existing_schedules) - }) - - # 逐一检查每个已有日程 - for existing in existing_schedules: - result = self._compare_two_schedules(new_schedule, existing) - - if result and result.get('is_similar'): - # 发现相似日程 - schedule_to_keep = result.get('keep_schedule') - - if schedule_to_keep == 'new': - # 保留新日程,删除旧日程 - debug_logger.log_info('ScheduleSimilarityChecker', - f"发现相似日程,LLM建议保留新日程「{new_schedule.get('title')}」") - return True, existing['schedule_id'], result - elif schedule_to_keep == 'existing': - # 保留旧日程,不创建新日程 - debug_logger.log_info('ScheduleSimilarityChecker', - f"发现相似日程,LLM建议保留已有日程「{existing.get('title')}」") - return True, None, result - - # 没有发现相似日程 - debug_logger.log_info('ScheduleSimilarityChecker', '未发现相似日程') - return False, None, None - - def _compare_two_schedules( - self, - schedule1: Dict[str, Any], - schedule2: Dict[str, Any] - ) -> Optional[Dict[str, Any]]: - """ - 使用LLM比较两个日程是否相似 - - Args: - schedule1: 第一个日程(新日程) - schedule2: 第二个日程(已有日程) - - Returns: - LLM判断结果字典,包含is_similar和keep_schedule字段 - """ - system_prompt = """你是一个日程管理专家。你需要判断两个日程是否相似,以及应该保留哪一个。 - -判断标准: -1. 如果两个日程的主题、活动内容基本一致,视为相似日程 -2. 即使时间、地点略有差异,但如果核心活动相同,也视为相似 -3. 如果一个日程明确说明是与另一个日程不同的活动,则不相似 -4. 如果两个日程的标题或描述明确表明是不同的事情,则不相似 - -如果两个日程相似,应该保留信息更详细、更完整的那一个: -- 比较标题和描述的详细程度 -- 比较时间信息的明确程度 -- 比较其他元数据的丰富程度 +Schedule Similarity Checker - v3.1.0 兼容层 -请返回JSON格式的判断结果: -{ - "is_similar": true/false, // 是否相似 - "reason": "判断理由", // 详细说明判断的理由 - "keep_schedule": "new"/"existing"/"none" // 应保留哪个(new=新日程, existing=已有日程, none=不相似无需选择) -}""" - - # 格式化日程信息 - schedule1_info = self._format_schedule_for_llm(schedule1, "新日程") - schedule2_info = self._format_schedule_for_llm(schedule2, "已有日程") - - user_prompt = f"""请判断以下两个日程是否相似,如果相似,应该保留哪一个: - -{schedule1_info} - -{schedule2_info} - -请分析这两个日程,并返回JSON格式的判断结果。""" - - try: - # 调用LLM进行相似度判断 - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} - ] - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': messages, - 'temperature': self.temperature, - 'max_tokens': self.max_tokens, - 'stream': False - } - - debug_logger.log_request('ScheduleSimilarityChecker', self.api_url, payload, headers) - - start_time = time.time() - response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=30 - ) - elapsed_time = time.time() - start_time - - debug_logger.log_response('ScheduleSimilarityChecker', response.status_code, response.text, elapsed_time) - - if response.status_code == 200: - result = response.json() - content = result['choices'][0]['message']['content'].strip() - - # 尝试解析JSON结果 - try: - # 提取JSON部分(可能被包裹在markdown代码块中) - import re - json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL) - if json_match: - content = json_match.group(1) - - parsed_result = json.loads(content) - - # 验证结果格式 - if 'is_similar' in parsed_result and 'keep_schedule' in parsed_result: - debug_logger.log_info('ScheduleSimilarityChecker', - f"LLM判断结果:相似={parsed_result['is_similar']}, 保留={parsed_result.get('keep_schedule')}") - return parsed_result - else: - debug_logger.log_error('ScheduleSimilarityChecker', 'LLM返回结果格式不正确') - return None - - except json.JSONDecodeError as e: - debug_logger.log_error('ScheduleSimilarityChecker', f'解析LLM响应JSON失败: {str(e)}') - debug_logger.log_info('ScheduleSimilarityChecker', f'原始响应: {content}') - return None - else: - debug_logger.log_error('ScheduleSimilarityChecker', f'LLM API请求失败: {response.status_code}') - return None - - except requests.exceptions.Timeout: - debug_logger.log_error('ScheduleSimilarityChecker', 'LLM API请求超时') - return None - except Exception as e: - debug_logger.log_error('ScheduleSimilarityChecker', f'相似度检查失败: {str(e)}', e) - return None +v4.0 重构说明: +- 真实实现已迁移到 src.prefrontal.schedule.schedule_similarity_checker +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.prefrontal.schedule.schedule_similarity_checker +""" - def _format_schedule_for_llm(self, schedule: Dict[str, Any], label: str) -> str: - """ - 格式化日程信息供LLM分析 - - Args: - schedule: 日程信息字典 - label: 标签(如"新日程"或"已有日程") - - Returns: - 格式化的日程信息字符串 - """ - lines = [f"【{label}】"] - lines.append(f"标题:{schedule.get('title', '无')}") - lines.append(f"描述:{schedule.get('description', '无')}") - lines.append(f"开始时间:{schedule.get('start_time', '无')}") - lines.append(f"结束时间:{schedule.get('end_time', '无')}") - lines.append(f"类型:{schedule.get('schedule_type', '无')}") - - # 添加元数据信息(如果有) - metadata = schedule.get('metadata', {}) - if metadata: - lines.append(f"附加信息:{json.dumps(metadata, ensure_ascii=False)}") - - return '\n'.join(lines) +from __future__ import annotations +from src.prefrontal.schedule.schedule_similarity_checker import ( + ScheduleSimilarityChecker, + get_schedules_on_same_day, +) -def get_schedules_on_same_day( - schedule_manager, - target_date: str, - exclude_schedule_id: str = None -) -> List[Dict[str, Any]]: - """ - 获取指定日期当天的所有日程 - - Args: - schedule_manager: 日程管理器实例 - target_date: 目标日期(ISO格式,如"2024-01-15T10:00:00") - exclude_schedule_id: 要排除的日程ID(可选) - - Returns: - 当天的日程列表 - """ - try: - # 解析目标日期 - dt = datetime.fromisoformat(target_date.replace('Z', '+00:00')) - day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0) - day_end = dt.replace(hour=23, minute=59, second=59, microsecond=999999) - - # 查询当天的日程 - schedules = schedule_manager.get_schedules_by_time_range( - start_time=day_start.isoformat(), - end_time=day_end.isoformat(), - include_inactive=False - ) - - # 排除指定的日程ID - if exclude_schedule_id: - schedules = [s for s in schedules if s.schedule_id != exclude_schedule_id] - - # 转换为字典列表 - return [s.to_dict() for s in schedules] - - except Exception as e: - debug_logger.log_error('ScheduleSimilarityChecker', f'获取当天日程失败: {str(e)}', e) - return [] +__all__ = ["ScheduleSimilarityChecker", "get_schedules_on_same_day"] diff --git a/src/core/self_timeline.py b/src/core/self_timeline.py new file mode 100644 index 0000000..78bf93e --- /dev/null +++ b/src/core/self_timeline.py @@ -0,0 +1,247 @@ +""" +自我时间线聚合模块 +聚合多来源输出 Bot 自我时间线(按相关性评分排序)。 +来源:日程 / 对话 / 创作(P3)/ 记忆 / 梦境日记(P1) + +参考架构:spec §Exploratory → SelfTimelineAggregator +""" + +import os +import re +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional + +from src.core.database_manager import DatabaseManager +from src.tools.debug_logger import get_debug_logger + +ENABLE_SELF_TIMELINE = os.getenv('ENABLE_SELF_TIMELINE', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + + +class SelfTimelineAggregator: + """ + 自我时间线聚合器。 + - aggregate_timeline(user, query, now): 输出按相关性评分排序的 list[dict] + - ChatAgent.chat() 检测到时间线询问关键词时调用 + """ + + QUERY_KEYWORDS = [ + r'你今天', r'你昨天', r'你最近', r'你早上', r'你下午', + r'你晚上', r'你这周', r'你这几天', r'你上午', + ] + + def __init__(self, db_manager: DatabaseManager = None, + schedule_manager=None, + long_term_memory=None, + creative_writer=None, + knowledge_base=None, + dream_diary=None): + self.db = db_manager or DatabaseManager() + self.schedule_manager = schedule_manager + self.long_term_memory = long_term_memory + self.creative_writer = creative_writer + self.knowledge_base = knowledge_base + self.dream_diary = dream_diary + self.enabled = ENABLE_SELF_TIMELINE + + def is_timeline_query(self, text: str) -> bool: + if not text: + return False + return any(re.search(p, text) for p in self.QUERY_KEYWORDS) + + def aggregate_timeline(self, user: str, query: str, + now: Optional[datetime] = None, + window_days: int = 7) -> List[Dict[str, Any]]: + """ + 聚合多来源时间线事件。 + 每条 {source, content, time, score, metadata} + """ + if not self.enabled: + return [] + now = now or datetime.now() + events: List[Dict[str, Any]] = [] + # 1) 日程 + events.extend(self._from_schedule(user, now, window_days)) + # 2) 对话 + events.extend(self._from_conversation(user, now, window_days)) + # 3) 创作 + events.extend(self._from_creative(user, now, window_days)) + # 4) 记忆 + events.extend(self._from_knowledge(user, now, window_days)) + # 5) 梦境日记 + events.extend(self._from_dream_diary(user, now, window_days)) + # 相关性评分 + for e in events: + e['score'] = self._score_event(e, query, now) + events.sort(key=lambda e: e['score'], reverse=True) + return events + + def format_for_prompt(self, events: List[Dict[str, Any]], + limit: int = 5) -> str: + if not events: + return "" + lines = ["【我最近在做的事】"] + for e in events[:limit]: + t = e.get('time', '') + src = e.get('source', '?') + content = e.get('content', '')[:80] + lines.append(f"• [{src}] {t}:{content}") + return "\n".join(lines) + + # ===== 来源 ===== + + def _from_schedule(self, user: str, now: datetime, + window_days: int) -> List[Dict[str, Any]]: + if not self.schedule_manager: + return [] + try: + start = (now - timedelta(days=window_days)).isoformat() + end = now.isoformat() + schedules = self.schedule_manager.get_schedules_by_time_range( + start, end, queryable_only=True + ) + except Exception as e: + debug_logger.log_error('SelfTimeline', f'日程聚合失败: {e}', e) + return [] + return [{ + 'source': 'schedule', + 'content': f"{s.title}:{s.description or ''}", + 'time': s.start_time if hasattr(s, 'start_time') else '', + 'metadata': {'schedule_id': getattr(s, 'schedule_id', '')}, + } for s in schedules] + + def _from_conversation(self, user: str, now: datetime, + window_days: int) -> List[Dict[str, Any]]: + try: + messages = self.db.get_short_term_messages() + except Exception: + return [] + events = [] + for m in messages: + try: + t = m.get('timestamp', '') + if not t: + continue + msg_dt = datetime.fromisoformat(t) + if (now - msg_dt) > timedelta(days=window_days): + continue + content = (m.get('content', '') or '')[:120] + if m.get('role') == 'user': + content = f"用户说:{content}" + else: + content = f"我回复:{content}" + events.append({ + 'source': 'conversation', + 'content': content, + 'time': t, + 'metadata': {'role': m.get('role')}, + }) + except (ValueError, TypeError): + continue + return events + + def _from_creative(self, user: str, now: datetime, + window_days: int) -> List[Dict[str, Any]]: + try: + projects = self.db.list_creative_projects(limit=20) + except Exception: + return [] + events = [] + for p in projects: + try: + t = p.get('created_at', '') + if not t: + continue + dt = datetime.fromisoformat(t) + if (now - dt) > timedelta(days=window_days): + continue + events.append({ + 'source': 'creative', + 'content': f"创作项目《{p.get('title', '?')}》({p.get('status', '?')})", + 'time': t, + 'metadata': {'project_uuid': p.get('uuid')}, + }) + except (ValueError, TypeError): + continue + return events + + def _from_knowledge(self, user: str, now: datetime, + window_days: int) -> List[Dict[str, Any]]: + try: + # 简化:列出最近 10 条实体 + cursor = self.db.get_connection().__enter__() + rows = cursor.execute( + 'SELECT * FROM entities ORDER BY created_at DESC LIMIT 10' + ).fetchall() + except Exception: + return [] + events = [] + for r in rows: + try: + t = r['created_at'] + dt = datetime.fromisoformat(t) + if (now - dt) > timedelta(days=window_days): + continue + events.append({ + 'source': 'knowledge', + 'content': f"认识了「{r['name']}」", + 'time': t, + 'metadata': {'entity_uuid': r['uuid']}, + }) + except (KeyError, ValueError, TypeError): + continue + return events + + def _from_dream_diary(self, user: str, now: datetime, + window_days: int) -> List[Dict[str, Any]]: + events = [] + try: + dreams = self.db.get_dream_records(limit=20) if hasattr(self.db, 'get_dream_records') else [] + except Exception: + dreams = [] + for d in dreams: + try: + t = d.get('date') or d.get('created_at', '') + if not t: + continue + dt = datetime.fromisoformat(t) + if (now - dt) > timedelta(days=window_days): + continue + events.append({ + 'source': 'dream_diary', + 'content': f"梦境:{d.get('content', '')[:60]}", + 'time': t, + 'metadata': {'type': d.get('dream_type', '')}, + }) + except (ValueError, TypeError): + continue + return events + + # ===== 相关性评分 ===== + + def _score_event(self, event: Dict[str, Any], query: str, now: datetime) -> float: + score = 0.0 + # 时间衰减:越近越高 + try: + t = datetime.fromisoformat(event.get('time', '')) + delta_h = max(0.0, (now - t).total_seconds() / 3600.0) + score += max(0.0, 1.0 - delta_h / (24 * 7)) + except (ValueError, TypeError): + pass + # 关键词匹配 + q_tokens = set(re.findall(r'[\u4e00-\u9fffA-Za-z0-9]+', query or '')) + e_tokens = set(re.findall(r'[\u4e00-\u9fffA-Za-z0-9]+', event.get('content', ''))) + if q_tokens and e_tokens: + overlap = len(q_tokens & e_tokens) + score += min(1.0, overlap * 0.2) + # 来源权重 + weight = { + 'conversation': 0.3, + 'schedule': 0.4, + 'creative': 0.2, + 'dream_diary': 0.1, + 'knowledge': 0.1, + }.get(event.get('source', ''), 0.1) + score += weight + return round(score, 3) diff --git a/src/core/user_habits.py b/src/core/user_habits.py new file mode 100644 index 0000000..d4c3d60 --- /dev/null +++ b/src/core/user_habits.py @@ -0,0 +1,20 @@ +""" +UserHabits - v3.1.0 兼容层 + +v4.0 重构说明: +- 真实实现已迁移到 src.hypothalamus.habits.user_habits +- 本文件保留原接口,通过导入转发保持 v3.1.0 代码向后兼容 +- 新代码应优先使用 src.hypothalamus.habits.user_habits +""" + +from __future__ import annotations + +from src.hypothalamus.habits.user_habits import ( + ENABLE_USER_HABITS, + UserHabitTracker, +) + +__all__ = [ + "ENABLE_USER_HABITS", + "UserHabitTracker", +] diff --git a/src/cortex/__init__.py b/src/cortex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cortex/config/__init__.py b/src/cortex/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cortex/config/model_config.py b/src/cortex/config/model_config.py new file mode 100644 index 0000000..bda4c77 --- /dev/null +++ b/src/cortex/config/model_config.py @@ -0,0 +1,140 @@ +""" +Cortex Model Config - v4.0 多层模型配置 + +位于 src/cortex/config/,属于大脑皮层(Cortex)的认知基础设施。 + +管理不同类型任务使用的不同模型配置。 + +注意:本模块从 v3.1.0 的 src.core.model_config 迁移而来,逻辑保持一致。 +""" + +from __future__ import annotations + +import os +from enum import Enum +from typing import Any, Dict, Optional +from dotenv import load_dotenv + +from src.cortex.providers import registry as llm_providers + +load_dotenv() + + +class ModelType(Enum): + """模型类型枚举""" + + MAIN = "main" # 主模型:处理主要对话和复杂推理 + TOOL = "tool" # 小模型:处理工具调用和轻量级任务 + VISION = "vision" # 多模态模型:处理视觉和多模态任务 + + +class ModelConfig: + """ + 模型配置类 + 提供多层模型架构的配置管理 + """ + + def __init__(self): + """初始化模型配置""" + try: + self.api_url = llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + # 启动期 LLM_BASE_URL 缺失时(custom)不要阻塞;保留空串 + self.api_url = "" + self.api_key = llm_providers.resolve_api_key() + + # 主模型配置 + self.main_model = { + 'name': os.getenv('MAIN_MODEL_NAME', os.getenv('MODEL_NAME', os.getenv( + 'LLM_MODEL_NAME', llm_providers.resolve_model_name(llm_providers.resolve_provider())))), + 'temperature': float(os.getenv('MAIN_MODEL_TEMPERATURE', os.getenv('TEMPERATURE', '0.8'))), + 'max_tokens': int(os.getenv('MAIN_MODEL_MAX_TOKENS', os.getenv('MAX_TOKENS', '2000'))), + 'base_url': os.getenv('MAIN_MODEL_BASE_URL'), + } + + # 小模型配置(工具级任务) + self.tool_model = { + 'name': os.getenv('TOOL_MODEL_NAME', 'zai-org/GLM-4.6V'), + 'temperature': float(os.getenv('TOOL_MODEL_TEMPERATURE', '0.3')), + 'max_tokens': int(os.getenv('TOOL_MODEL_MAX_TOKENS', '500')), + 'base_url': os.getenv('TOOL_MODEL_BASE_URL'), + } + + # 多模态模型配置 + self.vision_model = { + 'name': os.getenv('VISION_MODEL_NAME', 'Qwen/Qwen3-VL-32B-Instruct'), + 'temperature': float(os.getenv('VISION_MODEL_TEMPERATURE', '0.5')), + 'max_tokens': int(os.getenv('VISION_MODEL_MAX_TOKENS', '1000')), + 'base_url': os.getenv('VISION_MODEL_BASE_URL'), + } + + # 视觉工具LLM配置(向后兼容) + self.vision_llm_temperature = float(os.getenv('VISION_LLM_TEMPERATURE', '0.3')) + self.vision_llm_max_tokens = int(os.getenv('VISION_LLM_MAX_TOKENS', '10')) + self.vision_llm_timeout = int(os.getenv('VISION_LLM_TIMEOUT', '10')) + + # NPS工具系统LLM配置(向后兼容) + self.nps_llm_temperature = float(os.getenv('NPS_LLM_TEMPERATURE', '0.3')) + self.nps_llm_max_tokens = int(os.getenv('NPS_LLM_MAX_TOKENS', '100')) + self.nps_llm_timeout = int(os.getenv('NPS_LLM_TIMEOUT', '10')) + + def get_model_config(self, model_type: ModelType) -> Dict[str, Any]: + """ + 获取指定类型的模型配置 + """ + if model_type == ModelType.MAIN: + return self.main_model.copy() + elif model_type == ModelType.TOOL: + return self.tool_model.copy() + elif model_type == ModelType.VISION: + return self.vision_model.copy() + else: + return self.main_model.copy() + + def get_api_config(self) -> Dict[str, str]: + """ + 获取API配置 + """ + return { + 'api_key': self.api_key, + 'api_url': self.api_url, + } + + def is_valid(self) -> bool: + """ + 检查配置是否有效 + """ + if not self.api_key: + return False + return self.api_key not in ('your-api-key', '') + + def get_summary(self) -> str: + """ + 获取配置摘要 + """ + try: + provider = llm_providers.resolve_provider() + except Exception: + provider = "unknown" + return f"""多层模型配置: + provider: {provider} + 主模型: {self.main_model['name']} + 工具模型: {self.tool_model['name']} + 多模态模型: {self.vision_model['name']} + API地址: {self.api_url}""" + + +_global_config: Optional[ModelConfig] = None + + +def get_model_config() -> ModelConfig: + """ + 获取全局模型配置实例(单例模式) + """ + global _global_config + if _global_config is None: + _global_config = ModelConfig() + return _global_config + + +__all__ = ["ModelType", "ModelConfig", "get_model_config"] diff --git a/src/cortex/echo_cortex.py b/src/cortex/echo_cortex.py new file mode 100644 index 0000000..775df61 --- /dev/null +++ b/src/cortex/echo_cortex.py @@ -0,0 +1,137 @@ +""" +EchoCortex - 大脑皮层的 MVP 模拟模块。 + +用于验证 CentralRouter + Gateway 的端到端通路。 +实际回复会调用 LLMGateway,但 MVP 阶段先用 Echo 逻辑。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +class EchoCortex(BaseModule): + """ + Echo 皮层模块。 + + 处理 chat 通道请求,返回一个模拟的 LLM 回复, + 同时展示如何向 hippocampus 请求记忆上下文。 + """ + + module_id = "cortex.echo" + module_type = "cortex" + + def __init__(self, router: "CentralRouter") -> None: + super().__init__(router) + + async def handle(self, packet: Packet) -> Packet: + """ + 处理 chat 请求。 + + v4.0:EchoCortex 作为 cortex 入口,负责: + 1. 查询海马体获取记忆上下文 + 2. 组装 system prompt + 记忆 + 用户输入 + 3. 通过 LLMGateway 调用真实 LLM + 4. 触发记忆存储事件 + """ + user_input = packet.payload.get("content", "") + + # 1) 查询记忆 + memory_context = [] + try: + memory_resp = await self.request( + target="limbic.hippocampus", + channel="memory_query", + payload={"query": user_input, "limit": 3}, + metadata=packet.metadata, + ) + if not memory_resp.is_error(): + memory_context = memory_resp.payload.get("memories", []) + except Exception: + pass + + # 2) 构造消息 + messages = self._build_messages(user_input, memory_context) + + # 3) 根据配置决定是否调用真实 LLM + reply = "" + use_llm = packet.payload.get("use_llm", False) + + if use_llm: + try: + llm_resp = await self.request( + target="nervous_system.gateway.llm", + channel="llm_chat", + payload={"messages": messages, "task_type": "main"}, + metadata=packet.metadata, + ) + if not llm_resp.is_error(): + reply = llm_resp.payload.get("content", "") + else: + reply = f"【Echo 降级回复】我收到了你的消息:\"{user_input}\"。(LLM 错误: {llm_resp.payload.get('error')})" + except Exception as exc: + reply = f"【Echo 降级回复】我收到了你的消息:\"{user_input}\"。(LLM 调用失败: {exc})" + + if not reply: + reply = self._build_echo_reply(user_input, memory_context) + + # 4) 发送记忆存储事件 + await self.emit( + channel="memory_store", + payload={ + "role": "user", + "content": user_input, + "user_id": packet.metadata.get("user_id", "default"), + }, + ) + await self.emit( + channel="memory_store", + payload={ + "role": "assistant", + "content": reply, + "user_id": packet.metadata.get("user_id", "default"), + }, + ) + + return packet.response({ + "role": "assistant", + "content": reply, + "model": "cortex.echo-via-llm_gateway", + "memory_count": len(memory_context), + }) + + def _build_echo_reply(self, user_input: str, memory_context: list) -> str: + """ + 构造 Echo 回复(不调用真实 LLM)。 + """ + if not user_input: + return "你好,我是 Neo。请告诉我你想聊什么?" + + if memory_context: + return ( + f"【Echo 回复】我收到了你的消息:\"{user_input}\"。\n" + f"(我回忆起 {len(memory_context)} 条相关记忆)" + ) + + return f"【Echo 回复】我收到了你的消息:\"{user_input}\"。" + + def _build_messages(self, user_input: str, memory_context: list) -> list: + """ + 构造发送给 LLM 的消息列表。 + """ + system_prompt = "你是 Neo,一个温暖、聪明的 AI 伴侣。请根据上下文自然回复。" + messages = [{"role": "system", "content": system_prompt}] + + for mem in memory_context: + role = mem.get("role", "user") + content = mem.get("content", "") + messages.append({"role": role, "content": content}) + + messages.append({"role": "user", "content": user_input}) + return messages diff --git a/src/cortex/llm_core.py b/src/cortex/llm_core.py new file mode 100644 index 0000000..1e00a39 --- /dev/null +++ b/src/cortex/llm_core.py @@ -0,0 +1,340 @@ +""" +Cortex LLM Core - v4.0 大脑皮层 LLM 核心。 + +统一的 LLM 推理入口,封装 LangChain/OpenAI 细节,支持: +- 多层模型(main/tool/vision) +- 同步/异步聊天 +- 模板渲染 +- debug 日志 + +本模块从 v3.1.0 的 src.core.langchain_llm 迁移并抽象而来。 +""" + +from __future__ import annotations + +import os +import time +from typing import Any, AsyncIterator, Dict, List, Optional + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from langchain_openai import ChatOpenAI + +from src.cortex.config.model_config import ModelType, get_model_config +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet +from src.tools.debug_logger import get_debug_logger + +logger = get_debug_logger() + + +class LLMCore(BaseModule): + """ + 大脑皮层 LLM 核心模块。 + + 对外通过 CentralRouter 接收 llm_chat / llm_stream / llm_template 请求。 + 内部管理 LangChainLLM 实例池。 + """ + + module_id = "cortex.llm_core" + module_type = "cortex" + + def __init__(self, router): + super().__init__(router) + self._model_router: Optional[ModelRouter] = None + + def _get_model_router(self) -> ModelRouter: + """ + 懒加载模型路由器,避免启动期强制初始化 LLM。 + """ + if self._model_router is None: + self._model_router = ModelRouter() + return self._model_router + + async def handle(self, packet: Packet) -> Packet: + """ + 处理 LLM 相关请求。 + """ + channel = packet.channel + try: + model_router = self._get_model_router() + except Exception as exc: + return Packet.error(packet, f"LLM 初始化失败: {exc}", code="LLM_INIT_ERROR") + + if channel == "llm_chat": + messages = packet.payload.get("messages", []) + task_type = packet.payload.get("task_type", "main") + reply = model_router.route(task_type).chat(messages) + return packet.response({"role": "assistant", "content": reply}) + + if channel == "llm_stream": + # 同步场景下返回完整回复;真实流式请使用 handle_stream + messages = packet.payload.get("messages", []) + task_type = packet.payload.get("task_type", "main") + reply = model_router.route(task_type).chat(messages) + return packet.response({"role": "assistant", "content": reply, "stream": False}) + + if channel == "llm_template": + template = packet.payload.get("template", "") + variables = packet.payload.get("variables", {}) + history = packet.payload.get("history") + task_type = packet.payload.get("task_type", "main") + reply = model_router.route(task_type).chat_with_template(template, variables, history) + return packet.response({"role": "assistant", "content": reply}) + + if channel == "llm_info": + task_type = packet.payload.get("task_type", "main") + info = model_router.route(task_type).get_model_info() + return packet.response(info) + + return packet.response({"status": "unknown_channel", "channel": channel}) + + async def handle_stream(self, packet: Packet) -> AsyncIterator[Packet]: + """ + 处理 LLM 流式请求。 + + 支持 channel: + - llm_stream: 逐 token 返回 content chunk,最后 yield done + """ + channel = packet.channel + try: + model_router = self._get_model_router() + except Exception as exc: + yield packet.stream_error(f"LLM 初始化失败: {exc}", code="LLM_INIT_ERROR") + return + + if channel == "llm_stream": + messages = packet.payload.get("messages", []) + task_type = packet.payload.get("task_type", "main") + try: + async for chunk in model_router.route(task_type).chat_stream(messages): + yield packet.stream_chunk({"role": "assistant", "content": chunk}) + except Exception as exc: + logger.log_error('LLMCore', f'流式处理异常: {exc}', exc) + yield packet.stream_error(str(exc), code="LLM_STREAM_ERROR") + yield packet.stream_done() + return + + # 其他 channel 默认委托给 handle 并 yield done + response = await self.handle(packet) + if response.is_error(): + yield response + return + yield packet.stream_chunk(response.payload) + yield packet.stream_done() + + +class LangChainLLM: + """ + 基于 LangChain 的 LLM 封装。 + + 保持与 v3.1.0 基本一致的接口,方便平滑迁移。 + """ + + def __init__(self, model_type: ModelType = ModelType.MAIN): + self.model_type = model_type + self.config = get_model_config() + + api_config = self.config.get_api_config() + self.api_key = api_config['api_key'] or 'placeholder-key' + self.api_url = api_config['api_url'] + + model_config = self.config.get_model_config(model_type) + self.model_name = model_config['name'] + self.temperature = model_config['temperature'] + self.max_tokens = model_config['max_tokens'] + + timeout_defaults = { + ModelType.MAIN: 60, + ModelType.TOOL: 45, + ModelType.VISION: 90 + } + default_timeout = timeout_defaults.get(model_type, 45) + timeout_env_keys = { + ModelType.MAIN: 'MAIN_MODEL_TIMEOUT', + ModelType.TOOL: 'TOOL_MODEL_TIMEOUT', + ModelType.VISION: 'VISION_MODEL_TIMEOUT' + } + timeout_env_key = timeout_env_keys.get(model_type, 'LLM_TIMEOUT') + self.timeout = int(os.getenv(timeout_env_key, str(default_timeout))) + + base_url = self.api_url + if '/chat/completions' in base_url: + base_url = base_url.replace('/chat/completions', '') + + self.llm = ChatOpenAI( + model=self.model_name, + temperature=self.temperature, + max_tokens=self.max_tokens, + api_key=self.api_key, + base_url=base_url, + timeout=self.timeout + ) + + logger.log_module('LangChainLLM', f'初始化{model_type.value}模型', { + 'model_name': self.model_name, + 'temperature': self.temperature, + 'max_tokens': self.max_tokens, + 'timeout': self.timeout + }) + + if not self.config.is_valid(): + print("警告: 未设置有效的API密钥,请在.env文件中配置LLM_API_KEY") + + def _convert_messages_to_langchain(self, messages: List[Dict[str, str]]) -> List: + """将标准消息格式转换为 LangChain 消息对象。""" + langchain_messages = [] + for msg in messages: + role = msg.get('role', 'user') + content = msg.get('content', '') + if role == 'system': + langchain_messages.append(SystemMessage(content=content)) + elif role == 'assistant': + langchain_messages.append(AIMessage(content=content)) + else: + langchain_messages.append(HumanMessage(content=content)) + return langchain_messages + + def chat(self, messages: List[Dict[str, str]]) -> str: + """发送聊天请求。""" + try: + logger.log_module('LangChainLLM', f'准备发送{self.model_type.value}模型请求', { + 'message_count': len(messages), + 'model_name': self.model_name + }) + + for i, msg in enumerate(messages): + logger.log_prompt( + 'LangChainLLM', + msg['role'], + msg['content'], + {'message_index': i, 'total_messages': len(messages), 'model_type': self.model_type.value} + ) + + langchain_messages = self._convert_messages_to_langchain(messages) + start_time = time.time() + response = self.llm.invoke(langchain_messages) + elapsed_time = time.time() - start_time + + reply_content = response.content + logger.log_response('LangChainLLM', { + 'content': reply_content, + 'model': self.model_name, + 'model_type': self.model_type.value + }, 200, elapsed_time) + + return reply_content + except Exception as e: + logger.log_error('LangChainLLM', f'LLM调用错误: {str(e)}', e) + return f"抱歉,处理请求时出现错误: {str(e)}" + + async def chat_stream(self, messages: List[Dict[str, str]]) -> AsyncIterator[str]: + """流式发送聊天请求,逐 token 返回字符串 chunk。""" + try: + logger.log_module('LangChainLLM', f'准备流式发送{self.model_type.value}模型请求', { + 'message_count': len(messages), + 'model_name': self.model_name + }) + + for i, msg in enumerate(messages): + logger.log_prompt( + 'LangChainLLM', + msg['role'], + msg['content'], + {'message_index': i, 'total_messages': len(messages), 'model_type': self.model_type.value} + ) + + langchain_messages = self._convert_messages_to_langchain(messages) + start_time = time.time() + async for chunk in self.llm.astream(langchain_messages): + content = getattr(chunk, 'content', None) + if content: + yield content + elapsed_time = time.time() - start_time + logger.log_info('LangChainLLM', '流式响应完成', { + 'elapsed_time': elapsed_time, + 'model_type': self.model_type.value, + }) + except Exception as e: + logger.log_error('LangChainLLM', f'流式LLM调用错误: {str(e)}', e) + yield f"抱歉,处理请求时出现错误: {str(e)}" + + def chat_with_template( + self, + template: str, + variables: Dict[str, Any], + message_history: Optional[List[Dict[str, str]]] = None + ) -> str: + """使用模板和变量进行聊天。""" + try: + if message_history: + prompt = ChatPromptTemplate.from_messages([ + ("system", template), + MessagesPlaceholder(variable_name="history"), + ("human", "{input}") + ]) + history_messages = self._convert_messages_to_langchain(message_history) + variables['history'] = history_messages + else: + prompt = ChatPromptTemplate.from_messages([ + ("system", template), + ("human", "{input}") + ]) + + chain = prompt | self.llm | StrOutputParser() + start_time = time.time() + response = chain.invoke(variables) + elapsed_time = time.time() - start_time + + logger.log_info('LangChainLLM', '模板聊天完成', { + 'elapsed_time': elapsed_time, + 'model_type': self.model_type.value, + 'response_length': len(response) + }) + return response + except Exception as e: + logger.log_error('LangChainLLM', f'模板聊天错误: {str(e)}', e) + return f"抱歉,处理请求时出现错误: {str(e)}" + + def get_model_info(self) -> Dict[str, Any]: + """获取模型信息。""" + return { + 'model_type': self.model_type.value, + 'model_name': self.model_name, + 'temperature': self.temperature, + 'max_tokens': self.max_tokens + } + + +class ModelRouter: + """ + 模型路由器:根据任务类型自动选择合适的模型。 + """ + + def __init__(self): + self.main_llm = LangChainLLM(ModelType.MAIN) + self.tool_llm = LangChainLLM(ModelType.TOOL) + self.vision_llm = LangChainLLM(ModelType.VISION) + + logger.log_module('ModelRouter', '模型路由器初始化完成', { + 'main_model': self.main_llm.model_name, + 'tool_model': self.tool_llm.model_name, + 'vision_model': self.vision_llm.model_name + }) + + def route(self, task_type: str = 'main') -> LangChainLLM: + """根据任务类型路由。""" + if task_type == 'tool': + return self.tool_llm + elif task_type == 'vision': + return self.vision_llm + return self.main_llm + + def get_model_by_type(self, model_type: ModelType) -> LangChainLLM: + """根据模型类型获取实例。""" + if model_type == ModelType.TOOL: + return self.tool_llm + elif model_type == ModelType.VISION: + return self.vision_llm + return self.main_llm diff --git a/src/cortex/providers/__init__.py b/src/cortex/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cortex/providers/registry.py b/src/cortex/providers/registry.py new file mode 100644 index 0000000..7bc0c12 --- /dev/null +++ b/src/cortex/providers/registry.py @@ -0,0 +1,182 @@ +""" +Cortex Provider Registry - v4.0 供应商注册表 + +位于 src/cortex/providers/,属于大脑皮层(Cortex)的认知基础设施。 + +提供 6 个 OpenAI-兼容 preset + 统一的环境变量解析入口。 + +注意:本模块从 v3.1.0 的 src.core.llm_providers 迁移而来,逻辑保持一致。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +class ConfigError(Exception): + """LLM 配置错误(如 custom 预设缺少 LLM_BASE_URL / LLM_MODEL_NAME)。""" + + +@dataclass(frozen=True) +class LLMProvider: + """ + LLM 供应商预设。 + + Attributes: + name: 内部名称(小写,用于环境变量匹配) + base_url: 默认 API 根地址(不含 /chat/completions) + default_model: 默认模型名(custom 时为 None) + """ + + name: str + base_url: Optional[str] + default_model: Optional[str] + + +# 6 个 preset +PROVIDERS: Dict[str, LLMProvider] = { + "openai": LLMProvider( + name="openai", + base_url="https://api.openai.com/v1", + default_model="gpt-4o-mini", + ), + "deepseek": LLMProvider( + name="deepseek", + base_url="https://api.deepseek.com/v1", + default_model="deepseek-chat", + ), + "siliconflow": LLMProvider( + name="siliconflow", + base_url="https://api.siliconflow.cn/v1", + default_model="deepseek-ai/DeepSeek-V3.2", + ), + "moonshot": LLMProvider( + name="moonshot", + base_url="https://api.moonshot.cn/v1", + default_model="moonshot-v1-8k", + ), + "zhipu": LLMProvider( + name="zhipu", + base_url="https://open.bigmodel.cn/api/paas/v4", + default_model="glm-4-flash", + ), + "custom": LLMProvider( + name="custom", + base_url=None, + default_model=None, + ), +} + + +def resolve_provider() -> str: + """ + 解析当前生效的 provider 名称。 + + 优先级: + 1. ``LLM_PROVIDER`` 显式设置 + 2. ``SILICONFLOW_API_URL`` 含 ``siliconflow.cn`` → siliconflow + 3. ``SILICONFLOW_API_KEY`` 已设 → siliconflow(向后兼容) + 4. 默认 ``openai`` + """ + explicit = (os.getenv("LLM_PROVIDER") or "").strip().lower() + if explicit: + if explicit in PROVIDERS: + return explicit + return "custom" + + legacy_url = (os.getenv("SILICONFLOW_API_URL") or "").strip().lower() + if "siliconflow.cn" in legacy_url: + return "siliconflow" + + if (os.getenv("SILICONFLOW_API_KEY") or "").strip(): + return "siliconflow" + + return "openai" + + +def get_provider_obj(name: Optional[str] = None) -> LLMProvider: + """ + 取得 provider 的 dataclass 实例(不存在时返回 custom)。 + """ + target = (name or resolve_provider()).lower() + return PROVIDERS.get(target, PROVIDERS["custom"]) + + +def resolve_api_key() -> str: + """ + 解析 API key。 + + 优先级:``LLM_API_KEY`` > ``OPENAI_API_KEY`` > ``SILICONFLOW_API_KEY`` > "" + """ + for k in ("LLM_API_KEY", "OPENAI_API_KEY", "SILICONFLOW_API_KEY"): + v = (os.getenv(k) or "").strip() + if v: + return v + return "" + + +def resolve_base_url(provider: Optional[str] = None) -> str: + """ + 解析 base_url。 + + 优先级:``LLM_BASE_URL`` > provider preset 的默认 base_url。 + custom 预设若 ``LLM_BASE_URL`` 未设则 raise ConfigError。 + """ + explicit = (os.getenv("LLM_BASE_URL") or "").strip() + if explicit: + return explicit + + obj = get_provider_obj(provider) + if obj.base_url: + return obj.base_url + + raise ConfigError("LLM_BASE_URL 未设置(custom 供应商必须显式提供)") + + +def resolve_model_name(provider: Optional[str] = None) -> str: + """ + 解析模型名。 + + 优先级:``LLM_MODEL_NAME`` > provider preset 的 default_model。 + custom 预设若 ``LLM_MODEL_NAME`` 未设则 raise ConfigError。 + """ + explicit = (os.getenv("LLM_MODEL_NAME") or "").strip() + if explicit: + return explicit + + obj = get_provider_obj(provider) + if obj.default_model: + return obj.default_model + + raise ConfigError("LLM_MODEL_NAME 未设置(custom 供应商必须显式提供)") + + +def get_provider_summary() -> Dict[str, Any]: + """ + 返回当前生效的 provider 配置(供 /api/llm/config 端点使用)。 + """ + provider_name = resolve_provider() + base_url = resolve_base_url(provider_name) + model_name = resolve_model_name(provider_name) + api_key = resolve_api_key() + return { + "provider": provider_name, + "base_url": base_url, + "model_name": model_name, + "api_key_set": bool(api_key), + } + + +__all__ = [ + "ConfigError", + "LLMProvider", + "PROVIDERS", + "resolve_provider", + "resolve_api_key", + "resolve_base_url", + "resolve_model_name", + "get_provider_obj", + "get_provider_summary", +] diff --git a/src/gui/__init__.py b/src/gui/__init__.py deleted file mode 100644 index 52e6adb..0000000 --- a/src/gui/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Neo Agent - GUI modules - -图形界面模块包含所有GUI相关的组件 - -Usage: - from src.gui.gui_enhanced import EnhancedChatDebugGUI - from src.gui.database_gui import DatabaseGUI - from src.gui.nps_gui import NPSManagerGUI - from src.gui.schedule_gui import ScheduleGUI - from src.gui.settings_migration_gui import SettingsMigrationGUI -""" - -__all__ = [ - 'gui_enhanced', - 'database_gui', - 'nps_gui', - 'schedule_gui', - 'settings_migration_gui', -] diff --git a/src/gui/database_gui.py b/src/gui/database_gui.py deleted file mode 100644 index 47b2eaa..0000000 --- a/src/gui/database_gui.py +++ /dev/null @@ -1,1758 +0,0 @@ -""" -数据库管理GUI组件 -提供可视化界面管理数据库内容 -""" - -import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox, simpledialog -from typing import Dict, Any, List -from src.core.database_manager import DatabaseManager -from src.tools.tooltip_utils import create_treeview_tooltip - - -class DatabaseManagerGUI: - """ - 数据库管理GUI界面 - 提供数据库的可视化管理、编辑功能 - """ - - def __init__(self, parent_frame, db_manager: DatabaseManager): - """ - 初始化数据库管理GUI - - Args: - parent_frame: 父容器 - db_manager: 数据库管理器实例 - """ - self.parent = parent_frame - self.db = db_manager - - # 自动刷新相关 - self.auto_refresh_enabled = True - self.refresh_interval = 2000 # 默认2秒刷新一次 - self.refresh_job = None - - # 数据缓存(用于tooltip性能优化) - self._base_facts_cache = None - self._entities_cache = {} - self._emotions_cache = None - - # 创建界面 - self.create_widgets() - - # 首次刷新数据 - self.refresh_all() - - # 启动自动刷新 - self.start_auto_refresh() - - # 绑定窗口关闭事件 - self.parent.bind('', self.on_destroy) - - def on_destroy(self, event=None): - """窗口销毁时的清理工作""" - self.stop_auto_refresh() - - def create_widgets(self): - """ - 创建所有GUI组件 - """ - # 顶部工具栏 - toolbar = ttk.Frame(self.parent) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(toolbar, text="📑 数据库管理", font=("微软雅黑", 10, "bold")).pack(side=tk.LEFT, padx=5) - - ttk.Button(toolbar, text="🔄 刷新全部", command=self.refresh_all, width=10).pack(side=tk.LEFT, padx=2) - - # 自动刷新开关 - self.auto_refresh_btn = ttk.Button(toolbar, text="⏸️ 暂停刷新", command=self.toggle_auto_refresh, width=10) - self.auto_refresh_btn.pack(side=tk.LEFT, padx=2) - - # 刷新间隔设置 - ttk.Label(toolbar, text="间隔:").pack(side=tk.LEFT, padx=(10, 2)) - self.interval_var = tk.StringVar(value="2") - interval_combo = ttk.Combobox(toolbar, textvariable=self.interval_var, width=5, - values=["1", "2", "3", "5", "10"]) - interval_combo.pack(side=tk.LEFT, padx=2) - interval_combo.bind('<>', self.change_refresh_interval) - ttk.Label(toolbar, text="秒").pack(side=tk.LEFT, padx=2) - - ttk.Button(toolbar, text="📊 统计信息", command=self.show_statistics, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑清空数据", command=self.clear_confirm, width=10).pack(side=tk.LEFT, padx=2) - - # 刷新状态指示器 - self.refresh_indicator = ttk.Label(toolbar, text="🟢", font=("微软雅黑", 12)) - self.refresh_indicator.pack(side=tk.RIGHT, padx=5) - - # 最后刷新时间 - self.last_refresh_label = ttk.Label(toolbar, text="", font=("微软雅黑", 8), foreground="gray") - self.last_refresh_label.pack(side=tk.RIGHT, padx=5) - - # 统计信息标签 - self.stats_label = ttk.Label(toolbar, text="", font=("微软雅黑", 9)) - self.stats_label.pack(side=tk.RIGHT, padx=10) - - # 分割线 - ttk.Separator(self.parent, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=5, pady=2) - - # 创建标签页 - self.notebook = ttk.Notebook(self.parent) - self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 标签页1:基础知识 - self.create_base_knowledge_tab() - - # 标签页2:实体管理 - self.create_entities_tab() - - # 标签页3:短期记忆 - self.create_short_term_tab() - - # 标签页4:长期记忆 - self.create_long_term_tab() - - # 标签页5:情感分析历史 - self.create_emotion_tab() - - # 标签页6:环境管理 - self.create_environments_tab() - - # 标签页7:域管理 - self.create_domains_tab() - - # 标签页8:日程管理 - self.create_schedules_tab() - - # 标签页8:日程管理 - self.create_schedules_tab() - - def create_base_knowledge_tab(self): - """ - 创建基础知识管理标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="🔒 基础知识") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="➕添加", command=self.add_base_knowledge, width=8).pack(side=tk.LEFT, padx=2) - #ttk.Button(toolbar, text="✏编辑", command=self.edit_base_knowledge, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑删除", command=self.delete_base_knowledge, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_base_knowledge, width=8).pack(side=tk.LEFT, padx=2) - - # 搜索框 - ttk.Label(toolbar, text="搜索:").pack(side=tk.LEFT, padx=(20, 5)) - self.base_search_var = tk.StringVar() - self.base_search_entry = ttk.Entry(toolbar, textvariable=self.base_search_var, width=20) - self.base_search_entry.pack(side=tk.LEFT, padx=2) - self.base_search_entry.bind('', lambda e: self.refresh_base_knowledge()) - - # 创建Treeview显示列表 - tree_frame = ttk.Frame(tab) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 滚动条 - scrollbar_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL) - - # Treeview - self.base_tree = ttk.Treeview( - tree_frame, - columns=("entity", "content", "category", "confidence", "created"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - # 配置列 - 优化列宽 - self.base_tree.heading("entity", text="实体名称") - self.base_tree.heading("content", text="内容") - self.base_tree.heading("category", text="分类") - self.base_tree.heading("confidence", text="置信度") - self.base_tree.heading("created", text="创建时间") - - self.base_tree.column("entity", width=150, minwidth=100, stretch=False) - self.base_tree.column("content", width=400, minwidth=200, stretch=True) - self.base_tree.column("category", width=100, minwidth=80, stretch=False) - self.base_tree.column("confidence", width=80, minwidth=70, stretch=False) - self.base_tree.column("created", width=160, minwidth=140, stretch=False) - - scrollbar_y.config(command=self.base_tree.yview) - scrollbar_x.config(command=self.base_tree.xview) - - # 布局 - self.base_tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - tree_frame.grid_rowconfigure(0, weight=1) - tree_frame.grid_columnconfigure(0, weight=1) - - # 双击编辑 - self.base_tree.bind("", lambda e: self.edit_base_knowledge()) - - # 添加鼠标悬停提示 - def get_base_tooltip(item_id, values, tags): - """获取基础知识的工具提示文本""" - if not tags: - return None - - fact_id = tags[0] - # 使用缓存的数据,避免每次悬停都查询数据库 - if self._base_facts_cache is None: - self._base_facts_cache = self.db.get_all_base_facts() - - fact = next((f for f in self._base_facts_cache if f['id'] == fact_id), None) - - if fact: - tooltip_text = f"实体名称: {fact['entity_name']}\n" - tooltip_text += f"分类: {fact['category']}\n" - tooltip_text += f"置信度: {fact['confidence']:.2f}\n" - tooltip_text += f"创建时间: {fact['created_at'][:19] if fact.get('created_at') else 'N/A'}\n" - tooltip_text += f"\n完整内容:\n{fact['content']}" - return tooltip_text - return None - - create_treeview_tooltip(self.base_tree, get_base_tooltip) - - def create_entities_tab(self): - """ - 创建实体管理标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="📦 实体管理") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="➕新建实体", command=self.add_entity, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="👁查看详情", command=self.view_entity_detail, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑删除", command=self.delete_entity, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_entities, width=8).pack(side=tk.LEFT, padx=2) - - # 搜索框 - ttk.Label(toolbar, text="搜索:").pack(side=tk.LEFT, padx=(20, 5)) - self.entity_search_var = tk.StringVar() - self.entity_search_entry = ttk.Entry(toolbar, textvariable=self.entity_search_var, width=20) - self.entity_search_entry.pack(side=tk.LEFT, padx=2) - self.entity_search_entry.bind('', lambda e: self.refresh_entities()) - - # 创建Treeview - tree_frame = ttk.Frame(tab) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - scrollbar_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL) - - self.entity_tree = ttk.Treeview( - tree_frame, - columns=("name", "has_def", "info_count", "created", "updated"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - self.entity_tree.heading("name", text="实体名称") - self.entity_tree.heading("has_def", text="有定义") - self.entity_tree.heading("info_count", text="相关信息数") - self.entity_tree.heading("created", text="创建时间") - self.entity_tree.heading("updated", text="更新时间") - - self.entity_tree.column("name", width=250, minwidth=150, stretch=True) - self.entity_tree.column("has_def", width=80, minwidth=60, stretch=False) - self.entity_tree.column("info_count", width=110, minwidth=90, stretch=False) - self.entity_tree.column("created", width=160, minwidth=140, stretch=False) - self.entity_tree.column("updated", width=160, minwidth=140, stretch=False) - - scrollbar_y.config(command=self.entity_tree.yview) - scrollbar_x.config(command=self.entity_tree.xview) - - self.entity_tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - tree_frame.grid_rowconfigure(0, weight=1) - tree_frame.grid_columnconfigure(0, weight=1) - - # 双击查看详情 - self.entity_tree.bind("", lambda e: self.view_entity_detail()) - - # 添加鼠标悬停提示 - def get_entity_tooltip(item_id, values, tags): - """获取实体的工具提示文本""" - if not tags: - return None - - entity_uuid = tags[0] - # 使用缓存避免重复查询 - if entity_uuid not in self._entities_cache: - entity = self.db.get_entity_by_uuid(entity_uuid) - definition = self.db.get_entity_definition(entity_uuid) - self._entities_cache[entity_uuid] = (entity, definition) - else: - entity, definition = self._entities_cache[entity_uuid] - - if entity: - tooltip_text = f"实体名称: {entity['name']}\n" - tooltip_text += f"UUID: {entity['uuid']}\n" - tooltip_text += f"创建时间: {entity['created_at'][:19]}\n" - tooltip_text += f"更新时间: {entity['updated_at'][:19]}\n" - - if definition: - tooltip_text += f"\n定义:\n{definition['content'][:200]}{'...' if len(definition['content']) > 200 else ''}" - - return tooltip_text - return None - - create_treeview_tooltip(self.entity_tree, get_entity_tooltip) - - def create_short_term_tab(self): - """ - 创建短期记忆标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="💭 短期记忆") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="🗑清空全部", command=self.clear_short_term, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_short_term, width=8).pack(side=tk.LEFT, padx=2) - - self.short_term_count_label = ttk.Label(toolbar, text="消息数: 0", font=("微软雅黑", 9)) - self.short_term_count_label.pack(side=tk.RIGHT, padx=10) - - # 显示区域 - text_frame = ttk.Frame(tab) - text_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - self.short_term_text = scrolledtext.ScrolledText( - text_frame, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9" - ) - self.short_term_text.pack(fill=tk.BOTH, expand=True) - - # 配置标签颜色 - self.short_term_text.tag_config("user", foreground="#0066cc", font=("微软雅黑", 9, "bold")) - self.short_term_text.tag_config("assistant", foreground="#ff6600", font=("微软雅黑", 9, "bold")) - self.short_term_text.tag_config("timestamp", foreground="#999999", font=("微软雅黑", 8)) - - def create_long_term_tab(self): - """ - 创建长期记忆标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="📚 长期记忆") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="🗑清空全部", command=self.clear_long_term, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_long_term, width=8).pack(side=tk.LEFT, padx=2) - - self.long_term_count_label = ttk.Label(toolbar, text="概括数: 0", font=("微软雅黑", 9)) - self.long_term_count_label.pack(side=tk.RIGHT, padx=10) - - # 显示区域 - text_frame = ttk.Frame(tab) - text_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - self.long_term_text = scrolledtext.ScrolledText( - text_frame, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9" - ) - self.long_term_text.pack(fill=tk.BOTH, expand=True) - - def create_emotion_tab(self): - """ - 创建情感分析历史标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="💖 情感分析") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="👁查看详情", command=self.view_emotion_detail, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_emotion, width=8).pack(side=tk.LEFT, padx=2) - - self.emotion_count_label = ttk.Label(toolbar, text="分析数: 0", font=("微软雅黑", 9)) - self.emotion_count_label.pack(side=tk.RIGHT, padx=10) - - # 创建Treeview - tree_frame = ttk.Frame(tab) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - scrollbar_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL) - - self.emotion_tree = ttk.Treeview( - tree_frame, - columns=("relationship", "tone", "score", "created"), - show="headings", - yscrollcommand=scrollbar_y.set - ) - - self.emotion_tree.heading("relationship", text="关系类型") - self.emotion_tree.heading("tone", text="情感基调") - self.emotion_tree.heading("score", text="总评分") - self.emotion_tree.heading("created", text="分析时间") - - self.emotion_tree.column("relationship", width=180, minwidth=120, stretch=True) - self.emotion_tree.column("tone", width=180, minwidth=120, stretch=True) - self.emotion_tree.column("score", width=100, minwidth=80, stretch=False) - self.emotion_tree.column("created", width=180, minwidth=150, stretch=False) - - scrollbar_y.config(command=self.emotion_tree.yview) - - self.emotion_tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - - tree_frame.grid_rowconfigure(0, weight=1) - tree_frame.grid_columnconfigure(0, weight=1) - - # 双击查看详情 - self.emotion_tree.bind("", lambda e: self.view_emotion_detail()) - - # 添加鼠标悬停提示 - def get_emotion_tooltip(item_id, values, tags): - """获取情感分析的工具提示文本""" - if not tags: - return None - - emotion_uuid = tags[0] - # 使用缓存的情感历史数据 - if self._emotions_cache is None: - self._emotions_cache = self.db.get_emotion_history() - - emotion = next((e for e in self._emotions_cache if e['uuid'] == emotion_uuid), None) - - if emotion: - tooltip_text = f"关系类型: {emotion.get('relationship_type', '未知')}\n" - tooltip_text += f"情感基调: {emotion.get('emotional_tone', '未知')}\n" - tooltip_text += f"总评分: {emotion.get('overall_score', 0)}/100\n" - tooltip_text += f"分析时间: {emotion['created_at'][:19]}\n" - tooltip_text += f"\n五维度评分:\n" - tooltip_text += f"• 亲密度: {emotion.get('intimacy', 0)}/100\n" - tooltip_text += f"• 信任度: {emotion.get('trust', 0)}/100\n" - tooltip_text += f"• 愉悦度: {emotion.get('pleasure', 0)}/100\n" - tooltip_text += f"• 共鸣度: {emotion.get('resonance', 0)}/100\n" - tooltip_text += f"• 依赖度: {emotion.get('dependence', 0)}/100" - return tooltip_text - return None - - create_treeview_tooltip(self.emotion_tree, get_emotion_tooltip) - - def create_environments_tab(self): - """ - 创建环境管理标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="🗺️ 环境管理") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="➕添加环境", command=self.add_environment, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="✏编辑", command=self.edit_environment, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑删除", command=self.delete_environment, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="✅激活", command=self.activate_environment, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_environments, width=8).pack(side=tk.LEFT, padx=2) - - self.env_count_label = ttk.Label(toolbar, text="环境数: 0", font=("微软雅黑", 9)) - self.env_count_label.pack(side=tk.RIGHT, padx=10) - - # 创建Treeview - tree_frame = ttk.Frame(tab) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - scrollbar_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL) - - self.env_tree = ttk.Treeview( - tree_frame, - columns=("name", "description", "active", "created"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - self.env_tree.heading("name", text="环境名称") - self.env_tree.heading("description", text="描述") - self.env_tree.heading("active", text="状态") - self.env_tree.heading("created", text="创建时间") - - self.env_tree.column("name", width=150, minwidth=100, stretch=False) - self.env_tree.column("description", width=350, minwidth=200, stretch=True) - self.env_tree.column("active", width=80, minwidth=60, stretch=False) - self.env_tree.column("created", width=160, minwidth=140, stretch=False) - - scrollbar_y.config(command=self.env_tree.yview) - scrollbar_x.config(command=self.env_tree.xview) - - self.env_tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - tree_frame.grid_rowconfigure(0, weight=1) - tree_frame.grid_columnconfigure(0, weight=1) - - # 双击编辑 - self.env_tree.bind("", lambda e: self.edit_environment()) - - # 添加鼠标悬停提示 - def get_env_tooltip(item_id, values, tags): - """获取环境的工具提示文本""" - if not tags: - return None - - env_uuid = tags[0] - env = self.db.get_environment(env_uuid) - - if env: - tooltip_text = f"环境: {env['name']}\n" - tooltip_text += f"描述: {env['overall_description'][:100]}...\n" if len(env.get('overall_description', '')) > 100 else f"描述: {env.get('overall_description', '')}\n" - if env.get('atmosphere'): - tooltip_text += f"氛围: {env['atmosphere']}\n" - if env.get('lighting'): - tooltip_text += f"光照: {env['lighting']}\n" - tooltip_text += f"状态: {'激活' if env.get('is_active') else '未激活'}\n" - tooltip_text += f"创建时间: {env['created_at'][:19]}" - return tooltip_text - return None - - create_treeview_tooltip(self.env_tree, get_env_tooltip) - - def create_domains_tab(self): - """ - 创建域管理标签页 - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="🏘️ 域管理") - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="➕创建域", command=self.add_domain, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="✏编辑", command=self.edit_domain, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑删除", command=self.delete_domain, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="📍管理环境", command=self.manage_domain_environments, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄刷新", command=self.refresh_domains, width=8).pack(side=tk.LEFT, padx=2) - - self.domain_count_label = ttk.Label(toolbar, text="域数: 0", font=("微软雅黑", 9)) - self.domain_count_label.pack(side=tk.RIGHT, padx=10) - - # 创建Treeview - tree_frame = ttk.Frame(tab) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - scrollbar_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL) - - self.domain_tree = ttk.Treeview( - tree_frame, - columns=("name", "description", "default_env", "env_count", "created"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - self.domain_tree.heading("name", text="域名称") - self.domain_tree.heading("description", text="描述") - self.domain_tree.heading("default_env", text="默认环境") - self.domain_tree.heading("env_count", text="环境数") - self.domain_tree.heading("created", text="创建时间") - - self.domain_tree.column("name", width=120, minwidth=100, stretch=False) - self.domain_tree.column("description", width=300, minwidth=200, stretch=True) - self.domain_tree.column("default_env", width=120, minwidth=100, stretch=False) - self.domain_tree.column("env_count", width=80, minwidth=60, stretch=False) - self.domain_tree.column("created", width=160, minwidth=140, stretch=False) - - scrollbar_y.config(command=self.domain_tree.yview) - scrollbar_x.config(command=self.domain_tree.xview) - - self.domain_tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - tree_frame.grid_rowconfigure(0, weight=1) - tree_frame.grid_columnconfigure(0, weight=1) - - # 双击编辑 - self.domain_tree.bind("", lambda e: self.edit_domain()) - - # 添加鼠标悬停提示 - def get_domain_tooltip(item_id, values, tags): - """获取域的工具提示文本""" - if not tags: - return None - - domain_uuid = tags[0] - domain = self.db.get_domain(domain_uuid) - - if domain: - tooltip_text = f"域: {domain['name']}\n" - tooltip_text += f"描述: {domain.get('description', '')}\n" - - # 获取域中的环境 - envs = self.db.get_domain_environments(domain_uuid) - if envs: - tooltip_text += f"包含环境: {', '.join([e['name'] for e in envs])}\n" - - # 显示默认环境 - if domain.get('default_environment_uuid'): - default_env = self.db.get_environment(domain['default_environment_uuid']) - if default_env: - tooltip_text += f"默认环境: {default_env['name']}\n" - - tooltip_text += f"创建时间: {domain['created_at'][:19]}" - return tooltip_text - return None - - create_treeview_tooltip(self.domain_tree, get_domain_tooltip) - - # ==================== 刷新方法 ==================== - - def refresh_all(self): - """刷新所有标签页数据""" - # 显示刷新中状态 - self.refresh_indicator.config(text="🔄") - - try: - self.refresh_base_knowledge() - self.refresh_entities() - self.refresh_short_term() - self.refresh_long_term() - self.refresh_emotion() - self.refresh_environments() - self.refresh_domains() - - # 刷新日程(如果方法存在) - if hasattr(self, 'refresh_schedules'): - self.refresh_schedules() - - self.update_statistics() - - # 刷新完成,显示绿色指示器和时间戳 - self.refresh_indicator.config(text="🟢") - from datetime import datetime - current_time = datetime.now().strftime("%H:%M:%S") - self.last_refresh_label.config(text=f"最后刷新: {current_time}") - except Exception as e: - # 刷新出错,显示红色指示器 - self.refresh_indicator.config(text="🔴") - print(f"刷新数据时出错: {e}") - - def refresh_base_knowledge(self): - """刷新基础知识列表""" - # 清空缓存 - self._base_facts_cache = None - - # 保存当前选中项 - selected_items = self.base_tree.selection() - selected_ids = [] - for item in selected_items: - tags = self.base_tree.item(item)['tags'] - if tags: - selected_ids.append(tags[0]) - - # 清空现有项 - for item in self.base_tree.get_children(): - self.base_tree.delete(item) - - # 获取所有基础知识并更新缓存 - base_facts = self.db.get_all_base_facts() - self._base_facts_cache = base_facts - - # 应用搜索过滤 - search_text = self.base_search_var.get().lower() - if search_text: - base_facts = [f for f in base_facts if search_text in f['entity_name'].lower() or search_text in f['content'].lower()] - - # 添加到树形视图 - for fact in base_facts: - item_id = self.base_tree.insert("", "end", values=( - fact['entity_name'], - fact['content'][:50] + "..." if len(fact['content']) > 50 else fact['content'], - fact['category'], - f"{fact['confidence']:.2f}", - fact['created_at'][:19] if fact.get('created_at') else "" - ), tags=(fact['id'],)) - - # 恢复选中状态 - if fact['id'] in selected_ids: - self.base_tree.selection_add(item_id) - - def refresh_entities(self): - """刷新实体列表""" - # 清空缓存 - self._entities_cache = {} - - # 保存当前选中项 - selected_items = self.entity_tree.selection() - selected_uuids = [] - for item in selected_items: - tags = self.entity_tree.item(item)['tags'] - if tags: - selected_uuids.append(tags[0]) - - # 清空现有项 - for item in self.entity_tree.get_children(): - self.entity_tree.delete(item) - - # 获取所有实体 - entities = self.db.get_all_entities() - - # 应用搜索过滤 - search_text = self.entity_search_var.get().lower() - if search_text: - entities = [e for e in entities if search_text in e['name'].lower()] - - # 添加到树形视图 - for entity in entities: - # 获取定义和相关信息 - definition = self.db.get_entity_definition(entity['uuid']) - related_info = self.db.get_entity_related_info(entity['uuid']) - - # 预填充缓存以提高tooltip性能 - self._entities_cache[entity['uuid']] = (entity, definition) - - item_id = self.entity_tree.insert("", "end", values=( - entity['name'], - "是" if definition else "否", - len(related_info), - entity['created_at'][:19], - entity['updated_at'][:19] - ), tags=(entity['uuid'],)) - - # 恢复选中状态 - if entity['uuid'] in selected_uuids: - self.entity_tree.selection_add(item_id) - - def refresh_short_term(self): - """刷新短期记忆显示""" - # 记住当前是否滚动到底部 - was_at_bottom = False - try: - # 检查是否在底部附近(允许一点误差) - yview = self.short_term_text.yview() - was_at_bottom = yview[1] >= 0.99 - except: - pass - - self.short_term_text.config(state=tk.NORMAL) - self.short_term_text.delete(1.0, tk.END) - - messages = self.db.get_short_term_messages() - self.short_term_count_label.config(text=f"消息数: {len(messages)}") - - for msg in messages: - role_text = "用户" if msg['role'] == 'user' else "助手" - timestamp = msg['timestamp'][:19] if msg.get('timestamp') else "" - - self.short_term_text.insert(tk.END, f"[{timestamp}] ", "timestamp") - self.short_term_text.insert(tk.END, f"{role_text}:\n", msg['role']) - self.short_term_text.insert(tk.END, f"{msg['content']}\n\n") - - self.short_term_text.config(state=tk.DISABLED) - - # 如果之前在底部,刷新后也滚动到底部(用于查看新消息) - if was_at_bottom or len(messages) > 0: - self.short_term_text.see(tk.END) - - def refresh_long_term(self): - """刷新长期记忆显示""" - self.long_term_text.config(state=tk.NORMAL) - self.long_term_text.delete(1.0, tk.END) - - summaries = self.db.get_long_term_summaries() - self.long_term_count_label.config(text=f"概括数: {len(summaries)}") - - for i, summary in enumerate(summaries, 1): - self.long_term_text.insert(tk.END, f"━━━━━ 主题 {i} ━━━━━\n", "header") - self.long_term_text.insert(tk.END, f"时间: {summary['created_at'][:19]} - {summary['ended_at'][:19]}\n") - self.long_term_text.insert(tk.END, f"轮数: {summary.get('rounds', 0)} 轮 | 消息: {summary.get('message_count', 0)} 条\n") - self.long_term_text.insert(tk.END, f"\n{summary['summary']}\n\n\n") - - self.long_term_text.config(state=tk.DISABLED) - - def refresh_emotion(self): - """刷新情感分析历史""" - # 清空缓存 - self._emotions_cache = None - - # 清空现有项 - for item in self.emotion_tree.get_children(): - self.emotion_tree.delete(item) - - # 获取情感分析历史并更新缓存 - emotions = self.db.get_emotion_history() - self._emotions_cache = emotions - self.emotion_count_label.config(text=f"分析数: {len(emotions)}") - - for emotion in emotions: - self.emotion_tree.insert("", "end", values=( - emotion.get('relationship_type', '未知'), - emotion.get('emotional_tone', '未知'), - f"{emotion.get('overall_score', 0)}/100", - emotion['created_at'][:19] - ), tags=(emotion['uuid'],)) - - def update_statistics(self): - """更新统计信息显示""" - stats = self.db.get_statistics() - text = f"基础知识: {stats['base_knowledge_count']} | " \ - f"实体: {stats['entities_count']} | " \ - f"短期记忆: {stats['short_term_count']} | " \ - f"长期记忆: {stats['long_term_count']} | " \ - f"情感分析: {stats['emotion_count']} | " \ - f"DB大��: {stats.get('db_size_kb', 0):.1f} KB" - self.stats_label.config(text=text) - - # ==================== 自动刷新方法 ==================== - - def start_auto_refresh(self): - """启动自动刷新""" - if self.auto_refresh_enabled and self.refresh_job is None: - self._schedule_refresh() - - def stop_auto_refresh(self): - """停止自动刷新""" - if self.refresh_job is not None: - self.parent.after_cancel(self.refresh_job) - self.refresh_job = None - - def _schedule_refresh(self): - """调度下一次刷新""" - if self.auto_refresh_enabled: - self.refresh_all() - self.refresh_job = self.parent.after(self.refresh_interval, self._schedule_refresh) - - def toggle_auto_refresh(self): - """切换自动刷新状态""" - self.auto_refresh_enabled = not self.auto_refresh_enabled - - if self.auto_refresh_enabled: - self.auto_refresh_btn.config(text="⏸️ 暂停刷新") - self.start_auto_refresh() - else: - self.auto_refresh_btn.config(text="▶️ 启动刷新") - self.stop_auto_refresh() - - def change_refresh_interval(self, event=None): - """改变刷新间隔""" - try: - interval_seconds = float(self.interval_var.get()) - self.refresh_interval = int(interval_seconds * 1000) # 转换为毫秒 - - # 如果正在自动刷新,重新调度 - if self.auto_refresh_enabled: - self.stop_auto_refresh() - self.start_auto_refresh() - except ValueError: - messagebox.showwarning("警告", "请输入有效的数字!") - self.interval_var.set("2") - - # ==================== 操作方法 ==================== - - def add_base_knowledge(self): - """添加基础知识""" - dialog = tk.Toplevel(self.parent) - dialog.title("添加基础知识") - dialog.geometry("500x400") - - ttk.Label(dialog, text="实体名称:").grid(row=0, column=0, padx=10, pady=10, sticky="w") - entity_entry = ttk.Entry(dialog, width=40) - entity_entry.grid(row=0, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="内容:").grid(row=1, column=0, padx=10, pady=10, sticky="nw") - content_text = tk.Text(dialog, width=40, height=8, wrap=tk.WORD) - content_text.grid(row=1, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="分类:").grid(row=2, column=0, padx=10, pady=10, sticky="w") - category_combo = ttk.Combobox(dialog, width=38, values=["通用", "机构类型", "定义", "规则", "其他"]) - category_combo.set("通用") - category_combo.grid(row=2, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="描述:").grid(row=3, column=0, padx=10, pady=10, sticky="nw") - desc_text = tk.Text(dialog, width=40, height=4, wrap=tk.WORD) - desc_text.grid(row=3, column=1, padx=10, pady=10) - - def save(): - entity_name = entity_entry.get().strip() - content = content_text.get(1.0, tk.END).strip() - category = category_combo.get() - description = desc_text.get(1.0, tk.END).strip() - - if not entity_name or not content: - messagebox.showwarning("警告", "实体名称和内容不能为空!") - return - - if self.db.add_base_fact(entity_name, content, category, description, immutable=True): - messagebox.showinfo("成功", f"已添加基础知识: {entity_name}") - self.refresh_base_knowledge() - self.update_statistics() - dialog.destroy() - else: - messagebox.showerror("错误", "添加失败,可能该实体已存在!") - - btn_frame = ttk.Frame(dialog) - btn_frame.grid(row=4, column=0, columnspan=2, pady=20) - ttk.Button(btn_frame, text="保存", command=save, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(btn_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def edit_base_knowledge(self): - """编辑基础知识""" - selection = self.base_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一条基础知识!") - return - - messagebox.showinfo("提示", "基础知识不可编辑(immutable=True)\n如需修改,请删除后重新添加。") - - def delete_base_knowledge(self): - """删除基础知识""" - selection = self.base_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一条基础知识!") - return - - if messagebox.askyesno("确认", "确定要删除选中的基础知识吗?\n(基础知识一般不应删除)"): - for item in selection: - values = self.base_tree.item(item)['values'] - entity_name = values[0] - self.db.delete_base_fact(entity_name) - - self.refresh_base_knowledge() - self.update_statistics() - messagebox.showinfo("成功", "删除完成") - - def add_entity(self): - """添加新实体""" - name = simpledialog.askstring("添加实体", "请输入实体名称:") - if name: - uuid = self.db.create_entity(name) - messagebox.showinfo("成功", f"已创建实体: {name}\nUUID: {uuid}") - self.refresh_entities() - self.update_statistics() - - def view_entity_detail(self): - """查看实体详情""" - selection = self.entity_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一个实体!") - return - - item = selection[0] - entity_uuid = self.entity_tree.item(item)['tags'][0] - - # 获取实体详细信息 - entity = self.db.get_entity_by_uuid(entity_uuid) - definition = self.db.get_entity_definition(entity_uuid) - related_info = self.db.get_entity_related_info(entity_uuid) - - # 创建详情窗口 - dialog = tk.Toplevel(self.parent) - dialog.title(f"实体详情: {entity['name']}") - dialog.geometry("700x600") - - text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=("微软雅黑", 10)) - text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - # 显示信息 - text.insert(tk.END, f"实体名称: {entity['name']}\n") - text.insert(tk.END, f"UUID: {entity['uuid']}\n") - text.insert(tk.END, f"创建时间: {entity['created_at']}\n") - text.insert(tk.END, f"更新时间: {entity['updated_at']}\n") - text.insert(tk.END, "\n" + "="*60 + "\n\n") - - if definition: - text.insert(tk.END, "【定义】\n", "header") - text.insert(tk.END, f"{definition['content']}\n") - text.insert(tk.END, f"类型: {definition['type']} | 置信度: {definition['confidence']:.2f}\n") - text.insert(tk.END, f"来源: {definition.get('source', '未知')}\n\n") - else: - text.insert(tk.END, "【定义】无\n\n") - - text.insert(tk.END, f"【相关信息】({len(related_info)} 条)\n", "header") - for i, info in enumerate(related_info, 1): - text.insert(tk.END, f"\n{i}. {info['content']}\n") - text.insert(tk.END, f" 类型: {info['type']} | 置信度: {info['confidence']:.2f} | 创建时间: {info['created_at'][:19]}\n") - - text.config(state=tk.DISABLED) - - ttk.Button(dialog, text="关闭", command=dialog.destroy).pack(pady=10) - - def delete_entity(self): - """删除实体(暂未实现级联删除)""" - messagebox.showinfo("提示", "实体删除功能需要实现级联删除,暂未开放。") - - def view_emotion_detail(self): - """查看情感分析详情""" - selection = self.emotion_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一条情感分析记录!") - return - - item = selection[0] - emotion_uuid = self.emotion_tree.item(item)['tags'][0] - - # 获取完整情感分析数据 - emotions = self.db.get_emotion_history() - emotion = next((e for e in emotions if e['uuid'] == emotion_uuid), None) - - if not emotion: - return - - # 显示详情 - info = f"""情感分析详情 - -关系类型: {emotion.get('relationship_type', '未知')} -情感基调: {emotion.get('emotional_tone', '未知')} -总体评分: {emotion.get('overall_score', 0)}/100 - -五维度评分: -• 亲密度: {emotion.get('intimacy', 0)}/100 -• 信任度: {emotion.get('trust', 0)}/100 -• 愉悦度: {emotion.get('pleasure', 0)}/100 -• 共鸣度: {emotion.get('resonance', 0)}/100 -• 依赖度: {emotion.get('dependence', 0)}/100 - -分析时间: {emotion['created_at']} - -分析摘要: -{emotion.get('analysis_summary', '无')}""" - - messagebox.showinfo("情感分析详情", info) - - def clear_short_term(self): - """清空短期记忆""" - if messagebox.askyesno("确认", "确定要清空所有短期记忆吗?"): - if self.db.clear_short_term_memory(): - self.refresh_short_term() - self.update_statistics() - messagebox.showinfo("成功", "短期记忆已清空") - - def clear_long_term(self): - """清空长期记忆""" - if messagebox.askyesno("确认", "确定要清空所有长期记忆吗?"): - if self.db.clear_long_term_memory(): - self.refresh_long_term() - self.update_statistics() - messagebox.showinfo("成功", "长期记忆已清空") - - def show_statistics(self): - """显示详细统计信息""" - stats = self.db.get_statistics() - info = f"""数据库统计信息 - -基础知识: {stats['base_knowledge_count']} 条 -实体数量: {stats['entities_count']} 个 -短期记忆: {stats['short_term_count']} 条消息 -长期记忆: {stats['long_term_count']} 个主题概括 -情感分析: {stats['emotion_count']} 条记录 - -数据库文件大小: {stats.get('db_size_kb', 0):.2f} KB""" - - messagebox.showinfo("数据库统计", info) - - def clear_confirm(self): - """确认清空所有数据""" - if messagebox.askyesno("警告", "确定要清空所有数据吗?\n此操作不可恢复!", icon='warning'): - if messagebox.askyesno("二次确认", "真的确定要清空所有数据吗?", icon='warning'): - # 这里需要实现清空所有数据的功能 - messagebox.showinfo("提示", "清空所有数据功能需要在DatabaseManager中实现。") - - # ==================== 环境管理方法 ==================== - - def refresh_environments(self): - """刷新环境列表""" - # 清空现有项 - for item in self.env_tree.get_children(): - self.env_tree.delete(item) - - # 获取所有环境 - environments = self.db.get_all_environments() - - # 更新计数 - self.env_count_label.config(text=f"环境数: {len(environments)}") - - # 添加到树视图 - for env in environments: - status = "✅激活" if env.get('is_active') else "⭕未激活" - self.env_tree.insert("", tk.END, values=( - env['name'], - env.get('overall_description', '')[:50] + "..." if len(env.get('overall_description', '')) > 50 else env.get('overall_description', ''), - status, - env['created_at'][:19] - ), tags=(env['uuid'],)) - - def add_environment(self): - """添加新环境""" - dialog = tk.Toplevel(self.parent) - dialog.title("添加环境") - dialog.geometry("600x500") - - ttk.Label(dialog, text="环境名称:").grid(row=0, column=0, padx=10, pady=10, sticky="w") - name_entry = ttk.Entry(dialog, width=50) - name_entry.grid(row=0, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="整体描述:").grid(row=1, column=0, padx=10, pady=10, sticky="nw") - desc_text = tk.Text(dialog, width=50, height=5, wrap=tk.WORD) - desc_text.grid(row=1, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="氛围:").grid(row=2, column=0, padx=10, pady=10, sticky="w") - atmosphere_entry = ttk.Entry(dialog, width=50) - atmosphere_entry.grid(row=2, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="光照:").grid(row=3, column=0, padx=10, pady=10, sticky="w") - lighting_entry = ttk.Entry(dialog, width=50) - lighting_entry.grid(row=3, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="声音:").grid(row=4, column=0, padx=10, pady=10, sticky="w") - sounds_entry = ttk.Entry(dialog, width=50) - sounds_entry.grid(row=4, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="气味:").grid(row=5, column=0, padx=10, pady=10, sticky="w") - smells_entry = ttk.Entry(dialog, width=50) - smells_entry.grid(row=5, column=1, padx=10, pady=10) - - def save(): - name = name_entry.get().strip() - description = desc_text.get(1.0, tk.END).strip() - atmosphere = atmosphere_entry.get().strip() - lighting = lighting_entry.get().strip() - sounds = sounds_entry.get().strip() - smells = smells_entry.get().strip() - - if not name or not description: - messagebox.showwarning("警告", "环境名称和描述不能为空!") - return - - try: - env_uuid = self.db.create_environment( - name=name, - overall_description=description, - atmosphere=atmosphere, - lighting=lighting, - sounds=sounds, - smells=smells - ) - messagebox.showinfo("成功", f"已创建环境: {name}") - self.refresh_environments() - self.update_statistics() - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"创建失败: {str(e)}") - - button_frame = ttk.Frame(dialog) - button_frame.grid(row=6, column=0, columnspan=2, pady=20) - ttk.Button(button_frame, text="保存", command=save, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def edit_environment(self): - """编辑选中的环境""" - selected = self.env_tree.selection() - if not selected: - messagebox.showwarning("警告", "请先选择一个环境!") - return - - env_uuid = self.env_tree.item(selected[0])['tags'][0] - env = self.db.get_environment(env_uuid) - - if not env: - messagebox.showerror("错误", "环境不存在!") - return - - dialog = tk.Toplevel(self.parent) - dialog.title(f"编辑环境: {env['name']}") - dialog.geometry("600x500") - - ttk.Label(dialog, text="环境名称:").grid(row=0, column=0, padx=10, pady=10, sticky="w") - name_entry = ttk.Entry(dialog, width=50) - name_entry.insert(0, env['name']) - name_entry.grid(row=0, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="整体描述:").grid(row=1, column=0, padx=10, pady=10, sticky="nw") - desc_text = tk.Text(dialog, width=50, height=5, wrap=tk.WORD) - desc_text.insert(1.0, env.get('overall_description', '')) - desc_text.grid(row=1, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="氛围:").grid(row=2, column=0, padx=10, pady=10, sticky="w") - atmosphere_entry = ttk.Entry(dialog, width=50) - atmosphere_entry.insert(0, env.get('atmosphere', '')) - atmosphere_entry.grid(row=2, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="光照:").grid(row=3, column=0, padx=10, pady=10, sticky="w") - lighting_entry = ttk.Entry(dialog, width=50) - lighting_entry.insert(0, env.get('lighting', '')) - lighting_entry.grid(row=3, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="声音:").grid(row=4, column=0, padx=10, pady=10, sticky="w") - sounds_entry = ttk.Entry(dialog, width=50) - sounds_entry.insert(0, env.get('sounds', '')) - sounds_entry.grid(row=4, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="气味:").grid(row=5, column=0, padx=10, pady=10, sticky="w") - smells_entry = ttk.Entry(dialog, width=50) - smells_entry.insert(0, env.get('smells', '')) - smells_entry.grid(row=5, column=1, padx=10, pady=10) - - def save(): - name = name_entry.get().strip() - description = desc_text.get(1.0, tk.END).strip() - atmosphere = atmosphere_entry.get().strip() - lighting = lighting_entry.get().strip() - sounds = sounds_entry.get().strip() - smells = smells_entry.get().strip() - - if not name or not description: - messagebox.showwarning("警告", "环境名称和描述不能为空!") - return - - try: - self.db.update_environment( - env_uuid, - name=name, - overall_description=description, - atmosphere=atmosphere, - lighting=lighting, - sounds=sounds, - smells=smells - ) - messagebox.showinfo("成功", f"已更新环境: {name}") - self.refresh_environments() - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"更新失败: {str(e)}") - - button_frame = ttk.Frame(dialog) - button_frame.grid(row=6, column=0, columnspan=2, pady=20) - ttk.Button(button_frame, text="保存", command=save, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def delete_environment(self): - """删除选中的环境""" - selected = self.env_tree.selection() - if not selected: - messagebox.showwarning("警告", "请先选择一个环境!") - return - - env_uuid = self.env_tree.item(selected[0])['tags'][0] - env = self.db.get_environment(env_uuid) - - if not env: - return - - if messagebox.askyesno("确认", f"确定要删除环境 '{env['name']}' 吗?"): - if self.db.delete_environment(env_uuid): - messagebox.showinfo("成功", "环境已删除") - self.refresh_environments() - self.update_statistics() - else: - messagebox.showerror("错误", "删除失败!") - - def activate_environment(self): - """激活选中的环境""" - selected = self.env_tree.selection() - if not selected: - messagebox.showwarning("警告", "请先选择一个环境!") - return - - env_uuid = self.env_tree.item(selected[0])['tags'][0] - env = self.db.get_environment(env_uuid) - - if not env: - return - - if self.db.set_active_environment(env_uuid): - messagebox.showinfo("成功", f"已激活环境: {env['name']}") - self.refresh_environments() - else: - messagebox.showerror("错误", "激活失败!") - - # ==================== 域管理方法 ==================== - - def refresh_domains(self): - """刷新域列表""" - # 清空现有项 - for item in self.domain_tree.get_children(): - self.domain_tree.delete(item) - - # 获取所有域 - domains = self.db.get_all_domains() - - # 更新计数 - self.domain_count_label.config(text=f"域数: {len(domains)}") - - # 添加到树视图 - for domain in domains: - # 获取默认环境名称 - default_env_name = "" - if domain.get('default_environment_uuid'): - default_env = self.db.get_environment(domain['default_environment_uuid']) - if default_env: - default_env_name = default_env['name'] - - # 获取域中的环境数量 - envs = self.db.get_domain_environments(domain['uuid']) - env_count = len(envs) - - self.domain_tree.insert("", tk.END, values=( - domain['name'], - domain.get('description', '')[:50] + "..." if len(domain.get('description', '')) > 50 else domain.get('description', ''), - default_env_name, - env_count, - domain['created_at'][:19] - ), tags=(domain['uuid'],)) - - def add_domain(self): - """添加新域""" - dialog = tk.Toplevel(self.parent) - dialog.title("创建域") - dialog.geometry("500x300") - - ttk.Label(dialog, text="域名称:").grid(row=0, column=0, padx=10, pady=10, sticky="w") - name_entry = ttk.Entry(dialog, width=40) - name_entry.grid(row=0, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="描述:").grid(row=1, column=0, padx=10, pady=10, sticky="nw") - desc_text = tk.Text(dialog, width=40, height=6, wrap=tk.WORD) - desc_text.grid(row=1, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="默认环境:").grid(row=2, column=0, padx=10, pady=10, sticky="w") - - # 获取所有环境 - all_envs = self.db.get_all_environments() - env_names = ["(不设置)"] + [env['name'] for env in all_envs] - env_combo = ttk.Combobox(dialog, width=38, values=env_names, state="readonly") - env_combo.set("(不设置)") - env_combo.grid(row=2, column=1, padx=10, pady=10) - - def save(): - name = name_entry.get().strip() - description = desc_text.get(1.0, tk.END).strip() - default_env_name = env_combo.get() - - if not name: - messagebox.showwarning("警告", "域名称不能为空!") - return - - # 获取默认环境UUID - default_env_uuid = None - if default_env_name != "(不设置)": - for env in all_envs: - if env['name'] == default_env_name: - default_env_uuid = env['uuid'] - break - - try: - domain_uuid = self.db.create_domain( - name=name, - description=description, - default_environment_uuid=default_env_uuid - ) - messagebox.showinfo("成功", f"已创建域: {name}") - self.refresh_domains() - self.update_statistics() - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"创建失败: {str(e)}") - - button_frame = ttk.Frame(dialog) - button_frame.grid(row=3, column=0, columnspan=2, pady=20) - ttk.Button(button_frame, text="保存", command=save, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def edit_domain(self): - """编辑选中的域""" - selected = self.domain_tree.selection() - if not selected: - messagebox.showwarning("警告", "请先选择一个域!") - return - - domain_uuid = self.domain_tree.item(selected[0])['tags'][0] - domain = self.db.get_domain(domain_uuid) - - if not domain: - messagebox.showerror("错误", "域不存在!") - return - - dialog = tk.Toplevel(self.parent) - dialog.title(f"编辑域: {domain['name']}") - dialog.geometry("500x300") - - ttk.Label(dialog, text="域名称:").grid(row=0, column=0, padx=10, pady=10, sticky="w") - name_entry = ttk.Entry(dialog, width=40) - name_entry.insert(0, domain['name']) - name_entry.grid(row=0, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="描述:").grid(row=1, column=0, padx=10, pady=10, sticky="nw") - desc_text = tk.Text(dialog, width=40, height=6, wrap=tk.WORD) - desc_text.insert(1.0, domain.get('description', '')) - desc_text.grid(row=1, column=1, padx=10, pady=10) - - ttk.Label(dialog, text="默认环境:").grid(row=2, column=0, padx=10, pady=10, sticky="w") - - # 获取所有环境 - all_envs = self.db.get_all_environments() - env_names = ["(不设置)"] + [env['name'] for env in all_envs] - env_combo = ttk.Combobox(dialog, width=38, values=env_names, state="readonly") - - # 设置当前默认环境 - if domain.get('default_environment_uuid'): - default_env = self.db.get_environment(domain['default_environment_uuid']) - if default_env: - env_combo.set(default_env['name']) - else: - env_combo.set("(不设置)") - else: - env_combo.set("(不设置)") - - env_combo.grid(row=2, column=1, padx=10, pady=10) - - def save(): - name = name_entry.get().strip() - description = desc_text.get(1.0, tk.END).strip() - default_env_name = env_combo.get() - - if not name: - messagebox.showwarning("警告", "域名称不能为空!") - return - - # 获取默认环境UUID - default_env_uuid = None - if default_env_name != "(不设置)": - for env in all_envs: - if env['name'] == default_env_name: - default_env_uuid = env['uuid'] - break - - try: - self.db.update_domain( - domain_uuid, - name=name, - description=description, - default_environment_uuid=default_env_uuid - ) - messagebox.showinfo("成功", f"已更新域: {name}") - self.refresh_domains() - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"更新失败: {str(e)}") - - button_frame = ttk.Frame(dialog) - button_frame.grid(row=3, column=0, columnspan=2, pady=20) - ttk.Button(button_frame, text="保存", command=save, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def delete_domain(self): - """删除选中的域""" - selected = self.domain_tree.selection() - if not selected: - messagebox.showwarning("警告", "请先选择一个域!") - return - - domain_uuid = self.domain_tree.item(selected[0])['tags'][0] - domain = self.db.get_domain(domain_uuid) - - if not domain: - return - - if messagebox.askyesno("确认", f"确定要删除域 '{domain['name']}' 吗?\n这将移除域与环境的关联,但不会删除环境本身。"): - if self.db.delete_domain(domain_uuid): - messagebox.showinfo("成功", "域已删除") - self.refresh_domains() - self.update_statistics() - else: - messagebox.showerror("错误", "删除失败!") - - def manage_domain_environments(self): - """管理域中的环境""" - selected = self.domain_tree.selection() - if not selected: - messagebox.showwarning("警告", "请先选择一个域!") - return - - def create_schedules_tab(self): - """ - 创建日程数据管理标签页(简化版,仅用于数据查看和编辑) - """ - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text="📅 日程数据") - - # 说明文本 - info_frame = ttk.Frame(tab) - info_frame.pack(fill=tk.X, padx=5, pady=5) - - info_label = ttk.Label( - info_frame, - text="💡 提示:完整的日程管理功能请使用主界面的「📅 日程管理」标签页\n这里仅提供基础的数据查看和编辑功能", - font=("微软雅黑", 9), - foreground="#666", - justify=tk.LEFT - ) - info_label.pack(side=tk.LEFT, padx=5) - - # 工具栏 - toolbar = ttk.Frame(tab) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(toolbar, text="🔄 刷新", command=self.refresh_schedules, width=8).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="📝 编辑数据", command=self.edit_schedule_data, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑 删除", command=self.delete_schedule_data, width=8).pack(side=tk.LEFT, padx=2) - - # 创建Treeview显示列表 - tree_frame = ttk.Frame(tab) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 滚动条 - scrollbar_y = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL) - - # Treeview - self.schedules_tree = ttk.Treeview( - tree_frame, - columns=("id", "title", "type", "start_time", "end_time", "priority", "status"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - # 配置列 - self.schedules_tree.heading("id", text="ID") - self.schedules_tree.heading("title", text="标题") - self.schedules_tree.heading("type", text="类型") - self.schedules_tree.heading("start_time", text="开始时间") - self.schedules_tree.heading("end_time", text="结束时间") - self.schedules_tree.heading("priority", text="优先级") - self.schedules_tree.heading("status", text="状态") - - self.schedules_tree.column("id", width=80, minwidth=60, stretch=False) - self.schedules_tree.column("title", width=150, minwidth=100, stretch=True) - self.schedules_tree.column("type", width=80, minwidth=70, stretch=False) - self.schedules_tree.column("start_time", width=150, minwidth=120, stretch=False) - self.schedules_tree.column("end_time", width=150, minwidth=120, stretch=False) - self.schedules_tree.column("priority", width=70, minwidth=60, stretch=False) - self.schedules_tree.column("status", width=80, minwidth=70, stretch=False) - - scrollbar_y.config(command=self.schedules_tree.yview) - scrollbar_x.config(command=self.schedules_tree.xview) - - # 布局 - self.schedules_tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - tree_frame.grid_rowconfigure(0, weight=1) - tree_frame.grid_columnconfigure(0, weight=1) - - # 双击编辑 - self.schedules_tree.bind("", lambda e: self.edit_schedule_data()) - - # 统计信息 - stats_frame = ttk.Frame(tab) - stats_frame.pack(fill=tk.X, padx=5, pady=5) - - self.schedule_stats_label = ttk.Label(stats_frame, text="", font=("微软雅黑", 9)) - self.schedule_stats_label.pack(side=tk.LEFT, padx=5) - - # 首次刷新 - self.refresh_schedules() - - def refresh_schedules(self): - """刷新日程数据列表""" - # 清空现有内容 - for item in self.schedules_tree.get_children(): - self.schedules_tree.delete(item) - - try: - # 直接从数据库读取 - with self.db.get_connection() as conn: - cursor = conn.cursor() - - # 首先检查表是否存在 - cursor.execute(""" - SELECT name FROM sqlite_master - WHERE type='table' AND name='schedules' - """) - - if not cursor.fetchone(): - # 表不存在,静默跳过(首次启动时会出现这种情况) - self.schedule_stats_label.config(text="日程表尚未初始化(首次使用日程功能时会自动创建)") - return - - cursor.execute(""" - SELECT schedule_id, title, schedule_type, start_time, end_time, - priority, is_active, collaboration_status - FROM schedules - ORDER BY start_time DESC - LIMIT 500 - """) - - schedules = cursor.fetchall() - - # 类型映射 - type_map = {'recurring': '周期', 'appointment': '预约', 'temporary': '临时'} - priority_map = {1: '低', 2: '中', 3: '高', 4: '关键'} - - for schedule in schedules: - schedule_id = schedule[0][:8] # 显示前8位 - title = schedule[1] - stype = type_map.get(schedule[2], schedule[2]) - start_time = schedule[3][:16] if schedule[3] else "" - end_time = schedule[4][:16] if schedule[4] else "" - priority = priority_map.get(schedule[5], str(schedule[5])) - status = "激活" if schedule[6] else "已删除" - - # 添加到树 - self.schedules_tree.insert( - "", - tk.END, - values=(schedule_id, title, stype, start_time, end_time, priority, status), - tags=(schedule[0],) # 完整ID作为tag - ) - - # 更新统计 - self.schedule_stats_label.config(text=f"共 {len(schedules)} 条日程记录") - - except Exception as e: - # 如果是"no such table"错误,静默处理 - error_msg = str(e).lower() - if "no such table" in error_msg or "schedules" in error_msg: - self.schedule_stats_label.config(text="日程表尚未初始化") - else: - messagebox.showerror("错误", f"刷新日程数据失败:\n{str(e)}") - - def edit_schedule_data(self): - """编辑日程数据""" - selection = self.schedules_tree.selection() - if not selection: - messagebox.showwarning("警告", "请选择要编辑的日程") - return - - item = selection[0] - schedule_id = self.schedules_tree.item(item)['tags'][0] - - try: - # 从数据库读取完整数据 - with self.db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT * FROM schedules WHERE schedule_id = ?", (schedule_id,)) - schedule = cursor.fetchone() - - if not schedule: - messagebox.showerror("错误", "日程不存在") - return - - # 创建编辑对话框 - dialog = tk.Toplevel(self.parent) - dialog.title("编辑日程数据") - dialog.geometry("500x400") - dialog.transient(self.parent) - dialog.grab_set() - - form_frame = ttk.Frame(dialog, padding=10) - form_frame.pack(fill=tk.BOTH, expand=True) - - # 显示可编辑字段 - fields = [] - - # 标题 - ttk.Label(form_frame, text="标题:").grid(row=0, column=0, sticky=tk.W, pady=2) - title_entry = ttk.Entry(form_frame, width=40) - title_entry.insert(0, schedule[1] or "") - title_entry.grid(row=0, column=1, pady=2) - fields.append(('title', title_entry)) - - # 描述 - ttk.Label(form_frame, text="描述:").grid(row=1, column=0, sticky=tk.W, pady=2) - desc_text = tk.Text(form_frame, width=40, height=3) - desc_text.insert("1.0", schedule[2] or "") - desc_text.grid(row=1, column=1, pady=2) - fields.append(('description', desc_text)) - - # 优先级 - ttk.Label(form_frame, text="优先级 (1-4):").grid(row=2, column=0, sticky=tk.W, pady=2) - priority_entry = ttk.Entry(form_frame, width=40) - priority_entry.insert(0, str(schedule[6])) - priority_entry.grid(row=2, column=1, pady=2) - fields.append(('priority', priority_entry)) - - # 激活状态 - ttk.Label(form_frame, text="激活状态:").grid(row=3, column=0, sticky=tk.W, pady=2) - active_var = tk.BooleanVar(value=bool(schedule[10])) - ttk.Checkbutton(form_frame, variable=active_var).grid(row=3, column=1, sticky=tk.W, pady=2) - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(fill=tk.X, padx=10, pady=10) - - def save_changes(): - try: - with self.db.get_connection() as conn: - cursor = conn.cursor() - - title = title_entry.get() - description = desc_text.get("1.0", tk.END).strip() - priority = int(priority_entry.get()) - is_active = 1 if active_var.get() else 0 - - cursor.execute(""" - UPDATE schedules - SET title = ?, description = ?, priority = ?, is_active = ? - WHERE schedule_id = ? - """, (title, description, priority, is_active, schedule_id)) - - conn.commit() - messagebox.showinfo("成功", "日程数据已更新") - dialog.destroy() - self.refresh_schedules() - - except Exception as e: - messagebox.showerror("错误", f"更新失败:\n{str(e)}") - - ttk.Button(button_frame, text="保存", command=save_changes, width=10).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=10).pack(side=tk.LEFT, padx=5) - - except Exception as e: - messagebox.showerror("错误", f"加载日程数据失败:\n{str(e)}") - - def delete_schedule_data(self): - """删除日程数据(软删除)""" - selection = self.schedules_tree.selection() - if not selection: - messagebox.showwarning("警告", "请选择要删除的日程") - return - - item = selection[0] - values = self.schedules_tree.item(item)['values'] - schedule_id = self.schedules_tree.item(item)['tags'][0] - - if not messagebox.askyesno("确认", f"确定要删除日程「{values[1]}」吗?\n\n这是软删除,数据仍保留在数据库中。"): - return - - try: - with self.db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute("UPDATE schedules SET is_active = 0 WHERE schedule_id = ?", (schedule_id,)) - conn.commit() - - messagebox.showinfo("成功", "日程已标记为删除") - self.refresh_schedules() - - except Exception as e: - messagebox.showerror("错误", f"删除失败:\n{str(e)}") diff --git a/src/gui/gui_enhanced.py b/src/gui/gui_enhanced.py deleted file mode 100644 index fd214a6..0000000 --- a/src/gui/gui_enhanced.py +++ /dev/null @@ -1,5253 +0,0 @@ -""" -增强版Tkinter GUI界面 -包含聊天主题时间线可视化功能和Debug日志界面 -""" - -import os -import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox, Canvas, simpledialog -from datetime import datetime -import threading -import math -from typing import Dict, Any, List, Optional -from src.core.chat_agent import ChatAgent -from src.core.database_manager import DatabaseManager -from src.tools.debug_logger import get_debug_logger, DebugLogger -from src.core.emotion_analyzer import format_emotion_summary -from src.tools.tooltip_utils import ToolTip, create_treeview_tooltip -from src.gui.nps_gui import NPSManagerGUI - - -class EmotionImpressionDisplay(Canvas): - """ - 情感印象展示画布 - 用于展示基于印象的情感分析结果 - """ - - def __init__(self, parent, **kwargs): - """ - 初始化印象展示画布 - - Args: - parent: 父容器 - """ - super().__init__(parent, **kwargs) - self.emotion_data = None - self.colors = { - 'bg': '#f8f9fa', - 'positive': '#4caf50', - 'neutral': '#9e9e9e', - 'negative': '#f44336', - 'text': '#212121', - 'secondary': '#757575', - 'border': '#e0e0e0' - } - - # 绑定事件 - self.bind('', self.on_resize) - - def update_emotion(self, emotion_data: Dict[str, Any]): - """ - 更新情感数据并重绘 - - Args: - emotion_data: 情感分析数据 - """ - debug_logger = get_debug_logger() - debug_logger.log_info('EmotionImpressionDisplay', '更新情感数据', { - 'has_data': bool(emotion_data), - 'keys': list(emotion_data.keys()) if emotion_data else [] - }) - - self.emotion_data = emotion_data - self.draw_impression() - - debug_logger.log_info('EmotionImpressionDisplay', '印象展示重绘完成') - - def draw_impression(self): - """ - 绘制印象展示 - """ - self.delete('all') # 清空画布 - - width = self.winfo_width() - height = self.winfo_height() - - if width <= 1 or height <= 1: - return - - # 如果没有数据,显示提示 - if not self.emotion_data: - self.create_text( - width // 2, height // 2, - text="暂无情感分析数据\n对话后点击「分析情感关系」按钮", - font=('微软雅黑', 10), - fill=self.colors['secondary'], - justify=tk.CENTER - ) - return - - # 提取数据 - overall_score = self.emotion_data.get('overall_score', 50) - sentiment = self.emotion_data.get('sentiment', 'neutral') - relationship_type = self.emotion_data.get('relationship_type', '未知') - emotional_tone = self.emotion_data.get('emotional_tone', '未知') - - # 根据情感倾向选择颜色 - if sentiment == 'positive': - score_color = self.colors['positive'] - sentiment_text = "正面印象" - elif sentiment == 'negative': - score_color = self.colors['negative'] - sentiment_text = "负面印象" - else: - score_color = self.colors['neutral'] - sentiment_text = "中性印象" - - # 绘制评分圆环 - center_x = width // 2 - center_y = height // 3 - radius = min(width, height) // 5 - - # 背景圆 - self.create_oval( - center_x - radius, center_y - radius, - center_x + radius, center_y + radius, - outline=self.colors['border'], width=15, - fill='' - ) - - # 评分圆弧(根据分数显示) - extent = int(360 * (overall_score / 100)) - self.create_arc( - center_x - radius, center_y - radius, - center_x + radius, center_y + radius, - start=90, extent=-extent, - outline=score_color, width=15, - style='arc' - ) - - # 中心显示评分 - self.create_text( - center_x, center_y - 10, - text=str(overall_score), - font=('微软雅黑', 32, 'bold'), - fill=score_color - ) - self.create_text( - center_x, center_y + 20, - text=sentiment_text, - font=('微软雅黑', 10), - fill=self.colors['text'] - ) - - # 绘制关系信息 - info_y = center_y + radius + 40 - self.create_text( - center_x, info_y, - text=f"关系类型:{relationship_type}", - font=('微软雅黑', 11, 'bold'), - fill=self.colors['text'] - ) - self.create_text( - center_x, info_y + 25, - text=f"情感基调:{emotional_tone}", - font=('微软雅黑', 10), - fill=self.colors['secondary'] - ) - - def _draw_pentagon(self, cx, cy, radius, **kwargs): - """ - 绘制五边形(保留以兼容旧方法) - - Args: - cx: 中心x坐标 - cy: 中心y坐标 - radius: 半径 - """ - points = [] - for i in range(5): - angle = math.radians(90 - i * 72) - x = cx + radius * math.cos(angle) - y = cy - radius * math.sin(angle) - points.extend([x, y]) - return self.create_polygon(points, **kwargs) - - def on_resize(self, event): - """ - 响应窗口大小变化 - - Args: - event: 事件对象 - """ - self.draw_impression() - - -class TopicTimelineCanvas(Canvas): - """ - 主题时间线画布 - 用于可视化展示聊天主题的变化 - """ - - def __init__(self, parent, **kwargs): - """ - 初始化时间线画布 - - Args: - parent: 父容器 - """ - super().__init__(parent, **kwargs) - self.topics = [] - self.colors = [ - '#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', - '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2', - '#F8B739', '#52B788', '#FF8FA3', '#6A9BD1' - ] - - # 绑定鼠标事件 - self.bind('', self.on_resize) - self.bind('', self.on_mouse_move) - - # 工具提示 - self.tooltip = None - - def update_topics(self, summaries): - """ - 更新主题数据并重绘 - - Args: - summaries: 长期记忆概括列表 - """ - self.topics = summaries - self.draw_timeline() - - def draw_timeline(self): - """ - 绘制时间线 - """ - self.delete('all') # 清空画布 - - if not self.topics: - # 如果没有数据,显示提示 - width = self.winfo_width() - height = self.winfo_height() - self.create_text( - width // 2, height // 2, - text="暂无主题数据\n对话超过20轮后将自动生成主题概括", - font=('微软雅黑', 10), - fill='#999999', - justify=tk.CENTER - ) - return - - width = self.winfo_width() - height = self.winfo_height() - - if width <= 1 or height <= 1: - return - - # 计算布局参数 - padding = 40 - timeline_y = height // 2 - available_width = width - 2 * padding - - # 如果只有一个主题 - if len(self.topics) == 1: - x = width // 2 - self._draw_topic_node(x, timeline_y, self.topics[0], 0) - return - - # 多个主题:均匀分布 - step = available_width / (len(self.topics) - 1) if len(self.topics) > 1 else 0 - - # 绘制时间线 - self.create_line( - padding, timeline_y, - width - padding, timeline_y, - fill='#CCCCCC', width=2, tags='timeline' - ) - - # 绘制各个主题节点 - for i, topic in enumerate(self.topics): - x = padding + i * step - self._draw_topic_node(x, timeline_y, topic, i) - - # 绘制连接线(除了最后一个) - if i < len(self.topics) - 1: - next_x = padding + (i + 1) * step - self.create_line( - x, timeline_y, - next_x, timeline_y, - fill=self.colors[i % len(self.colors)], - width=3, - arrow=tk.LAST, - arrowshape=(10, 12, 5), - tags=f'line_{i}' - ) - - def _draw_topic_node(self, x, y, topic, index): - """ - 绘制单个主题节点 - - Args: - x: X坐标 - y: Y坐标 - topic: 主题数据 - index: 索引 - """ - color = self.colors[index % len(self.colors)] - radius = 12 - - # 绘制节点圆圈 - node_id = self.create_oval( - x - radius, y - radius, - x + radius, y + radius, - fill=color, - outline='white', - width=3, - tags=f'node_{index}' - ) - - # 绘制节点编号 - self.create_text( - x, y, - text=str(index + 1), - font=('Arial', 10, 'bold'), - fill='white', - tags=f'node_text_{index}' - ) - - # 绘制日期标签 - date_str = topic.get('created_at', '')[:10] if topic.get('created_at') else '' - self.create_text( - x, y - 30, - text=date_str, - font=('微软雅黑', 8), - fill='#666666', - tags=f'date_{index}' - ) - - # 绘制主题摘要(简短版) - summary = topic.get('summary', '') - short_summary = summary[:15] + '...' if len(summary) > 15 else summary - self.create_text( - x, y + 30, - text=short_summary, - font=('微软雅黑', 8), - fill='#333333', - width=100, - tags=f'summary_{index}' - ) - - # 绑定点击事件 - self.tag_bind(f'node_{index}', '', - lambda e, t=topic, i=index: self.on_node_click(t, i)) - - # 存储完整信息用于工具提示 - self.itemconfig(node_id, tags=(f'node_{index}', f'tooltip_{index}')) - - def on_node_click(self, topic, index): - """ - 节点点击事件处理 - - Args: - topic: 主题数据 - index: 索引 - """ - # 显示详细信息 - info = f"""主题 {index + 1} 详细信息 - -时间范围: {topic.get('created_at', '')[:19]} 至 {topic.get('ended_at', '')[:19]} -对话轮数: {topic.get('rounds', 0)} 轮 -消息数量: {topic.get('message_count', 0)} 条 -UUID: {topic.get('uuid', '')} - -主题概括: -{topic.get('summary', '')}""" - - messagebox.showinfo(f"主题 {index + 1}", info) - - def on_mouse_move(self, event): - """ - 鼠标移动事件处理(用于显示工具提示) - - Args: - event: 事件对象 - """ - # 查找鼠标下的节点 - items = self.find_overlapping(event.x - 2, event.y - 2, event.x + 2, event.y + 2) - - for item in items: - tags = self.gettags(item) - for tag in tags: - if tag.startswith('node_') and not tag.endswith('text'): - # 改变鼠标样式 - self.config(cursor='hand2') - return - - # 恢复默认鼠标样式 - self.config(cursor='') - - def on_resize(self, event): - """ - 窗口大小改变事件处理 - - Args: - event: 事件对象 - """ - self.draw_timeline() - - -class EnhancedChatDebugGUI: - """ - 增强版聊天调试GUI - 包含主题时��线可视化 - """ - - def __init__(self, root): - """ - 初始化GUI界面 - - Args: - root: Tkinter根窗口 - """ - self.root = root - self.root.title("智能对话代理 - 增强调试界面") - self.root.geometry("1400x900") - self.root.minsize(1000, 700) # 设置最小窗口尺寸,防止布局混乱 - - # 初始化聊天代理 - self.agent = None - self.is_processing = False - - # 创建UI组件 - self.create_widgets() - - # 初始化代理 - self.initialize_agent() - - # 绑定快捷键 - self.root.bind('', lambda e: self.send_message() if not e.state & 0x1 else None) - self.root.bind('', lambda e: self.input_text.insert(tk.INSERT, '\n')) - - def show_error_dialog(self, title: str, message: str, max_height: int = 400): - """ - 显示增强的错误对话框,支持长文本滚动和一键复制 - - Args: - title: 对话框标题 - message: 错误消息(可以很长) - max_height: 文本区域最大高度(像素) - """ - # 创建顶层窗口 - dialog = tk.Toplevel(self.root) - dialog.title(title) - dialog.geometry("600x500") - dialog.transient(self.root) - dialog.grab_set() - - # 主容器 - main_frame = ttk.Frame(dialog, padding=10) - main_frame.pack(fill=tk.BOTH, expand=True) - - # 错误图标和标题 - header_frame = ttk.Frame(main_frame) - header_frame.pack(fill=tk.X, pady=(0, 10)) - - # 错误图标(使用emoji或符号) - icon_label = tk.Label( - header_frame, - text="❌", - font=('Arial', 32), - fg='#f44336' - ) - icon_label.pack(side=tk.LEFT, padx=(0, 10)) - - # 标题文本 - title_label = tk.Label( - header_frame, - text=title, - font=('微软雅黑', 14, 'bold'), - fg='#212121' - ) - title_label.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # 提示标签 - tip_label = tk.Label( - main_frame, - text="💡 双击文本区域一键复制全部内容", - font=('微软雅黑', 9), - fg='#757575' - ) - tip_label.pack(fill=tk.X, pady=(0, 5)) - - # 可滚动的文本区域 - text_frame = ttk.Frame(main_frame) - text_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - - # 创建滚动文本框 - text_widget = scrolledtext.ScrolledText( - text_frame, - wrap=tk.WORD, - font=('Consolas', 10), - bg='#fff', - fg='#212121', - relief=tk.SOLID, - borderwidth=1, - padx=10, - pady=10 - ) - text_widget.pack(fill=tk.BOTH, expand=True) - - # 插入错误消息 - text_widget.insert('1.0', message) - text_widget.config(state=tk.DISABLED) # 设为只读 - - # 双击复制功能 - def copy_all_text(event): - """双击时复制全部文本到剪贴板""" - try: - # 获取所有文本 - all_text = text_widget.get('1.0', tk.END).strip() - # 复制到剪贴板 - dialog.clipboard_clear() - dialog.clipboard_append(all_text) - # 显示反馈 - tip_label.config( - text="✅ 已复制到剪贴板!", - fg='#4caf50' - ) - # 3秒后恢复原始提示 - dialog.after(3000, lambda: tip_label.config( - text="💡 双击文本区域一键复制全部内容", - fg='#757575' - )) - except Exception as e: - print(f"复制失败: {e}") - - text_widget.bind('', copy_all_text) - - # 按钮区域 - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill=tk.X) - - # 确定按钮 - ok_button = ttk.Button( - button_frame, - text="确定", - command=dialog.destroy, - width=15 - ) - ok_button.pack(side=tk.RIGHT) - - # 复制按钮 - def copy_button_click(): - """点击复制按钮""" - all_text = text_widget.get('1.0', tk.END).strip() - dialog.clipboard_clear() - dialog.clipboard_append(all_text) - tip_label.config( - text="✅ 已复制到剪贴板!", - fg='#4caf50' - ) - dialog.after(3000, lambda: tip_label.config( - text="💡 双击文本区域一键复制全部内容", - fg='#757575' - )) - - copy_button = ttk.Button( - button_frame, - text="📋 复制全部", - command=copy_button_click, - width=15 - ) - copy_button.pack(side=tk.RIGHT, padx=(0, 10)) - - # 居中显示 - dialog.update_idletasks() - x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2) - y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2) - dialog.geometry(f"+{x}+{y}") - - # 焦点设置到确定按钮,支持回车关闭 - ok_button.focus_set() - dialog.bind('', lambda e: dialog.destroy()) - dialog.bind('', lambda e: dialog.destroy()) - - def create_widgets(self): - """ - 创建所有UI组件 - 优化后的布局:将对话和调试分为两个独立的顶级标签页 - """ - # 主容器 - main_container = ttk.Frame(self.root) - main_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 创建顶级标签页,分离对话和调试功能 - self.main_notebook = ttk.Notebook(main_container) - self.main_notebook.pack(fill=tk.BOTH, expand=True) - - # ========== 标签页1:对话页面 ========== - chat_page = ttk.Frame(self.main_notebook) - self.main_notebook.add(chat_page, text="💬 对话") - - # 创建对话页面的内容 - self.create_chat_page(chat_page) - - # ========== 标签页2:调试页面 ========== - debug_page = ttk.Frame(self.main_notebook) - self.main_notebook.add(debug_page, text="🔧 调试") - - # 创建调试页面的内容 - self.create_debug_page(debug_page) - - def create_chat_page(self, parent): - """ - 创建对话页面 - - Args: - parent: 父容器 - """ - # 对话页面使用简洁布局,专注于聊天功能 - self.create_chat_area(parent) - - def create_debug_page(self, parent): - """ - 创建调试页面 - - Args: - parent: 父容器 - """ - # 上部:可视化区域(时间线和情感分析的标签页,固定高度) - visualization_frame = ttk.LabelFrame(parent, text="📊 数据可视化", padding=5, height=280) - visualization_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.TOP) - visualization_frame.pack_propagate(False) # 固定高度 - - # 创建标签页控件 - viz_notebook = ttk.Notebook(visualization_frame) - viz_notebook.pack(fill=tk.BOTH, expand=True) - - # 标签页1:对话主题时间线 - timeline_tab = ttk.Frame(viz_notebook) - viz_notebook.add(timeline_tab, text="📈 主题时间线") - - self.timeline_canvas = TopicTimelineCanvas( - timeline_tab, - bg='#f8f9fa', - highlightthickness=0 - ) - self.timeline_canvas.pack(fill=tk.BOTH, expand=True) - - # 标签页2:情感关系分析 - emotion_tab = ttk.Frame(viz_notebook) - viz_notebook.add(emotion_tab, text="💖 情感关系") - - # 创建一个水平容器用于情感分析显示和详细信息 - emotion_container = ttk.Frame(emotion_tab) - emotion_container.pack(fill=tk.BOTH, expand=True) - - # 左侧:情感印象显示 - radar_frame = ttk.Frame(emotion_container) - radar_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - self.emotion_canvas = EmotionImpressionDisplay( - radar_frame, - bg='#f8f9fa', - highlightthickness=0 - ) - self.emotion_canvas.pack(fill=tk.BOTH, expand=True) - - # 右侧:详细信息和控制按钮 - emotion_info_frame = ttk.Frame(emotion_container, width=250) - emotion_info_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=(10, 0)) - emotion_info_frame.pack_propagate(False) - - # 分析按钮 - ttk.Button( - emotion_info_frame, - text="🔍 分析情感关系", - command=self.analyze_emotion, - width=20 - ).pack(pady=5) - - # 情感分析详细信息 - self.emotion_info_text = scrolledtext.ScrolledText( - emotion_info_frame, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9", - relief=tk.FLAT, - height=12 - ) - self.emotion_info_text.pack(fill=tk.BOTH, expand=True, pady=5) - self.emotion_info_text.config(state=tk.DISABLED) - - # 下部:调试信息区域(填充剩余空间) - debug_info_frame = ttk.Frame(parent) - debug_info_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 创建调试区域 - self.create_debug_area(debug_info_frame) - - def create_chat_area(self, parent): - """ - 创建聊天区域 - - Args: - parent: 父容器 - """ - # 顶部标题栏(固定高度) - title_frame = ttk.Frame(parent, height=40) - title_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.TOP) - title_frame.pack_propagate(False) # 防止子组件改变frame大小 - - title_label = ttk.Label( - title_frame, - text="💬 智能对话系统", - font=("微软雅黑", 16, "bold") - ) - title_label.pack(side=tk.LEFT) - - self.status_label = ttk.Label( - title_frame, - text="● 就绪", - foreground="green", - font=("微软雅黑", 10) - ) - self.status_label.pack(side=tk.RIGHT, padx=10) - - # 角色信息栏(支持垂直滚动,150px高度) - self.character_frame = ttk.LabelFrame(parent, text="📋 当前角色", padding=5) - self.character_frame.pack(fill=tk.X, padx=5, pady=3, side=tk.TOP) - - # 使用Canvas和Scrollbar实现垂直滚动 - character_canvas = Canvas(self.character_frame, height=150, bg='#f9f9f9', highlightthickness=0) - character_scrollbar = ttk.Scrollbar(self.character_frame, orient=tk.VERTICAL, command=character_canvas.yview) - character_canvas.configure(yscrollcommand=character_scrollbar.set) - - character_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - character_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # 创建内部frame用于放置标签 - character_inner_frame = ttk.Frame(character_canvas) - character_canvas.create_window((0, 0), window=character_inner_frame, anchor=tk.NW) - - self.character_label = ttk.Label( - character_inner_frame, - text="加载中...", - font=("微软雅黑", 9), - wraplength=1200, # 设置换行宽度 - justify=tk.LEFT - ) - self.character_label.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 更新canvas的滚动区域 - def update_character_scroll(event=None): - character_canvas.configure(scrollregion=character_canvas.bbox("all")) - # 设置canvas宽度以匹配窗口 - character_canvas.itemconfig(character_canvas.find_all()[0], width=character_canvas.winfo_width()) - - character_inner_frame.bind("", update_character_scroll) - character_canvas.bind("", lambda e: update_character_scroll()) - self.character_canvas = character_canvas # 保存引用以便后续更新 - - # 添加工具提示支持 - self.character_tooltip = None - - # 记忆状态栏(固定高度) - memory_status_frame = ttk.Frame(parent, height=30) - memory_status_frame.pack(fill=tk.X, padx=5, pady=2, side=tk.TOP) - memory_status_frame.pack_propagate(False) - - self.memory_status_label = ttk.Label( - memory_status_frame, - text="短期记忆: 0轮 | 长期记忆: 0个主题", - font=("微软雅黑", 9), - foreground="#0066cc" - ) - self.memory_status_label.pack(side=tk.LEFT) - - ttk.Button( - memory_status_frame, - text="🔄", - width=3, - command=self.refresh_all - ).pack(side=tk.RIGHT, padx=2) - - # 输入区域(固定在底部,自适应高度) - input_frame = ttk.LabelFrame(parent, text="✏️ 输入消息", padding=5) - input_frame.pack(fill=tk.X, padx=5, pady=5, side=tk.BOTTOM) - - # 输入文本框(使用合理的高度,支持滚动) - self.input_text = scrolledtext.ScrolledText( - input_frame, - height=4, - wrap=tk.WORD, - font=("微软雅黑", 10), - relief=tk.SOLID, - borderwidth=1 - ) - self.input_text.pack(fill=tk.X, expand=False, padx=5, pady=(5, 2)) - - # 按钮区域 - button_frame = ttk.Frame(input_frame) - button_frame.pack(fill=tk.X, padx=5, pady=(2, 5)) - - self.send_button = ttk.Button( - button_frame, - text="发送 (Enter)", - command=self.send_message - ) - self.send_button.pack(side=tk.LEFT, padx=2) - - ttk.Button( - button_frame, - text="清空输入", - command=self.clear_input - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - button_frame, - text="清空对话", - command=self.clear_chat_display - ).pack(side=tk.LEFT, padx=2) - - # 聊天显示区域(填充剩余空间) - chat_frame = ttk.Frame(parent) - chat_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5, side=tk.TOP) - - self.chat_display = scrolledtext.ScrolledText( - chat_frame, - wrap=tk.WORD, - font=("微软雅黑", 11), - bg="#f5f5f5", - relief=tk.FLAT, - padx=10, - pady=10 - ) - self.chat_display.pack(fill=tk.BOTH, expand=True) - self.chat_display.config(state=tk.DISABLED) - - # 配置文本标签样式 - self.chat_display.tag_config("user", foreground="#0066cc", font=("微软雅黑", 11, "bold")) - self.chat_display.tag_config("assistant", foreground="#ff6600", font=("微软雅黑", 11, "bold")) - self.chat_display.tag_config("system", foreground="#666666", font=("微软雅黑", 9, "italic")) - self.chat_display.tag_config("timestamp", foreground="#999999", font=("微软雅黑", 8)) - self.chat_display.tag_config("archive", foreground="#9933cc", font=("微软雅黑", 9, "italic")) - - def create_debug_area(self, parent): - """ - 创建调试区域 - - Args: - parent: 父容器 - """ - # 调试选项卡 - notebook = ttk.Notebook(parent) - notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 选项卡1: 系统信息 - info_tab = ttk.Frame(notebook) - notebook.add(info_tab, text="系统信息") - - self.info_display = scrolledtext.ScrolledText( - info_tab, - wrap=tk.WORD, - font=("Consolas", 9), - bg="#f9f9f9", - relief=tk.FLAT - ) - self.info_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.info_display.config(state=tk.DISABLED) - - # 选项卡2: 短期记忆 - short_term_tab = ttk.Frame(notebook) - notebook.add(short_term_tab, text="短期记忆") - - self.short_term_display = scrolledtext.ScrolledText( - short_term_tab, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9", - relief=tk.FLAT - ) - self.short_term_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.short_term_display.config(state=tk.DISABLED) - - # 选项卡3: 长期记忆 - long_term_tab = ttk.Frame(notebook) - notebook.add(long_term_tab, text="长期记忆") - - self.long_term_display = scrolledtext.ScrolledText( - long_term_tab, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9", - relief=tk.FLAT - ) - self.long_term_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.long_term_display.config(state=tk.DISABLED) - - # 选项卡4: 理解阶段 - understanding_tab = ttk.Frame(notebook) - notebook.add(understanding_tab, text="🧠 理解阶段") - - self.understanding_display = scrolledtext.ScrolledText( - understanding_tab, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9", - relief=tk.FLAT - ) - self.understanding_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.understanding_display.config(state=tk.DISABLED) - - # 选项卡5: 知识库 - knowledge_tab = ttk.Frame(notebook) - notebook.add(knowledge_tab, text="📚 知识库") - - # 知识库顶部工具栏 - kb_toolbar = ttk.Frame(knowledge_tab) - kb_toolbar.pack(fill=tk.X, padx=5, pady=5) - - # 第一行:基础知识信息 - kb_info_frame = ttk.Frame(kb_toolbar) - kb_info_frame.pack(fill=tk.X, pady=(0, 5)) - - self.base_kb_info_label = ttk.Label( - kb_info_frame, - text="🔒 基础知识: 加载中...", - font=("微软雅黑", 9, "bold"), - foreground="#d35400" - ) - self.base_kb_info_label.pack(side=tk.LEFT, padx=2) - - ttk.Button( - kb_info_frame, - text="查看基础知识", - width=12, - command=self.show_base_knowledge - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - kb_info_frame, - text="添加基础知识", - width=12, - command=self.add_base_knowledge - ).pack(side=tk.LEFT, padx=2) - - # 第二行:搜索和筛选 - kb_search_frame = ttk.Frame(kb_toolbar) - kb_search_frame.pack(fill=tk.X) - - ttk.Label(kb_search_frame, text="搜索:", font=("微软雅黑", 9)).pack(side=tk.LEFT, padx=2) - - self.kb_search_var = tk.StringVar() - self.kb_search_entry = ttk.Entry(kb_search_frame, textvariable=self.kb_search_var, width=20) - self.kb_search_entry.pack(side=tk.LEFT, padx=2) - - ttk.Button( - kb_search_frame, - text="🔍", - width=3, - command=self.search_knowledge - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - kb_search_frame, - text="刷新", - width=6, - command=self.update_knowledge_display - ).pack(side=tk.LEFT, padx=2) - - # 知识类型筛选 - ttk.Label(kb_search_frame, text="类型:", font=("微软雅黑", 9)).pack(side=tk.LEFT, padx=(10, 2)) - self.kb_type_var = tk.StringVar(value="全部") - self.kb_type_combo = ttk.Combobox( - kb_search_frame, - textvariable=self.kb_type_var, - width=12, - state="readonly" - ) - self.kb_type_combo['values'] = ['全部', '基础知识', '个人信息', '偏好', '事实', '经历', '观点', '其他'] - self.kb_type_combo.pack(side=tk.LEFT, padx=2) - self.kb_type_combo.bind('<>', lambda e: self.filter_knowledge_by_type()) - - # 知识显示区域 - self.knowledge_display = scrolledtext.ScrolledText( - knowledge_tab, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9", - relief=tk.FLAT - ) - self.knowledge_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.knowledge_display.config(state=tk.DISABLED) - - # 选项卡6: 环境管理(智能体视觉) - environment_tab = ttk.Frame(notebook) - notebook.add(environment_tab, text="👁️ 环境管理") - - # 环境管理工具栏 - 第一行 - env_toolbar1 = ttk.Frame(environment_tab) - env_toolbar1.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label( - env_toolbar1, - text="智能体视觉环境配置", - font=("微软雅黑", 10, "bold") - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - env_toolbar1, - text="🔄 刷新", - command=self.refresh_environment_display, - width=10 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar1, - text="➕ 新建环境", - command=self.create_new_environment, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar1, - text="➕ 添加物体", - command=self.add_new_object, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar1, - text="🏠 创建默认环境", - command=self.create_default_environment, - width=15 - ).pack(side=tk.LEFT, padx=2) - - # 环境管理工具栏 - 第二行(新增) - env_toolbar2 = ttk.Frame(environment_tab) - env_toolbar2.pack(fill=tk.X, padx=5, pady=(0, 5)) - - ttk.Button( - env_toolbar2, - text="🔀 切换环境", - command=self.switch_active_environment_dialog, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar2, - text="🔗 管理连接", - command=self.manage_environment_connections, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar2, - text="🗺️ 关系图", - command=self.show_environment_relationship_map, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar2, - text="📋 使用记录", - command=self.show_vision_logs, - width=12 - ).pack(side=tk.LEFT, padx=2) - - # 环境管理工具栏 - 第三行(域管理) - env_toolbar3 = ttk.Frame(environment_tab) - env_toolbar3.pack(fill=tk.X, padx=5, pady=(0, 5)) - - ttk.Label( - env_toolbar3, - text="域管理:", - font=("微软雅黑", 9, "bold") - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - env_toolbar3, - text="🏘️ 创建域", - command=self.create_domain_dialog, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar3, - text="✏️ 编辑域", - command=self.edit_domain_dialog, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar3, - text="📍 管理域环境", - command=self.manage_domain_environments_dialog, - width=14 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - env_toolbar3, - text="🎯 切换域", - command=self.switch_to_domain_dialog, - width=12 - ).pack(side=tk.LEFT, padx=2) - - # 环境显示区域 - self.environment_display = scrolledtext.ScrolledText( - environment_tab, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9", - relief=tk.FLAT - ) - self.environment_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.environment_display.config(state=tk.DISABLED) - - # 选项卡7: Debug日志(仅在debug模式下显示) - debug_mode = os.getenv('DEBUG_MODE', 'False').lower() == 'true' - if debug_mode: - debug_tab = ttk.Frame(notebook) - notebook.add(debug_tab, text="🔧 Debug日志") - - # Debug工具栏 - debug_toolbar = ttk.Frame(debug_tab) - debug_toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label( - debug_toolbar, - text="Debug模式已启用", - font=("微软雅黑", 9, "bold"), - foreground="#e74c3c" - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - debug_toolbar, - text="刷新日志", - width=10, - command=self.update_debug_display - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - debug_toolbar, - text="清空日志", - width=10, - command=self.clear_debug_logs - ).pack(side=tk.LEFT, padx=2) - - # 日志类型筛选 - ttk.Label(debug_toolbar, text="类型:", font=("微软雅黑", 9)).pack(side=tk.LEFT, padx=(10, 2)) - self.debug_type_var = tk.StringVar(value="全部") - debug_type_combo = ttk.Combobox( - debug_toolbar, - textvariable=self.debug_type_var, - width=12, - state="readonly" - ) - debug_type_combo['values'] = ['全部', 'module', 'prompt', 'request', 'response', 'error', 'info'] - debug_type_combo.pack(side=tk.LEFT, padx=2) - debug_type_combo.bind('<>', lambda e: self.update_debug_display()) - - # 自动刷新开关 - self.debug_auto_refresh = tk.BooleanVar(value=True) - ttk.Checkbutton( - debug_toolbar, - text="自动刷新", - variable=self.debug_auto_refresh - ).pack(side=tk.LEFT, padx=10) - - # 统计信息 - self.debug_stats_label = ttk.Label( - debug_toolbar, - text="日志: 0 条", - font=("微软雅黑", 8) - ) - self.debug_stats_label.pack(side=tk.RIGHT, padx=5) - - # Debug日志显示区域 - self.debug_display = scrolledtext.ScrolledText( - debug_tab, - wrap=tk.WORD, - font=("Consolas", 9), - bg="#1e1e1e", - fg="#d4d4d4", - relief=tk.FLAT, - insertbackground="white" - ) - self.debug_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.debug_display.config(state=tk.DISABLED) - - # 配置颜色标签 - self.debug_display.tag_config('module', foreground='#4ec9b0') - self.debug_display.tag_config('prompt', foreground='#ce9178') - self.debug_display.tag_config('request', foreground='#569cd6') - self.debug_display.tag_config('response', foreground='#4fc1ff') - self.debug_display.tag_config('error', foreground='#f48771') - self.debug_display.tag_config('info', foreground='#b5cea8') - self.debug_display.tag_config('timestamp', foreground='#858585') - - # 获取debug logger并添加监听器 - self.debug_logger = get_debug_logger() - self.debug_logger.add_listener(self.on_debug_log_added) - - # 选项卡8: 数据库管理(注:选项卡7是条件性的Debug日志,仅在DEBUG_MODE=True时显示) - db_tab = ttk.Frame(notebook) - notebook.add(db_tab, text="💾 数据库管理") - - # 导入并创建数据库管理GUI - try: - from src.gui.database_gui import DatabaseManagerGUI - # 获取数据库管理器实例 - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'db'): - db_manager = self.agent.db - else: - from src.core.database_manager import DatabaseManager - db_manager = DatabaseManager() - - self.db_gui = DatabaseManagerGUI(db_tab, db_manager) - except Exception as e: - ttk.Label(db_tab, text=f"数据库管理界面加载失败:\n{str(e)}", - font=("微软雅黑", 10), foreground="red").pack(pady=50) - - # 选项卡9: 设定迁移 - migration_tab = ttk.Frame(notebook) - notebook.add(migration_tab, text="📦 设定迁移") - - # 导入并创建设定迁移GUI - try: - from src.gui.settings_migration_gui import SettingsMigrationGUI - # 获取数据库管理器实例 - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'db'): - migration_db_manager = self.agent.db - else: - from src.core.database_manager import DatabaseManager - migration_db_manager = DatabaseManager() - - # 创建设定迁移GUI(直接嵌入到tab中) - self.migration_gui = SettingsMigrationGUI(migration_tab, migration_db_manager) - except Exception as e: - ttk.Label(migration_tab, text=f"设定迁移界面加载失败:\n{str(e)}", - font=("微软雅黑", 10), foreground="red").pack(pady=50) - - # 选项卡10: 控制面板 - control_tab = ttk.Frame(notebook) - notebook.add(control_tab, text="⚙️ 控制面板") - - self.create_control_panel(control_tab) - - # 选项卡11: 日程管理 - schedule_tab = ttk.Frame(notebook) - notebook.add(schedule_tab, text="📅 日程管理") - - # 创建独立的日程管理GUI - try: - from src.gui.schedule_gui import ScheduleManagerGUI - # 获取数据库管理器实例 - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'db'): - schedule_db_manager = self.agent.db - else: - from src.core.database_manager import DatabaseManager - schedule_db_manager = DatabaseManager() - - self.schedule_gui = ScheduleManagerGUI(schedule_tab, schedule_db_manager) - except Exception as e: - ttk.Label(schedule_tab, text=f"日程管理界面加载失败:\n{str(e)}", - font=("微软雅黑", 10), foreground="red").pack(pady=50) - - # 选项卡12: 事件管理 - event_tab = ttk.Frame(notebook) - notebook.add(event_tab, text="📋 事件管理") - - self.create_event_management_panel(event_tab) - - # 选项卡13: NPS工具管理 - nps_tab = ttk.Frame(notebook) - notebook.add(nps_tab, text="🔧 NPS工具") - - # 创建NPS工具管理GUI - try: - # 获取NPS注册表实例 - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'nps_registry'): - nps_registry = self.agent.nps_registry - else: - from src.nps.nps_registry import NPSRegistry - nps_registry = NPSRegistry() - nps_registry.scan_and_register() - - self.nps_gui = NPSManagerGUI(nps_tab, nps_registry) - except Exception as e: - ttk.Label(nps_tab, text=f"NPS工具管理界面加载失败:\n{str(e)}", - font=("微软雅黑", 10), foreground="red").pack(pady=50) - - def create_control_panel(self, parent): - """ - 创建控制面板 - - Args: - parent: 父容器 - """ - control_container = ttk.Frame(parent, padding=10) - control_container.pack(fill=tk.BOTH, expand=True) - - ttk.Label(control_container, text="记忆管理", font=("微软雅黑", 10, "bold")).pack(anchor=tk.W, pady=5) - - ttk.Button( - control_container, - text="🔄 刷新所有信息", - command=self.refresh_all, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Button( - control_container, - text="📈 更新主题时间线", - command=self.update_timeline, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Button( - control_container, - text="🗑️ 清空所有记忆", - command=self.clear_all_memory, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Separator(control_container, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) - - # 个性化表达管理 - ttk.Label(control_container, text="个性化表达", font=("微软雅黑", 10, "bold")).pack(anchor=tk.W, pady=5) - - ttk.Button( - control_container, - text="🎯 立即学习用户习惯", - command=self.learn_user_expressions_now, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Button( - control_container, - text="➕ 添加智能体表达", - command=self.add_agent_expression_dialog, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Button( - control_container, - text="📋 查看表达风格", - command=self.show_expression_style, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Separator(control_container, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) - - ttk.Label(control_container, text="系统设置", font=("微软雅黑", 10, "bold")).pack(anchor=tk.W, pady=5) - - ttk.Button( - control_container, - text="♻️ 重新加载代理", - command=self.reload_agent, - width=25 - ).pack(fill=tk.X, pady=2) - - ttk.Button( - control_container, - text="ℹ️ 关于", - command=self.show_about, - width=25 - ).pack(fill=tk.X, pady=2) - - def create_event_management_panel(self, parent): - """ - 创建事件管理面板 - - Args: - parent: 父容器 - """ - # 主容器 - main_container = ttk.Frame(parent, padding=10) - main_container.pack(fill=tk.BOTH, expand=True) - - # 顶部工具栏 - toolbar = ttk.Frame(main_container) - toolbar.pack(fill=tk.X, pady=(0, 10)) - - ttk.Label(toolbar, text="事件管理系统", font=("微软雅黑", 12, "bold")).pack(side=tk.LEFT, padx=5) - - ttk.Button( - toolbar, - text="➕ 新建事件", - command=self.create_new_event, - width=12 - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - toolbar, - text="🔄 刷新列表", - command=self.refresh_event_list, - width=12 - ).pack(side=tk.LEFT, padx=5) - ttk.Button( - toolbar, - text="🚀 触发事件", - command=self.trigger_selected_event, - width=15 - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - toolbar, - text="📝 查看详情", - command=self.view_event_details, - width=15 - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - toolbar, - text="👥 查看协作详情", - command=self.view_collaboration_details, - width=18 - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - toolbar, - text="🗑️ 删除事件", - command=self.delete_selected_event, - width=15 - ).pack(side=tk.LEFT, padx=5) - - # 事件统计 - stats_frame = ttk.LabelFrame(main_container, text="📊 事件统计", padding=10) - stats_frame.pack(fill=tk.X, pady=(0, 10)) - - self.event_stats_label = ttk.Label( - stats_frame, - text="加载中...", - font=("微软雅黑", 9) - ) - self.event_stats_label.pack(anchor=tk.W) - - # 事件列表容器 - list_frame = ttk.LabelFrame(main_container, text="📋 事件列表", padding=5) - list_frame.pack(fill=tk.BOTH, expand=True) - - # 创建Treeview显示事件列表 - columns = ('标题', '类型', '优先级', '状态', '创建时间') - self.event_tree = ttk.Treeview( - list_frame, - columns=columns, - show='tree headings', - selectmode='browse' - ) - - # 设置列标题 - self.event_tree.heading('#0', text='ID') - for col in columns: - self.event_tree.heading(col, text=col) - - # 设置列宽 - 优化以更好显示内容 - self.event_tree.column('#0', width=100, minwidth=80, stretch=False) - self.event_tree.column('标题', width=300, minwidth=200, stretch=True) - self.event_tree.column('类型', width=80, minwidth=60, stretch=False) - self.event_tree.column('优先级', width=80, minwidth=60, stretch=False) - self.event_tree.column('状态', width=80, minwidth=60, stretch=False) - self.event_tree.column('创建时间', width=160, minwidth=140, stretch=False) - - # 滚动条 - scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.event_tree.yview) - self.event_tree.configure(yscrollcommand=scrollbar.set) - - self.event_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # 为事件树添加鼠标悬停提示 - def get_event_tooltip(item_id, values, tags): - """获取事件的工具提示文本""" - if values and tags: - tooltip_text = f"标题: {values[0]}\n" - tooltip_text += f"类型: {values[1]}\n" - tooltip_text += f"优先级: {values[2]}\n" - tooltip_text += f"状态: {values[3]}\n" - tooltip_text += f"创建时间: {values[4]}\n" - tooltip_text += f"完整ID: {tags[0] if tags else 'N/A'}" - return tooltip_text - return None - - create_treeview_tooltip(self.event_tree, get_event_tooltip) - - - def refresh_event_list(self): - """刷新事件列表""" - if not self.agent: - return - - # 检查 event_tree 是否存在 - if not hasattr(self, 'event_tree'): - return - - try: - # 清空现有列表 - for item in self.event_tree.get_children(): - self.event_tree.delete(item) - - # 获取所有事件 - from src.core.event_manager import EventType, EventStatus - all_events = self.agent.event_manager.get_all_events(limit=100) - - # 类型和状态的中文映射 - type_map = { - EventType.NOTIFICATION.value: '通知', - EventType.TASK.value: '任务' - } - - status_map = { - EventStatus.PENDING.value: '待处理', - EventStatus.PROCESSING.value: '处理中', - EventStatus.COMPLETED.value: '已完成', - EventStatus.FAILED.value: '失败', - EventStatus.CANCELLED.value: '已取消' - } - - priority_map = {1: '低', 2: '中', 3: '高', 4: '紧急'} - - # 添加事件到列表 - for event in all_events: - event_dict = event.to_dict() - self.event_tree.insert( - '', - 'end', - text=event_dict['event_id'][:8], # 只显示前8位ID - values=( - event_dict['title'], - type_map.get(event_dict['event_type'], event_dict['event_type']), - priority_map.get(event_dict['priority'], event_dict['priority']), - status_map.get(event_dict['status'], event_dict['status']), - event_dict['created_at'][:19] # 只显示日期时间部分 - ), - tags=(event_dict['event_id'],) # 将完整ID存在tags中 - ) - - # 更新统计信息 - if hasattr(self, 'event_stats_label'): - stats = self.agent.event_manager.get_statistics() - stats_text = f"""总事件数:{stats['total_events']} -待处理:{stats['pending']} | 处理中:{stats['processing']} | 已完成:{stats['completed']} -通知型:{stats['notifications']} | 任务型:{stats['tasks']}""" - self.event_stats_label.config(text=stats_text) - except Exception as e: - print(f"刷新事件列表时出错: {e}") - import traceback - traceback.print_exc() - - def create_new_event(self): - """创建新事件对话框""" - from src.core.event_manager import EventType, EventPriority - - dialog = tk.Toplevel(self.root) - dialog.title("创建新事件") - dialog.geometry("500x600") - dialog.transient(self.root) - dialog.grab_set() - - # 主容器 - container = ttk.Frame(dialog, padding=20) - container.pack(fill=tk.BOTH, expand=True) - - # 事件标题 - ttk.Label(container, text="事件标题:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - title_entry = ttk.Entry(container, font=("微软雅黑", 10)) - title_entry.pack(fill=tk.X, pady=(0, 10)) - - # 事件描述 - ttk.Label(container, text="事件描述:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - desc_text = scrolledtext.ScrolledText(container, height=5, font=("微软雅黑", 9)) - desc_text.pack(fill=tk.X, pady=(0, 10)) - - # 事件类型 - ttk.Label(container, text="事件类型:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - type_var = tk.StringVar(value="notification") - type_frame = ttk.Frame(container) - type_frame.pack(fill=tk.X, pady=(0, 10)) - ttk.Radiobutton( - type_frame, - text="通知型(向用户说明信息)", - variable=type_var, - value="notification" - ).pack(anchor=tk.W) - ttk.Radiobutton( - type_frame, - text="任务型(需要完成任务)", - variable=type_var, - value="task" - ).pack(anchor=tk.W) - - # 优先级 - ttk.Label(container, text="优先级:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - priority_var = tk.IntVar(value=2) - priority_frame = ttk.Frame(container) - priority_frame.pack(fill=tk.X, pady=(0, 10)) - for val, text in [(1, "低"), (2, "中"), (3, "高"), (4, "紧急")]: - ttk.Radiobutton( - priority_frame, - text=text, - variable=priority_var, - value=val - ).pack(side=tk.LEFT, padx=5) - - # 任务特定字段(只在任务型时显示) - task_frame = ttk.LabelFrame(container, text="任务专用字段", padding=10) - - ttk.Label(task_frame, text="任务要求:", font=("微软雅黑", 9)).pack(anchor=tk.W, pady=(0, 5)) - task_req_text = scrolledtext.ScrolledText(task_frame, height=3, font=("微软雅黑", 9)) - task_req_text.pack(fill=tk.X, pady=(0, 10)) - - ttk.Label(task_frame, text="完成标准:", font=("微软雅黑", 9)).pack(anchor=tk.W, pady=(0, 5)) - task_crit_text = scrolledtext.ScrolledText(task_frame, height=3, font=("微软雅黑", 9)) - task_crit_text.pack(fill=tk.X, pady=(0, 10)) - - def toggle_task_fields(): - if type_var.get() == "task": - task_frame.pack(fill=tk.X, pady=(0, 10)) - else: - task_frame.pack_forget() - - # 绑定类型变化事件 - type_frame.winfo_children()[0].configure(command=toggle_task_fields) - type_frame.winfo_children()[1].configure(command=toggle_task_fields) - - # 按钮 - button_frame = ttk.Frame(container) - button_frame.pack(pady=(10, 0)) - - def do_create(): - title = title_entry.get().strip() - if not title: - messagebox.showwarning("警告", "请输入事件标题!") - return - - description = desc_text.get("1.0", tk.END).strip() - event_type = EventType.TASK if type_var.get() == "task" else EventType.NOTIFICATION - priority = EventPriority(priority_var.get()) - - task_requirements = "" - completion_criteria = "" - if event_type == EventType.TASK: - task_requirements = task_req_text.get("1.0", tk.END).strip() - completion_criteria = task_crit_text.get("1.0", tk.END).strip() - - try: - # 创建事件 - event = self.agent.event_manager.create_event( - title=title, - description=description, - event_type=event_type, - priority=priority, - task_requirements=task_requirements, - completion_criteria=completion_criteria - ) - - messagebox.showinfo("成功", f"事件创建成功!\nID: {event.event_id[:8]}...") - self.refresh_event_list() - dialog.destroy() - - except Exception as e: - messagebox.showerror("错误", f"创建事件失败:{str(e)}") - - ttk.Button(button_frame, text="创建", command=do_create, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def trigger_selected_event(self): - """触发选中的事件""" - selection = self.event_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一个事件!") - return - - # 获取完整的事件ID(从tags中) - item_tags = self.event_tree.item(selection[0], 'tags') - if not item_tags: - messagebox.showerror("错误", "无法获取事件ID!") - return - - event_id = item_tags[0] - - # 确认 - event = self.agent.event_manager.get_event(event_id) - if not event: - messagebox.showerror("错误", "事件不存在!") - return - - from src.core.event_manager import EventStatus - if event.status != EventStatus.PENDING: - result = messagebox.askyesno( - "确认", - f"事件状态为「{event.status.value}」\n确定要重新触发吗?" - ) - if not result: - return - - # 在新线程中处理事件 - def process_event_thread(): - try: - self.update_status("处理事件中...", "orange") - self.is_processing = True - - # 设置中断性提问的回调 - 使用线程安全的方式 - def question_callback(question): - # 使用事件和共享变量在主线程安全地显示对话框并获取结果 - result_event = threading.Event() - result_holder = {"answer": ""} - - def ask_on_main_thread(): - answer = simpledialog.askstring( - "智能体提问", - question, - parent=self.root - ) - result_holder["answer"] = answer or "" - result_event.set() - - self.root.after(0, ask_on_main_thread) - result_event.wait() - return result_holder["answer"] - - self.agent.interrupt_question_tool.set_question_callback(question_callback) - - # 处理事件 - result_message = self.agent.handle_event(event_id) - - # 在聊天区域显示结果 - self.root.after(0, lambda: self.add_system_message(result_message)) - - self.is_processing = False - self.update_status("就绪", "green") - - # 刷新事件列表 - self.root.after(0, self.refresh_event_list) - - except Exception as e: - # 出错时的处理 - error_msg = f"处理事件时发生错误:{str(e)}" - print(error_msg) - import traceback - traceback.print_exc() - - self.is_processing = False - self.update_status("错误", "red") - - # 在主线程显示错误消息 - self.root.after(0, lambda: self.show_error_dialog("处理错误", error_msg)) - - import threading - thread = threading.Thread(target=process_event_thread) - thread.daemon = True - thread.start() - - def view_event_details(self): - """查看事件详情""" - selection = self.event_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一个事件!") - return - - item_tags = self.event_tree.item(selection[0], 'tags') - if not item_tags: - messagebox.showerror("错误", "无法获取事件ID!") - return - - event_id = item_tags[0] - event = self.agent.event_manager.get_event(event_id) - - if not event: - messagebox.showerror("错误", "事件不存在!") - return - - # 创建详情对话框 - dialog = tk.Toplevel(self.root) - dialog.title(f"事件详情 - {event.title}") - dialog.geometry("600x700") - dialog.transient(self.root) - - container = ttk.Frame(dialog, padding=20) - container.pack(fill=tk.BOTH, expand=True) - - # 事件基本信息 - info_frame = ttk.LabelFrame(container, text="基本信息", padding=10) - info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - - event_dict = event.to_dict() - info_text = f"""事件ID: {event_dict['event_id']} -标题: {event_dict['title']} -类型: {event_dict['event_type']} -优先级: {event_dict['priority']} -状态: {event_dict['status']} -创建时间: {event_dict['created_at']} - -描述: -{event_dict['description']}""" - - from src.core.event_manager import EventType - if event.event_type == EventType.TASK: - info_text += f""" - -任务要求: -{event_dict['metadata'].get('task_requirements', '')} - -完成标准: -{event_dict['metadata'].get('completion_criteria', '')}""" - - info_text_widget = scrolledtext.ScrolledText( - info_frame, - wrap=tk.WORD, - font=("微软雅黑", 9), - height=10, - relief=tk.FLAT, - background="#f8f9fa" - ) - info_text_widget.pack(fill=tk.BOTH, expand=True) - info_text_widget.insert(tk.END, info_text) - info_text_widget.config(state=tk.DISABLED) - - # 处理日志 - log_frame = ttk.LabelFrame(container, text="处理日志", padding=10) - log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - - log_text = scrolledtext.ScrolledText( - log_frame, - wrap=tk.WORD, - font=("微软雅黑", 9), - height=15 - ) - log_text.pack(fill=tk.BOTH, expand=True) - - logs = self.agent.event_manager.get_event_logs(event_id) - if logs: - for log in logs: - log_text.insert(tk.END, f"[{log['created_at']}] {log['log_type']}\n") - log_text.insert(tk.END, f"{log['log_content']}\n\n") - else: - log_text.insert(tk.END, "暂无处理日志") - - log_text.config(state=tk.DISABLED) - - # 关闭按钮 - ttk.Button( - container, - text="关闭", - command=dialog.destroy, - width=15 - ).pack(pady=(10, 0)) - - def delete_selected_event(self): - """删除选中的事件""" - selection = self.event_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一个事件!") - return - - item_tags = self.event_tree.item(selection[0], 'tags') - if not item_tags: - messagebox.showerror("错误", "无法获取事件ID!") - return - - event_id = item_tags[0] - event = self.agent.event_manager.get_event(event_id) - - if not event: - messagebox.showerror("错误", "事件不存在!") - return - - # 确认删除 - result = messagebox.askyesno( - "确认删除", - f"确定要删除事件「{event.title}」吗?\n此操作不可恢复!" - ) - - if result: - success = self.agent.event_manager.delete_event(event_id) - if success: - messagebox.showinfo("成功", "事件已删除") - self.refresh_event_list() - else: - messagebox.showerror("错误", "删除事件失败!") - - def view_collaboration_details(self): - """查看智能体协作详情""" - selection = self.event_tree.selection() - if not selection: - messagebox.showwarning("警告", "请先选择一个事件!") - return - - item_tags = self.event_tree.item(selection[0], 'tags') - if not item_tags: - messagebox.showerror("错误", "无法获取事件ID!") - return - - event_id = item_tags[0] - event = self.agent.event_manager.get_event(event_id) - - if not event: - messagebox.showerror("错误", "事件不存在!") - return - - # 检查是否为任务型事件 - from src.core.event_manager import EventType - if event.event_type != EventType.TASK: - messagebox.showinfo("提示", "只有任务型事件才有协作详情!") - return - - # 获取协作相关数据 - collaboration_logs = event.metadata.get('collaboration_logs', []) - orchestration_plan = event.metadata.get('orchestration_plan', None) - agent_results = event.metadata.get('agent_results', {}) - - if not collaboration_logs and not orchestration_plan: - messagebox.showinfo("提示", "该事件还没有协作日志。\n请先触发该任务事件。") - return - - # 创建对话框显示协作详情 - dialog = tk.Toplevel(self.root) - dialog.title(f"智能体协作详情 - {event.title}") - dialog.geometry("1000x750") - - # 设置对话框图标(如果主窗口有图标) - if self.root.iconbitmap: - try: - dialog.iconbitmap(default=self.root.iconbitmap) - except: - pass - - # 主容器 - main_frame = ttk.Frame(dialog, padding=10) - main_frame.pack(fill=tk.BOTH, expand=True) - - # 标题框架 - title_frame = ttk.Frame(main_frame) - title_frame.pack(fill=tk.X, pady=(0, 10)) - - ttk.Label( - title_frame, - text=f"📋 任务:{event.title}", - font=("微软雅黑", 12, "bold") - ).pack(side=tk.LEFT) - - # 显示协作模式 - if orchestration_plan: - strategy = orchestration_plan.get('execution_strategy', 'unknown') - strategy_text = { - 'simple': '简单直接', - 'parallel': '并行执行', - 'sequential': '顺序执行' - }.get(strategy, strategy) - - mode_label = ttk.Label( - title_frame, - text=f"🤖 {strategy_text}模式", - font=("微软雅黑", 9, "bold"), - foreground="#2c5aa0" - ) - mode_label.pack(side=tk.RIGHT, padx=10) - - ttk.Label( - title_frame, - text=f"共 {len(collaboration_logs)} 条协作记录", - font=("微软雅黑", 9), - foreground="gray" - ).pack(side=tk.RIGHT) - - # 创建Notebook用于分标签页显示 - notebook = ttk.Notebook(main_frame) - notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - - # 标签页1:编排计划(如果有) - if orchestration_plan: - plan_frame = ttk.Frame(notebook, padding=10) - notebook.add(plan_frame, text="📊 编排计划") - - # 创建滚动文本框 - plan_scrollbar = ttk.Scrollbar(plan_frame) - plan_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - plan_text = tk.Text( - plan_frame, - wrap=tk.WORD, - yscrollcommand=plan_scrollbar.set, - font=("Microsoft YaHei", 10), - padx=10, - pady=10, - relief=tk.FLAT, - background="#f8f9fa" - ) - plan_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - plan_scrollbar.config(command=plan_text.yview) - - # 配置标签样式 - plan_text.tag_config("title", foreground="#2c5aa0", font=("微软雅黑", 11, "bold")) - plan_text.tag_config("label", foreground="#6c757d", font=("微软雅黑", 9, "bold")) - plan_text.tag_config("value", foreground="#212529", font=("微软雅黑", 10)) - plan_text.tag_config("agent", foreground="#28a745", font=("微软雅黑", 10, "bold")) - - # 显示编排计划 - plan_text.insert(tk.END, "🎯 任务分析\n", "title") - plan_text.insert(tk.END, "─" * 80 + "\n\n") - - complexity = orchestration_plan.get('complexity', 'unknown') - plan_text.insert(tk.END, "任务复杂度:", "label") - plan_text.insert(tk.END, f"{complexity}\n\n", "value") - - plan_text.insert(tk.END, "执行策略:", "label") - plan_text.insert(tk.END, f"{strategy_text}\n\n", "value") - - reasoning = orchestration_plan.get('reasoning', '') - if reasoning: - plan_text.insert(tk.END, "分析理由:", "label") - plan_text.insert(tk.END, f"\n{reasoning}\n\n", "value") - - # 显示智能体列表 - agents = orchestration_plan.get('agents', []) - if agents: - plan_text.insert(tk.END, f"\n👥 智能体分配 (共{len(agents)}个)\n", "title") - plan_text.insert(tk.END, "─" * 80 + "\n\n") - - for i, agent in enumerate(agents, 1): - plan_text.insert(tk.END, f"智能体 {i}: ", "label") - plan_text.insert(tk.END, f"{agent.get('role', '未知')}\n", "agent") - - plan_text.insert(tk.END, " 任务: ", "label") - plan_text.insert(tk.END, f"{agent.get('task', '未指定')}\n", "value") - - deps = agent.get('dependencies', []) - if deps: - plan_text.insert(tk.END, " 依赖: ", "label") - plan_text.insert(tk.END, f"{', '.join(deps)}\n", "value") - - status = agent.get('status', 'pending') - plan_text.insert(tk.END, " 状态: ", "label") - status_text = { - 'pending': '待执行', - 'running': '执行中', - 'completed': '已完成', - 'failed': '失败' - }.get(status, status) - plan_text.insert(tk.END, f"{status_text}\n\n", "value") - - plan_text.config(state=tk.DISABLED) - - # 标签页2:智能体结果(如果有) - if agent_results: - results_frame = ttk.Frame(notebook, padding=10) - notebook.add(results_frame, text="📝 智能体结果") - - results_scrollbar = ttk.Scrollbar(results_frame) - results_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - results_text = tk.Text( - results_frame, - wrap=tk.WORD, - yscrollcommand=results_scrollbar.set, - font=("Microsoft YaHei", 10), - padx=10, - pady=10, - relief=tk.FLAT, - background="#f8f9fa" - ) - results_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - results_scrollbar.config(command=results_text.yview) - - # 配置标签样式 - results_text.tag_config("agent_name", foreground="#2c5aa0", font=("微软雅黑", 10, "bold")) - results_text.tag_config("result", foreground="#212529", font=("微软雅黑", 10)) - results_text.tag_config("separator", foreground="#dee2e6") - - # 显示各智能体的结果 - for i, (agent_id, result) in enumerate(agent_results.items(), 1): - # 尝试从orchestration_plan中找到对应的角色名 - role = agent_id - if orchestration_plan: - agents = orchestration_plan.get('agents', []) - for agent in agents: - if agent.get('agent_id') == agent_id: - role = agent.get('role', agent_id) - break - - results_text.insert(tk.END, f"【{role}】\n", "agent_name") - results_text.insert(tk.END, f"{result}\n", "result") - - if i < len(agent_results): - results_text.insert(tk.END, "\n" + "─" * 80 + "\n\n", "separator") - - results_text.config(state=tk.DISABLED) - - # 标签页3:协作日志 - logs_frame = ttk.Frame(notebook, padding=10) - notebook.add(logs_frame, text="📜 协作日志") - - # 创建滚动文本框 - scrollbar = ttk.Scrollbar(logs_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - text_widget = tk.Text( - logs_frame, - wrap=tk.WORD, - yscrollcommand=scrollbar.set, - font=("Microsoft YaHei", 10), - padx=10, - pady=10, - relief=tk.FLAT, - background="#f8f9fa" - ) - text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.config(command=text_widget.yview) - - # 配置文本标签样式 - text_widget.tag_config("agent_name", foreground="#2c5aa0", font=("微软雅黑", 10, "bold")) - text_widget.tag_config("timestamp", foreground="#6c757d", font=("微软雅黑", 8)) - text_widget.tag_config("action", foreground="#28a745", font=("微软雅黑", 9, "bold")) - text_widget.tag_config("content", foreground="#212529", font=("微软雅黑", 10)) - text_widget.tag_config("separator", foreground="#dee2e6") - - # 插入协作日志内容(对话形式) - for i, log in enumerate(collaboration_logs): - agent_role = log.get('agent_role', log.get('role', '未知智能体')) - action = log.get('action', log.get('type', '操作')) - content = log.get('content', log.get('message', '')) - timestamp = log.get('timestamp', '') - - # 格式化时间戳 - try: - from datetime import datetime - dt = datetime.fromisoformat(timestamp) - time_str = dt.strftime('%H:%M:%S') - except: - time_str = timestamp[-8:] if len(timestamp) >= 8 else timestamp - - # 插入时间戳 - text_widget.insert(tk.END, f"[{time_str}] ", "timestamp") - - # 插入智能体角色 - text_widget.insert(tk.END, f"{agent_role}", "agent_name") - text_widget.insert(tk.END, " ") - - # 插入动作 - text_widget.insert(tk.END, f"「{action}」\n", "action") - - # 插入内容 - if content: - text_widget.insert(tk.END, f" {content}\n", "content") - - # 添加分隔线(除了最后一条) - if i < len(collaboration_logs) - 1: - text_widget.insert(tk.END, " " + "─" * 80 + "\n\n", "separator") - else: - text_widget.insert(tk.END, "\n") - - # 禁用编辑 - text_widget.config(state=tk.DISABLED) - - # 底部按钮 - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill=tk.X) - - ttk.Button( - button_frame, - text="关闭", - command=dialog.destroy, - width=15 - ).pack(side=tk.RIGHT, padx=5) - - ttk.Button( - button_frame, - text="导出完整报告", - command=lambda: self.export_collaboration_logs(event, collaboration_logs, orchestration_plan, agent_results), - width=15 - ).pack(side=tk.RIGHT, padx=5) - - def export_collaboration_logs(self, event, collaboration_logs, orchestration_plan=None, agent_results=None): - """导出智能体协作报告""" - from tkinter import filedialog - import json - from datetime import datetime - - # 让用户选择保存位置 - default_filename = f"collaboration_report_{event.event_id[:8]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - filepath = filedialog.asksaveasfilename( - title="导出协作报告", - defaultextension=".txt", - initialfile=default_filename, - filetypes=[ - ("文本文件", "*.txt"), - ("JSON文件", "*.json"), - ("所有文件", "*.*") - ] - ) - - if not filepath: - return - - try: - if filepath.endswith('.json'): - # 导出为JSON格式 - with open(filepath, 'w', encoding='utf-8') as f: - json.dump({ - 'event': { - 'event_id': event.event_id, - 'title': event.title, - 'description': event.description, - 'status': event.status.value, - 'created_at': event.created_at - }, - 'orchestration_plan': orchestration_plan, - 'agent_results': agent_results, - 'collaboration_logs': collaboration_logs - }, f, ensure_ascii=False, indent=2) - else: - # 导出为文本格式 - with open(filepath, 'w', encoding='utf-8') as f: - f.write("=" * 80 + "\n") - f.write(f"智能体协作报告\n") - f.write("=" * 80 + "\n\n") - - f.write(f"任务标题:{event.title}\n") - f.write(f"任务ID:{event.event_id}\n") - f.write(f"状态:{event.status.value}\n") - f.write(f"创建时间:{event.created_at}\n") - f.write(f"导出时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") - - f.write(f"描述:{event.description}\n\n") - - # 导出编排计划 - if orchestration_plan: - f.write("=" * 80 + "\n") - f.write("【编排计划】\n") - f.write("=" * 80 + "\n\n") - - complexity = orchestration_plan.get('complexity', 'unknown') - strategy = orchestration_plan.get('execution_strategy', 'unknown') - reasoning = orchestration_plan.get('reasoning', '') - - f.write(f"任务复杂度:{complexity}\n") - f.write(f"执行策略:{strategy}\n") - if reasoning: - f.write(f"分析理由:{reasoning}\n") - f.write("\n") - - agents = orchestration_plan.get('agents', []) - if agents: - f.write(f"智能体分配(共{len(agents)}个):\n\n") - for i, agent in enumerate(agents, 1): - f.write(f" 智能体 {i}:{agent.get('role', '未知')}\n") - f.write(f" ID:{agent.get('agent_id', '')}\n") - f.write(f" 任务:{agent.get('task', '未指定')}\n") - f.write(f" 职责:{agent.get('description', '')}\n") - deps = agent.get('dependencies', []) - if deps: - f.write(f" 依赖:{', '.join(deps)}\n") - status = agent.get('status', 'pending') - f.write(f" 状态:{status}\n") - f.write("\n") - - # 导出智能体结果 - if agent_results: - f.write("=" * 80 + "\n") - f.write("【智能体执行结果】\n") - f.write("=" * 80 + "\n\n") - - for agent_id, result in agent_results.items(): - # 尝试找到角色名 - role = agent_id - if orchestration_plan: - agents = orchestration_plan.get('agents', []) - for agent in agents: - if agent.get('agent_id') == agent_id: - role = agent.get('role', agent_id) - break - - f.write(f"【{role}】\n") - f.write(f"{result}\n") - f.write("-" * 80 + "\n\n") - - # 导出协作日志 - f.write("=" * 80 + "\n") - f.write(f"【协作日志】(共{len(collaboration_logs)}条记录)\n") - f.write("=" * 80 + "\n\n") - - for i, log in enumerate(collaboration_logs, 1): - agent_role = log.get('agent_role', log.get('role', '未知智能体')) - action = log.get('action', log.get('type', '操作')) - content = log.get('content', log.get('message', '')) - timestamp = log.get('timestamp', '') - - f.write(f"[{i}] [{timestamp}] {agent_role} - {action}\n") - if content: - f.write(f" {content}\n") - f.write("\n") - - messagebox.showinfo("成功", f"协作报告已导出到:\n{filepath}") - except Exception as e: - messagebox.showerror("错误", f"导出失败:{str(e)}") - - def initialize_agent(self): - """ - 初始化聊天代理 - """ - try: - self.update_status("初始化中...", "orange") - self.agent = ChatAgent() - - # 记录初始知识库数量 - stats = self.agent.get_memory_stats() - self._last_kb_count = stats['knowledge_base']['total_knowledge'] - - # 记录初始情感分析数量 - emotion_history = self.agent.get_emotion_history() - self._last_emotion_count = len(emotion_history) - - # 如果已有情感数据,加载并显示最新的 - if emotion_history: - latest_emotion = self.agent.get_latest_emotion() - if latest_emotion: - self.update_emotion_display(latest_emotion) - print(f"✓ 加载已有情感数据: {len(emotion_history)} 条记录") - print(f" 最新关系类型: {latest_emotion.get('relationship_type', '未知')}") - print(f" 情感基调: {latest_emotion.get('emotional_tone', '未知')}") - print(f" 总体评分: {latest_emotion.get('overall_score', 0)}/100") - - # 更新所有信息显示 - self.update_character_info() - self.update_system_info() - self.refresh_all() - - # 刷新事件列表 - self.refresh_event_list() - - # 显示欢迎消息 - self.add_system_message("系统初始化完成!开始对话吧~") - - # 如果有情感数据,显示提示 - if emotion_history: - self.add_system_message( - f"💖 已加载情感分析数据 ({len(emotion_history)} 条) | " - f"当前关系:{latest_emotion.get('relationship_type', '未知')}" - ) - - self.update_status("就绪", "green") - - except Exception as e: - self.update_status("初始化失败", "red") - # 使用增强的错误格式 - error_msg = DebugLogger.format_exception_with_location(e, include_traceback=True) - self.show_error_dialog("初始化错误", f"初始化聊天代理时出错:\n\n{error_msg}") - - def update_character_info(self): - """ - 更新角色信息显示 - """ - if self.agent: - char_info = self.agent.get_character_info() - # 简化显示文本,主要信息在第一行 - info_text = f"姓名: {char_info['character_name']} | 性别: {char_info['character_gender']} | 身份: {char_info['character_role']} | 年龄: {char_info['character_age']}岁\n" - info_text += f"性格: {char_info['character_personality'][:50]}{'...' if len(char_info['character_personality']) > 50 else ''}" - - # 完整信息用于工具提示 - full_info = f"姓名: {char_info['character_name']}\n" - full_info += f"性别: {char_info['character_gender']}\n" - full_info += f"身份: {char_info['character_role']}\n" - full_info += f"年龄: {char_info['character_age']}岁\n" - full_info += f"身高: {char_info['character_height']}\n" - full_info += f"体重: {char_info['character_weight']}\n" - full_info += f"性格: {char_info['character_personality']}\n" - full_info += f"背景: {char_info.get('character_background', '未设置')}\n" - full_info += f"爱好: {char_info.get('character_hobby', '未设置')}" - - self.character_label.config(text=info_text) - - # 更新canvas滚动区域 - if hasattr(self, 'character_canvas'): - self.character_canvas.configure(scrollregion=self.character_canvas.bbox("all")) - - # 更新工具提示 - if self.character_tooltip: - self.character_tooltip.update_text(full_info) - else: - self.character_tooltip = ToolTip(self.character_label, full_info, delay=500, wraplength=500) - - def update_system_info(self): - """ - 更新系统信息显示 - """ - if not self.agent: - return - - info = [] - info.append("=" * 40) - info.append("系统信息") - info.append("=" * 40) - info.append("") - - char_info = self.agent.get_character_info() - info.append("【角色信息】") - for key, value in char_info.items(): - info.append(f" {key}: {value}") - - info.append("") - info.append("【系统配置】") - # 显示数据库路径而不是JSON文件路径 - info.append(f" 数据库文件: {self.agent.memory_manager.db.db_path}") - info.append(f" 最大短期轮数: {self.agent.memory_manager.max_short_term_rounds}") - info.append(f" 知识提取间隔: {self.agent.memory_manager.knowledge_extraction_interval} 轮") - info.append(f" API模型: {self.agent.llm.model_name}") - info.append(f" 温度参数: {self.agent.llm.temperature}") - - self.update_text_widget(self.info_display, "\n".join(info)) - - def refresh_all(self): - """ - 刷新所有信息 - """ - if not self.agent: - return - - self.update_memory_status() - self.update_short_term_display() - self.update_long_term_display() - self.update_understanding_display() # 新增:更新理解阶段显示 - self.update_knowledge_display() - self.refresh_environment_display() # 新增:更新环境显示 - self.update_timeline() - - def analyze_emotion(self): - """ - 分析情感关系 - """ - if not self.agent: - messagebox.showwarning("警告", "聊天代理未初始化") - return - - # 检查对话数量 - history = self.agent.get_conversation_history() - if len(history) < 2: - messagebox.showinfo("提示", "对话轮数太少,至少需要1轮对话(2条消息)才能进行情感分析") - return - - debug_logger = get_debug_logger() - debug_logger.log_module('GUI', '用户触发情感分析', { - 'history_count': len(history) - }) - - # 在线程中执行分析,避免UI卡顿 - def analyze_thread(): - try: - self.update_status("分析情感关系中...", "orange") - debug_logger.log_info('GUI', '开始情感分析线程') - - # 调用情感分析 - emotion_data = self.agent.analyze_emotion() - - debug_logger.log_info('GUI', '情感分析线程完成', { - 'overall_score': emotion_data.get('overall_score', 0), - 'relationship_type': emotion_data.get('relationship_type', '未知') - }) - - # 更新显示 - self.root.after(0, lambda: self.update_emotion_display(emotion_data)) - self.root.after(0, lambda: self.update_status("情感分析完成", "green")) - self.root.after(0, lambda: messagebox.showinfo("完成", "情感关系分析已完成!")) - - except Exception as e: - debug_logger.log_error('GUI', f'情感分析线程出错: {str(e)}', e) - error_msg = DebugLogger.format_exception_with_location(e, include_traceback=True) - self.root.after(0, lambda: self.update_status("分析失败", "red")) - self.root.after(0, lambda: self.show_error_dialog("错误", f"情感分析时出错:\n\n{error_msg}")) - - thread = threading.Thread(target=analyze_thread, daemon=True) - thread.start() - - def update_emotion_display(self, emotion_data: Dict[str, Any]): - """ - 更新情感关系显示 - - Args: - emotion_data: 情感分析数据 - """ - if not emotion_data: - return - - debug_logger = get_debug_logger() - debug_logger.log_info('GUI', '更新情感显示', { - 'has_data': bool(emotion_data) - }) - - # 更新雷达图 - self.emotion_canvas.update_emotion(emotion_data) - - # 更新详细信息文本 - info_text = format_emotion_summary(emotion_data) - self.update_text_widget(self.emotion_info_text, info_text) - - debug_logger.log_info('GUI', '情感显示更新完成') - - def update_memory_status(self): - """ - 更新记忆状态显示 - """ - if not self.agent: - return - - stats = self.agent.get_memory_stats() - base_kb_count = stats['knowledge_base'].get('total_base_knowledge', 0) - - # 优化状态文本,更紧凑 - status_text = f"短期: {stats['short_term']['rounds']}轮 | 长期: {stats['long_term']['total_summaries']}主题 | 知识: {stats['knowledge_base']['total_knowledge']}条" - if base_kb_count > 0: - status_text += f" | 基础: {base_kb_count}条" - - self.memory_status_label.config(text=status_text) - - # 添加完整信息工具提示 - full_info = f"短期记忆:\n" - full_info += f" - 对话轮数: {stats['short_term']['rounds']}\n" - full_info += f" - 消息数量: {stats['short_term']['total_messages']}\n\n" - full_info += f"长期记忆:\n" - full_info += f" - 主题概括: {stats['long_term']['total_summaries']} 个\n" - full_info += f" - 总轮数: {stats['long_term']['total_archived_rounds']}\n" - full_info += f" - 总消息: {stats['long_term']['total_archived_messages']}\n\n" - full_info += f"知识库:\n" - full_info += f" - 普通知识: {stats['knowledge_base']['total_knowledge']} 条\n" - full_info += f" - 基础知识: {base_kb_count} 条\n" - full_info += f" - 主体数量: {stats['knowledge_base'].get('total_entities', 0)}" - - # 更新或创建工具提示 - if not hasattr(self, 'memory_status_tooltip'): - self.memory_status_tooltip = ToolTip(self.memory_status_label, full_info, delay=500, wraplength=300) - else: - self.memory_status_tooltip.update_text(full_info) - - def update_short_term_display(self): - """ - 更新短期记忆显示 - """ - if not self.agent: - return - - history = self.agent.get_conversation_history() - - if not history: - self.update_text_widget(self.short_term_display, "暂无短期记忆") - return - - text = [] - text.append("=" * 40) - text.append(f"短期记忆 (共 {len(history)} 条消息)") - text.append("=" * 40) - text.append("") - - for i, msg in enumerate(history, 1): - role = "用户" if msg['role'] == 'user' else self.agent.character.name - timestamp = msg.get('timestamp', 'Unknown')[:19] - text.append(f"[{i}] {timestamp}") - text.append(f"{role}: {msg['content']}") - text.append("-" * 40) - - self.update_text_widget(self.short_term_display, "\n".join(text)) - - def update_long_term_display(self): - """ - 更新长期记忆显示 - """ - if not self.agent: - return - - summaries = self.agent.get_long_term_summaries() - - if not summaries: - self.update_text_widget(self.long_term_display, "暂无长期记忆\n对话超过20轮后将自动生成") - return - - text = [] - text.append("=" * 40) - text.append(f"长期记忆概括 (共 {len(summaries)} 个主题)") - text.append("=" * 40) - text.append("") - - for i, summary in enumerate(summaries, 1): - text.append(f"【主题 {i}】") - text.append(f"UUID: {summary.get('uuid', '')}") - text.append(f"时间: {summary.get('created_at', '')[:19]} ~ {summary.get('ended_at', '')[:19]}") - text.append(f"对话轮数: {summary.get('rounds', 0)} 轮") - text.append(f"消息数量: {summary.get('message_count', 0)} 条") - text.append(f"主题概括: {summary.get('summary', '')}") - text.append("=" * 40) - text.append("") - - self.update_text_widget(self.long_term_display, "\n".join(text)) - - def update_understanding_display(self, understanding_result: Dict[str, Any] = None): - """ - 更新理解阶段显示 - - Args: - understanding_result: 理解阶段结果字典 - """ - if not self.agent: - return - - # 如果没有传入结果,尝试获取最后一次的结果 - if understanding_result is None: - understanding_result = self.agent.get_last_understanding() - - if not understanding_result: - self.update_text_widget( - self.understanding_display, - "理解阶段\n\n等待用户输入...\n\n说明:\n当你发送消息后,系统会:\n1. 提取消息中的相关主体\n2. 从知识库检索相关知识\n3. 按优先级排序(定义>相关信息)\n4. 将知识提供给AI参考" - ) - return - - text = [] - text.append("=" * 50) - text.append("🧠 理解阶段分析") - text.append("=" * 50) - text.append("") - - text.append(f"【用户输入】\n{understanding_result.get('query', '')}") - text.append("") - text.append("-" * 50) - - entities_found = understanding_result.get('entities_found', []) - if entities_found: - text.append(f"\n【识别到的主体】(共 {len(entities_found)} 个)") - for i, entity in enumerate(entities_found, 1): - text.append(f" {i}. {entity}") - else: - text.append("\n【识别到的主体】") - text.append(" 未识别到相关主体") - - text.append("") - text.append("-" * 50) - - knowledge_items = understanding_result.get('knowledge_items', []) - if knowledge_items: - text.append(f"\n【检索到的知识】(共 {len(knowledge_items)} 条,按优先级排序)") - text.append("") - - # 按主体分组 - by_entity = {} - for item in knowledge_items: - entity_name = item['entity_name'] - if entity_name not in by_entity: - by_entity[entity_name] = {'definitions': [], 'info': []} - - if item['type'] == '定义': - by_entity[entity_name]['definitions'].append(item) - else: - by_entity[entity_name]['info'].append(item) - - for entity_name, items in by_entity.items(): - text.append(f"► 主体: {entity_name}") - text.append("") - - # 显示定义 - if items['definitions']: - for definition in items['definitions']: - confidence = definition['confidence'] - confidence_icon = "⭐⭐⭐" if confidence >= 0.9 else "⭐⭐" - priority_label = "【最高优先级】" - text.append(f" {confidence_icon} {priority_label} 定义") - text.append(f" 置信度: {confidence:.2f}") - text.append(f" 内容: {definition['content']}") - text.append(f" 时间: {definition.get('created_at', '')[:19]}") - text.append("") - - # 显示相关信息 - if items['info']: - text.append(" 其他相关信息:") - for info in items['info']: - confidence = info['confidence'] - confidence_icon = "⭐⭐" if confidence >= 0.8 else "⭐" - priority_label = "【次优先级】" - text.append(f" {confidence_icon} {priority_label} {info['type']}") - text.append(f" 置信度: {confidence:.2f}") - text.append(f" 内容: {info['content']}") - text.append(f" 时间: {info.get('created_at', '')[:19]}") - text.append("") - - text.append("-" * 50) - else: - text.append("\n【检索到的知识】") - text.append(" 知识库中暂无相关信息") - text.append("") - - text.append("") - text.append("【摘要】") - text.append(understanding_result.get('summary', '')) - text.append("") - text.append("=" * 50) - text.append("✓ AI将基于以上知识来回答用户问题") - - self.update_text_widget(self.understanding_display, "\n".join(text)) - - def update_knowledge_display(self): - """ - 更新知识库显示(支持基础知识和主体-定义-信息结构) - """ - if not self.agent: - return - - # 更新基础知识信息标签 - if hasattr(self.agent.memory_manager.knowledge_base, 'base_knowledge'): - base_kb = self.agent.memory_manager.knowledge_base.base_knowledge - base_facts = base_kb.get_all_base_facts() - self.base_kb_info_label.config( - text=f"🔒 基础知识: {len(base_facts)} 条 (优先级: 100%)" - ) - - knowledge_list = self.agent.get_all_knowledge() - - if not knowledge_list: - # 即使没有普通知识,也显示基础知识 - if hasattr(self.agent.memory_manager.knowledge_base, 'base_knowledge'): - base_kb = self.agent.memory_manager.knowledge_base.base_knowledge - base_facts = base_kb.get_all_base_facts() - if base_facts: - text = [] - text.append("=" * 60) - text.append("【核心基础知识库 - 最高优先级】") - text.append("=" * 60) - text.append("") - - for fact in base_facts: - text.append(f"🔒 主体: {fact['entity_name']}") - text.append(f" 内容: {fact['content']}") - text.append(f" 分类: {fact['category']}") - text.append(f" 优先级: {fact['priority']} | 置信度: {fact['confidence']*100:.0f}%") - if fact.get('description'): - text.append(f" 说明: {fact['description']}") - text.append(f" 创建时间: {fact['created_at'][:19]}") - text.append(f" 🔐 状态: 不可更改") - text.append("") - text.append("-" * 60) - text.append("") - - text.append("\n普通知识库: 暂无知识\n对话超过5轮后将自动提取知识") - self.update_text_widget(self.knowledge_display, "\n".join(text)) - return - - self.update_text_widget(self.knowledge_display, "暂无知识\n对话超过5轮后将自动提取知识") - return - - text = [] - text.append("=" * 60) - text.append(f"知识库总览") - - # 显示统计信息 - if hasattr(self.agent.memory_manager.knowledge_base, 'get_statistics'): - stats = self.agent.memory_manager.knowledge_base.get_statistics() - status_dist = stats.get('status_distribution', {}) - text.append(f"基础知识: {stats.get('base_knowledge_facts', 0)} 条 (优先级100%) | " - f"主体数: {stats.get('total_entities', 0)} | " - f"定义: {stats.get('total_definitions', 0)} | " - f"相关信息: {stats.get('total_related_info', 0)}") - if status_dist: - text.append(f"知识状态: 确认 {status_dist.get('确认', 0)} 条 | " - f"疑似 {status_dist.get('疑似', 0)} 条") - - text.append("=" * 60) - text.append("") - - # 首先显示基础知识(如果有) - if hasattr(self.agent.memory_manager.knowledge_base, 'base_knowledge'): - base_kb = self.agent.memory_manager.knowledge_base.base_knowledge - base_facts = base_kb.get_all_base_facts() - - if base_facts: - text.append("╔" + "═" * 58 + "╗") - text.append("║" + " " * 15 + "【核心基础知识 - 优先级100%】" + " " * 15 + "║") - text.append("╚" + "═" * 58 + "╝") - text.append("") - - for fact in base_facts: - text.append(f"🔒 主体: {fact['entity_name']}") - text.append(f" ● 内容: {fact['content']}") - text.append(f" ● 分类: {fact['category']} | 置信度: {fact['confidence']*100:.0f}%") - if fact.get('description'): - text.append(f" ● 说明: {fact['description']}") - text.append(f" ● 时间: {fact['created_at'][:19]} | 状态: 🔐 不可更改") - text.append("") - - text.append("=" * 60) - text.append("") - - # 显示普通知识库(按主体分组显示) - text.append("【普通知识库】") - text.append("") - - knowledge_by_entity = {} - for k in knowledge_list: - entity_name = k.get('entity_name', '未知主体') - if entity_name not in knowledge_by_entity: - knowledge_by_entity[entity_name] = {'definitions': [], 'related': []} - - if k.get('is_definition', False): - knowledge_by_entity[entity_name]['definitions'].append(k) - else: - knowledge_by_entity[entity_name]['related'].append(k) - - for entity_name, items in knowledge_by_entity.items(): - text.append(f"📌 主体: {entity_name}") - text.append("") - - # 显示定义(高置信度) - if items['definitions']: - for definition in items['definitions']: - confidence = definition.get('confidence', 1.0) - confidence_icon = "⭐" if confidence >= 0.9 else "✓" - - # 检查是否来自基础知识 - is_base = definition.get('is_base_knowledge', False) - base_mark = " [基础知识]" if is_base else "" - - text.append(f" {confidence_icon} 定义 (置信度: {confidence:.2f}){base_mark}") - text.append(f" 内容: {definition.get('content', '')}") - text.append(f" 类型: {definition.get('type', '')}") - text.append(f" 来源: {definition.get('source', '')}") - text.append(f" 时间: {definition.get('created_at', '')[:19]}") - if is_base: - text.append(f" 🔐 此定义来自基础知识库,不可更改") - text.append(f" UUID: {definition.get('uuid', '')}") - text.append("") - - # 显示相关信息 - if items['related']: - text.append(f" 相关信息 ({len(items['related'])}条):") - for i, info in enumerate(items['related'], 1): - confidence = info.get('confidence', 0.8) - confidence_icon = "•" if confidence >= 0.7 else "◦" - - # 显示状态和提及次数 - status = info.get('status', DatabaseManager.STATUS_SUSPECTED) - mention_count = info.get('mention_count', 1) - status_icon = "✓" if status == DatabaseManager.STATUS_CONFIRMED else "?" - status_text = f"{status_icon} {status}" - if status == DatabaseManager.STATUS_SUSPECTED: - status_text += f" (提及×{mention_count})" - - text.append(f" {confidence_icon} [{info.get('type', '其他')}] {status_text} (置信度: {confidence:.2f})") - text.append(f" {info.get('content', '')}") - text.append(f" 时间: {info.get('created_at', '')[:19]} | UUID: {info.get('uuid', '')}") - if i < len(items['related']): - text.append("") - - text.append("-" * 60) - text.append("") - - self.update_text_widget(self.knowledge_display, "\n".join(text)) - - def search_knowledge(self): - """ - 搜索知识库(支持主体名称搜索) - """ - if not self.agent: - return - - keyword = self.kb_search_var.get().strip() - if not keyword: - self.update_knowledge_display() - return - - results = self.agent.search_knowledge(keyword=keyword) - - if not results: - self.update_text_widget(self.knowledge_display, f"未找到包含 '{keyword}' 的知识") - return - - text = [] - text.append("=" * 50) - text.append(f"搜索结果: '{keyword}' (共 {len(results)} 条)") - text.append("=" * 50) - text.append("") - - # 按主体分组显示搜索结果 - results_by_entity = {} - for k in results: - entity_name = k.get('entity_name', '未知主体') - if entity_name not in results_by_entity: - results_by_entity[entity_name] = [] - results_by_entity[entity_name].append(k) - - for entity_name, items in results_by_entity.items(): - text.append(f"【主体: {entity_name}】") - for item in items: - confidence = item.get('confidence', 0.8) - is_def = item.get('is_definition', False) - type_label = "定义" if is_def else item.get('type', '其他') - confidence_icon = "⭐" if confidence >= 0.9 else "✓" if confidence >= 0.7 else "◦" - - text.append(f" {confidence_icon} [{type_label}] (置信度: {confidence:.2f})") - - # 显示状态信息(如果不是定义) - if not is_def: - status = item.get('status', DatabaseManager.STATUS_SUSPECTED) - mention_count = item.get('mention_count', 1) - status_icon = "✓" if status == DatabaseManager.STATUS_CONFIRMED else "?" - status_text = f"{status_icon} {status}" - if status == DatabaseManager.STATUS_SUSPECTED: - status_text += f" (提及×{mention_count})" - text.append(f" 状态: {status_text}") - - text.append(f" 内容: {item.get('content', '')}") - text.append(f" 来源: {item.get('source', '')}") - text.append(f" 时间: {item.get('created_at', '')[:19]}") - text.append(f" UUID: {item.get('uuid', '')}") - text.append("") - text.append("-" * 50) - - self.update_text_widget(self.knowledge_display, "\n".join(text)) - - def filter_knowledge_by_type(self): - """ - 按类型筛选知识(支持新的主体结构) - """ - if not self.agent: - return - - selected_type = self.kb_type_var.get() - - if selected_type == "全部": - self.update_knowledge_display() - return - - results = self.agent.search_knowledge(knowledge_type=selected_type) - - if not results: - self.update_text_widget(self.knowledge_display, f"暂无 '{selected_type}' 类型的知识") - return - - text = [] - text.append("=" * 50) - text.append(f"类型筛选: {selected_type} (共 {len(results)} 条)") - text.append("=" * 50) - text.append("") - - # 按主体分组显示 - results_by_entity = {} - for k in results: - entity_name = k.get('entity_name', '未知主体') - if entity_name not in results_by_entity: - results_by_entity[entity_name] = [] - results_by_entity[entity_name].append(k) - - for entity_name, items in results_by_entity.items(): - text.append(f"【主体: {entity_name}】") - for item in items: - confidence = item.get('confidence', 0.8) - is_def = item.get('is_definition', False) - confidence_icon = "⭐" if confidence >= 0.9 else "✓" if confidence >= 0.7 else "◦" - - text.append(f" {confidence_icon} {'定义' if is_def else '相关信息'} (置信度: {confidence:.2f})") - - # 显示状态信息(如果不是定义) - if not is_def: - status = item.get('status', DatabaseManager.STATUS_SUSPECTED) - mention_count = item.get('mention_count', 1) - status_icon = "✓" if status == DatabaseManager.STATUS_CONFIRMED else "?" - status_text = f"{status_icon} {status}" - if status == DatabaseManager.STATUS_SUSPECTED: - status_text += f" (提及×{mention_count})" - text.append(f" 状态: {status_text}") - - text.append(f" 内容: {item.get('content', '')}") - text.append(f" 来源: {item.get('source', '')}") - text.append(f" 时间: {item.get('created_at', '')[:19]}") - text.append(f" UUID: {item.get('uuid', '')}") - text.append("") - text.append("-" * 50) - - self.update_text_widget(self.knowledge_display, "\n".join(text)) - - def show_base_knowledge(self): - """ - 显示基础知识库详情 - """ - if not self.agent: - return - - base_kb = self.agent.memory_manager.knowledge_base.base_knowledge - base_facts = base_kb.get_all_base_facts() - - if not base_facts: - messagebox.showinfo("基础知识库", "基础知识库为空") - return - - # 创建新窗口显示基础知识 - base_window = tk.Toplevel(self.root) - base_window.title("基础知识库 - 最高优先级") - base_window.geometry("700x500") - - # 标题 - title_frame = ttk.Frame(base_window, padding=10) - title_frame.pack(fill=tk.X) - - ttk.Label( - title_frame, - text="🔒 基础知识库(优先级: 100% | 不可更改)", - font=("微软雅黑", 12, "bold"), - foreground="#d35400" - ).pack() - - # 统计信息 - stats = base_kb.get_statistics() - ttk.Label( - title_frame, - text=f"总计: {stats['total_facts']} 条基础事实", - font=("微软雅黑", 9) - ).pack() - - # 显示区域 - text_widget = scrolledtext.ScrolledText( - base_window, - wrap=tk.WORD, - font=("微软雅黑", 10), - bg="#fff9e6" - ) - text_widget.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - # 生成显示内容 - text = [] - text.append(base_kb.generate_base_knowledge_prompt()) - text.append("\n") - text.append("=" * 60) - text.append("详细信息") - text.append("=" * 60) - text.append("") - - for i, fact in enumerate(base_facts, 1): - text.append(f"{i}. 【{fact['entity_name']}】") - text.append(f" 内容: {fact['content']}") - text.append(f" 分类: {fact['category']}") - text.append(f" 优先级: {fact['priority']} | 置信度: {fact['confidence']*100:.0f}%") - if fact.get('description'): - text.append(f" 说明: {fact['description']}") - text.append(f" 创建时间: {fact['created_at'][:19]}") - text.append(f" 不可变: {'是' if fact.get('immutable', True) else '否'}") - text.append("") - - text_widget.insert(tk.END, "\n".join(text)) - text_widget.config(state=tk.DISABLED) - - # 关闭按钮 - ttk.Button( - base_window, - text="关闭", - command=base_window.destroy - ).pack(pady=10) - - def add_base_knowledge(self): - """ - 添加基础知识对话框 - """ - if not self.agent: - return - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("添加基础知识") - dialog.geometry("600x450") - dialog.transient(self.root) - dialog.grab_set() - - # 标题 - ttk.Label( - dialog, - text="添加核心基础知识", - font=("微软雅黑", 12, "bold") - ).pack(pady=10) - - ttk.Label( - dialog, - text="基础知识具有最高优先级(100%),不可被覆盖或更改", - font=("微软雅黑", 9), - foreground="#d35400" - ).pack() - - # 输入框架 - input_frame = ttk.Frame(dialog, padding=15) - input_frame.pack(fill=tk.BOTH, expand=True) - - # 实体名称 - ttk.Label(input_frame, text="实体名称:", font=("微软雅黑", 10)).grid(row=0, column=0, sticky=tk.W, pady=5) - entity_entry = ttk.Entry(input_frame, width=40, font=("微软雅黑", 10)) - entity_entry.grid(row=0, column=1, pady=5, padx=10) - - # 事实内容 - ttk.Label(input_frame, text="事实内容:", font=("微软雅黑", 10)).grid(row=1, column=0, sticky=tk.NW, pady=5) - content_text = tk.Text(input_frame, width=40, height=4, font=("微软雅黑", 10)) - content_text.grid(row=1, column=1, pady=5, padx=10) - - # 分类 - ttk.Label(input_frame, text="分类:", font=("微软雅黑", 10)).grid(row=2, column=0, sticky=tk.W, pady=5) - category_var = tk.StringVar(value="通用") - category_combo = ttk.Combobox( - input_frame, - textvariable=category_var, - width=38, - font=("微软雅黑", 10) - ) - category_combo['values'] = ['机构类型', '人物定义', '地点定义', '事物定义', '关系定义', '通用'] - category_combo.grid(row=2, column=1, pady=5, padx=10) - - # 说明 - ttk.Label(input_frame, text="说明:", font=("微软雅黑", 10)).grid(row=3, column=0, sticky=tk.NW, pady=5) - desc_text = tk.Text(input_frame, width=40, height=3, font=("微软雅黑", 10)) - desc_text.grid(row=3, column=1, pady=5, padx=10) - - # 提示信息 - tip_frame = ttk.Frame(dialog) - tip_frame.pack(fill=tk.X, padx=15, pady=5) - ttk.Label( - tip_frame, - text="⚠️ 注意:基础知识一旦添加,将优先于所有其他信息,即使与现实相悖也会被遵循", - font=("微软雅黑", 8), - foreground="red", - wraplength=550 - ).pack() - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=15) - - def save_base_knowledge(): - entity_name = entity_entry.get().strip() - content = content_text.get("1.0", tk.END).strip() - category = category_var.get().strip() - description = desc_text.get("1.0", tk.END).strip() - - if not entity_name or not content: - messagebox.showwarning("输入错误", "实体名称和事实内容不能为空") - return - - # 添加基础知识 - base_kb = self.agent.memory_manager.knowledge_base.base_knowledge - success = base_kb.add_base_fact( - entity_name=entity_name, - fact_content=content, - category=category, - description=description, - immutable=True - ) - - if success: - messagebox.showinfo("成功", f"已添加基础知识:{entity_name}") - self.update_knowledge_display() - dialog.destroy() - else: - messagebox.showerror("失败", "添加基础知识失败(可能已存在同名实体)") - - ttk.Button( - button_frame, - text="保存", - command=save_base_knowledge, - width=15 - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - button_frame, - text="取消", - command=dialog.destroy, - width=15 - ).pack(side=tk.LEFT, padx=5) - - def update_timeline(self): - """ - 更新主题时间线 - """ - if not self.agent: - return - - summaries = self.agent.get_long_term_summaries() - self.timeline_canvas.update_topics(summaries) - - def update_text_widget(self, widget, text): - """ - 更新文本组件内容 - """ - widget.config(state=tk.NORMAL) - widget.delete(1.0, tk.END) - widget.insert(tk.END, text) - widget.config(state=tk.DISABLED) - - def update_status(self, status: str, color: str = "black"): - """ - 更新状态标签 - """ - self.status_label.config(text=f"● {status}", foreground=color) - self.root.update() - - def on_debug_log_added(self, log_entry: Dict[str, Any]): - """ - Debug日志监听器回调,当有新日志时自动更新显示 - - Args: - log_entry: 日志条目 - """ - if not hasattr(self, 'debug_display') or not self.debug_auto_refresh.get(): - return - - # 在主线程中更新UI - self.root.after(0, lambda: self._append_debug_log(log_entry)) - - def _append_debug_log(self, log_entry: Dict[str, Any]): - """ - 添加单条debug日志到显示区域 - - Args: - log_entry: 日志条目 - """ - try: - # 检查是否需要筛选 - selected_type = self.debug_type_var.get() - if selected_type != "全部" and log_entry['type'] != selected_type: - return - - self.debug_display.config(state=tk.NORMAL) - - # 时间戳 - timestamp = log_entry['timestamp'][11:19] - self.debug_display.insert(tk.END, f"[{timestamp}] ", "timestamp") - - # 日志类型 - log_type = log_entry['type'].upper() - self.debug_display.insert(tk.END, f"[{log_type}] ", log_entry['type']) - - # 模块名 - module = log_entry.get('module', 'Unknown') - self.debug_display.insert(tk.END, f"{module} ", "info") - - # 根据类型显示不同内容 - if log_entry['type'] == 'module': - self.debug_display.insert(tk.END, f"| {log_entry.get('action', '')}\n") - if log_entry.get('details'): - self.debug_display.insert(tk.END, f" 详情: {log_entry['details']}\n", "info") - - elif log_entry['type'] == 'prompt': - prompt_type = log_entry.get('prompt_type', '') - content = log_entry.get('content', '') - display_content = content[:150] + "..." if len(content) > 150 else content - self.debug_display.insert(tk.END, f"| {prompt_type}\n") - self.debug_display.insert(tk.END, f" {display_content}\n", "prompt") - - elif log_entry['type'] == 'request': - api_url = log_entry.get('api_url', '') - self.debug_display.insert(tk.END, f"| {api_url}\n") - - elif log_entry['type'] == 'response': - status = log_entry.get('status_code', 0) - elapsed = log_entry.get('elapsed_time', 0) - self.debug_display.insert(tk.END, f"| 状态:{status} 耗时:{elapsed:.2f}s\n") - - elif log_entry['type'] == 'error': - message = log_entry.get('message', '') - self.debug_display.insert(tk.END, f"| {message}\n", "error") - - elif log_entry['type'] == 'info': - message = log_entry.get('message', '') - self.debug_display.insert(tk.END, f"| {message}\n") - - self.debug_display.insert(tk.END, "\n") - self.debug_display.see(tk.END) - self.debug_display.config(state=tk.DISABLED) - - # 更新统计 - if hasattr(self, 'debug_logger'): - stats = self.debug_logger.get_statistics() - self.debug_stats_label.config(text=f"日志: {stats['total_logs']} 条") - - except Exception as e: - print(f"✗ 更新debug日志显示失败: {e}") - - def update_debug_display(self): - """ - 更新Debug日志显示 - """ - if not hasattr(self, 'debug_display') or not hasattr(self, 'debug_logger'): - return - - try: - # 获取筛选类型 - selected_type = self.debug_type_var.get() - log_type = None if selected_type == "全部" else selected_type - - # 获取日志 - logs = self.debug_logger.get_logs(log_type=log_type, limit=500) - - # 清空并重新显示 - self.debug_display.config(state=tk.NORMAL) - self.debug_display.delete(1.0, tk.END) - - if not logs: - self.debug_display.insert(tk.END, "暂无日志\n", "info") - else: - for log_entry in logs: - self._append_debug_log(log_entry) - - self.debug_display.config(state=tk.DISABLED) - - # 更新统计 - stats = self.debug_logger.get_statistics() - self.debug_stats_label.config( - text=f"日志: {stats['total_logs']} 条 | " - f"模块:{stats['by_type']['module']} " - f"提示词:{stats['by_type']['prompt']} " - f"请求:{stats['by_type']['request']} " - f"响应:{stats['by_type']['response']} " - f"错误:{stats['by_type']['error']}" - ) - - except Exception as e: - print(f"✗ 更新debug显示失败: {e}") - - def clear_debug_logs(self): - """ - 清空Debug日志 - """ - if not hasattr(self, 'debug_logger'): - return - - if messagebox.askyesno("确认", "确定要清空所有Debug日志吗?"): - self.debug_logger.clear_logs() - if hasattr(self, 'debug_display'): - self.debug_display.config(state=tk.NORMAL) - self.debug_display.delete(1.0, tk.END) - self.debug_display.insert(tk.END, "日志已清空\n", "info") - self.debug_display.config(state=tk.DISABLED) - - self.debug_stats_label.config(text="日志: 0 条") - messagebox.showinfo("成功", "Debug日志已清空") - - def add_message_to_display(self, role: str, content: str): - """ - 在聊天显示区添加消息 - """ - self.chat_display.config(state=tk.NORMAL) - - timestamp = datetime.now().strftime("%H:%M:%S") - self.chat_display.insert(tk.END, f"[{timestamp}] ", "timestamp") - - if role == "user": - self.chat_display.insert(tk.END, "你: ", "user") - elif role == "assistant": - name = self.agent.character.name if self.agent else "助手" - self.chat_display.insert(tk.END, f"{name}: ", "assistant") - - self.chat_display.insert(tk.END, f"{content}\n\n") - self.chat_display.see(tk.END) - self.chat_display.config(state=tk.DISABLED) - - def add_system_message(self, message: str): - """ - 添加系统消息 - """ - self.chat_display.config(state=tk.NORMAL) - timestamp = datetime.now().strftime("%H:%M:%S") - self.chat_display.insert(tk.END, f"[{timestamp}] ", "timestamp") - self.chat_display.insert(tk.END, f"[系统] {message}\n\n", "system") - self.chat_display.see(tk.END) - self.chat_display.config(state=tk.DISABLED) - - def add_archive_message(self, rounds: int, summary: str): - """ - 添加归档消息 - """ - self.chat_display.config(state=tk.NORMAL) - timestamp = datetime.now().strftime("%H:%M:%S") - self.chat_display.insert(tk.END, f"[{timestamp}] ", "timestamp") - self.chat_display.insert(tk.END, f"[记忆归档] 已将前{rounds}轮对话归档\n", "archive") - self.chat_display.insert(tk.END, f"主题概括: {summary}\n\n", "archive") - self.chat_display.see(tk.END) - self.chat_display.config(state=tk.DISABLED) - - def add_knowledge_extraction_message(self, knowledge_count: int): - """ - 添加知识提取消息 - """ - self.chat_display.config(state=tk.NORMAL) - timestamp = datetime.now().strftime("%H:%M:%S") - self.chat_display.insert(tk.END, f"[{timestamp}] ", "timestamp") - self.chat_display.insert(tk.END, f"[知识提取] 已从最近5轮对话中提取 {knowledge_count} 条知识\n\n", "archive") - self.chat_display.see(tk.END) - self.chat_display.config(state=tk.DISABLED) - - def send_message(self): - """ - 发送消息 - """ - if self.is_processing: - messagebox.showwarning("请稍候", "正在处理上一条消息,请稍候...") - return - - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - user_input = self.input_text.get(1.0, tk.END).strip() - - if not user_input: - messagebox.showwarning("提示", "请输入消息内容") - return - - self.add_message_to_display("user", user_input) - self.input_text.delete(1.0, tk.END) - - self.is_processing = True - self.update_status("思考中...", "orange") - self.send_button.config(state=tk.DISABLED) - - # 记录当前长期记忆数量 - old_summary_count = len(self.agent.get_long_term_summaries()) - - def process_chat(): - try: - response = self.agent.chat(user_input) - self.root.after(0, lambda: self.handle_response(response, old_summary_count)) - except Exception as e: - error_msg = DebugLogger.format_exception_with_location(e, include_traceback=True) - self.root.after(0, lambda: self.handle_error(f"处理消息时出错:\n\n{error_msg}")) - - thread = threading.Thread(target=process_chat, daemon=True) - thread.start() - - def handle_response(self, response: str, old_summary_count: int): - """ - 处理代理回复 - """ - self.add_message_to_display("assistant", response) - - # 更新理解阶段显示 - understanding_result = self.agent.get_last_understanding() - if understanding_result: - self.update_understanding_display(understanding_result) - - # 检查是否生成了新的概括 - new_summaries = self.agent.get_long_term_summaries() - if len(new_summaries) > old_summary_count: - # 有新的概括生成 - latest_summary = new_summaries[-1] - self.add_archive_message(latest_summary.get('rounds', 20), latest_summary.get('summary', '')) - self.update_timeline() - - # 检查是否提取了新知识(通过对话轮数判断) - stats = self.agent.get_memory_stats() - current_rounds = stats['total_conversations'] - if current_rounds > 0 and current_rounds % 5 == 0: - # 刚好是5的倍数,可能提取了知识 - # 通过比较知识数量来确认 - old_kb_count = getattr(self, '_last_kb_count', 0) - new_kb_count = stats['knowledge_base']['total_knowledge'] - if new_kb_count > old_kb_count: - extracted_count = new_kb_count - old_kb_count - self.add_knowledge_extraction_message(extracted_count) - self._last_kb_count = new_kb_count - - # 检查是否进行了自动情感分析(每10轮) - short_term_rounds = stats['short_term']['rounds'] - if short_term_rounds > 0 and short_term_rounds % 10 == 0: - # 可能刚进行了情感分析,检查是否有新的情感数据 - old_emotion_count = getattr(self, '_last_emotion_count', 0) - emotion_history = self.agent.get_emotion_history() - new_emotion_count = len(emotion_history) - - if new_emotion_count > old_emotion_count: - # 有新的情感分析结果,自动刷新显示 - latest_emotion = self.agent.get_latest_emotion() - if latest_emotion: - debug_logger = get_debug_logger() - debug_logger.log_info('GUI', '检测到新的情感分析结果,自动刷新显示', { - 'emotion_count': new_emotion_count, - 'relationship_type': latest_emotion.get('relationship_type', '未知'), - 'overall_score': latest_emotion.get('overall_score', 0) - }) - - # 刷新情感显示 - self.update_emotion_display(latest_emotion) - - # 在聊天窗口显示提示 - self.add_system_message( - f"💖 情感分析已更新 | 关系:{latest_emotion.get('relationship_type', '未知')} | " - f"评分:{latest_emotion.get('overall_score', 0)}/100 | " - f"基调:{latest_emotion.get('emotional_tone', '未知')}" - ) - - self._last_emotion_count = new_emotion_count - - # 更新显示 - self.refresh_all() - - self.is_processing = False - self.update_status("就绪", "green") - self.send_button.config(state=tk.NORMAL) - self.input_text.focus() - - def handle_error(self, error_msg: str): - """ - 处理错误 - """ - self.add_system_message(f"错误: {error_msg}") - self.show_error_dialog("错误", error_msg) - - self.is_processing = False - self.update_status("出错", "red") - self.send_button.config(state=tk.NORMAL) - - def clear_input(self): - """ - 清空输入框 - """ - self.input_text.delete(1.0, tk.END) - - def clear_chat_display(self): - """ - 清空聊天显示区 - """ - result = messagebox.askyesno("确认", "确定要清空聊天显示区吗?\n(不会删除历史记忆)") - if result: - self.chat_display.config(state=tk.NORMAL) - self.chat_display.delete(1.0, tk.END) - self.chat_display.config(state=tk.DISABLED) - self.add_system_message("聊天显示区已清空") - - def clear_all_memory(self): - """ - 清空所有记忆 - """ - result = messagebox.askyesno( - "警告", - "确定要清空所有记忆吗?\n包括短期和长期记忆!\n此操作不可恢复!", - icon='warning' - ) - - if result: - if self.agent: - self.agent.clear_memory() - self.chat_display.config(state=tk.NORMAL) - self.chat_display.delete(1.0, tk.END) - self.chat_display.config(state=tk.DISABLED) - self.add_system_message("所有记忆已清空") - self.refresh_all() - - def reload_agent(self): - """ - 重新加载代理 - """ - result = messagebox.askyesno("确认", "确定要重新加载代理吗?\n将重新读取配置文件") - if result: - self.initialize_agent() - messagebox.showinfo("成功", "代理已重新加载") - - # ==================== 环境管理相关方法 ==================== - - def refresh_environment_display(self): - """ - 刷新环境显示 - """ - try: - # 获取所有环境和域 - environments = self.agent.db.get_all_environments() - domains = self.agent.db.get_all_domains() - active_env = self.agent.db.get_active_environment() - - display_text = "【智能体视觉环境配置】\n\n" - - # 显示域信息 - if domains: - display_text += f"📊 域(Domain)总览: {len(domains)} 个\n" - display_text += "=" * 60 + "\n\n" - - for domain in domains: - domain_envs = self.agent.db.get_domain_environments(domain['uuid']) - default_env = None - if domain.get('default_environment_uuid'): - default_env = self.agent.db.get_environment(domain['default_environment_uuid']) - - display_text += f"🏘️ 【域: {domain['name']}】\n" - display_text += f"描述: {domain.get('description', '无描述')}\n" - display_text += f"包含环境: {len(domain_envs)} 个\n" - if default_env: - display_text += f"默认环境: {default_env['name']}\n" - - if domain_envs: - display_text += "环境列表: " - display_text += ", ".join([e['name'] for e in domain_envs]) - display_text += "\n" - - display_text += "\n" - - display_text += "=" * 60 + "\n\n" - - # 显示环境信息 - if not environments: - display_text += "暂无环境配置。\n\n" - display_text += "💡 提示:\n" - display_text += "- 点击「创建默认环境」快速创建一个示例环境\n" - display_text += "- 点击「新建环境」手动创建自定义环境\n" - display_text += "- 环境配置后,当用户询问周围环境时,智能体会自动使用视觉工具\n" - else: - display_text += f"📍 环境总览: {len(environments)} 个环境\n" - if active_env: - display_text += f"当前激活: {active_env['name']}\n" - - # 显示当前环境所属的域 - current_domains = self.agent.db.get_environment_domains(active_env['uuid']) - if current_domains: - display_text += f"所属域: {', '.join([d['name'] for d in current_domains])}\n" - - display_text += "=" * 60 + "\n\n" - - for env in environments: - is_active = env['uuid'] == active_env['uuid'] if active_env else False - status_icon = "🟢" if is_active else "⚪" - - display_text += f"{status_icon} 【环境: {env['name']}】\n" - display_text += f"UUID: {env['uuid'][:8]}...\n" - display_text += f"整体描述: {env['overall_description']}\n" - - # 显示环境所属的域 - env_domains = self.agent.db.get_environment_domains(env['uuid']) - if env_domains: - display_text += f"所属域: {', '.join([d['name'] for d in env_domains])}\n" - - if env.get('atmosphere'): - display_text += f"氛围: {env['atmosphere']}\n" - if env.get('lighting'): - display_text += f"光照: {env['lighting']}\n" - if env.get('sounds'): - display_text += f"声音: {env['sounds']}\n" - if env.get('smells'): - display_text += f"气味: {env['smells']}\n" - - display_text += f"创建时间: {env['created_at']}\n" - - # 获取环境连接信息 - connections = self.agent.db.get_environment_connections(env['uuid']) - if connections: - display_text += f"\n连接关系: {len(connections)}个\n" - for conn in connections[:3]: # 只显示前3个连接 - if conn['from_environment_uuid'] == env['uuid']: - other_env = self.agent.db.get_environment(conn['to_environment_uuid']) - direction_symbol = "→" if conn['direction'] == 'one_way' else "⟷" - display_text += f" {direction_symbol} {other_env['name'] if other_env else '未知'} ({conn['connection_type']})\n" - elif conn['to_environment_uuid'] == env['uuid'] and conn['direction'] == 'bidirectional': - other_env = self.agent.db.get_environment(conn['from_environment_uuid']) - display_text += f" ⟷ {other_env['name'] if other_env else '未知'} ({conn['connection_type']})\n" - if len(connections) > 3: - display_text += f" ... 还有 {len(connections) - 3} 个连接\n" - else: - display_text += "\n连接关系: 无(孤立环境)\n" - - # 获取环境中的物体 - objects = self.agent.db.get_environment_objects(env['uuid']) - display_text += f"\n物体数量: {len(objects)}\n" - - if objects: - display_text += "物体列表:\n" - for obj in objects: - visibility = "👁️" if obj['is_visible'] else "👁️‍🗨️" - display_text += f" {visibility} {obj['name']} (优先级: {obj['priority']})\n" - display_text += f" {obj['description']}\n" - if obj.get('position'): - display_text += f" 位置: {obj['position']}\n" - - display_text += "\n" + "=" * 60 + "\n\n" - - self.update_text_widget(self.environment_display, display_text) - except Exception as e: - self.update_text_widget(self.environment_display, f"刷新环境显示时出错: {e}") - - def create_new_environment(self): - """ - 创建新环境 - """ - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("创建新环境") - dialog.geometry("600x500") - dialog.transient(self.root) - dialog.grab_set() - - # 环境名称 - ttk.Label(dialog, text="环境名称:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - name_entry = ttk.Entry(dialog, width=70) - name_entry.pack(pady=5, padx=10, fill=tk.X) - - # 整体描述 - ttk.Label(dialog, text="整体描述:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - desc_text = scrolledtext.ScrolledText(dialog, height=6, width=70, wrap=tk.WORD) - desc_text.pack(pady=5, padx=10, fill=tk.BOTH, expand=True) - - # 氛围 - ttk.Label(dialog, text="氛围:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - atmosphere_entry = ttk.Entry(dialog, width=70) - atmosphere_entry.pack(pady=5, padx=10, fill=tk.X) - - # 光照 - ttk.Label(dialog, text="光照:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - lighting_entry = ttk.Entry(dialog, width=70) - lighting_entry.pack(pady=5, padx=10, fill=tk.X) - - # 声音 - ttk.Label(dialog, text="声音:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - sounds_entry = ttk.Entry(dialog, width=70) - sounds_entry.pack(pady=5, padx=10, fill=tk.X) - - # 气味 - ttk.Label(dialog, text="气味:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - smells_entry = ttk.Entry(dialog, width=70) - smells_entry.pack(pady=5, padx=10, fill=tk.X) - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=20) - - def save_environment(): - name = name_entry.get().strip() - desc = desc_text.get("1.0", tk.END).strip() - - if not name or not desc: - messagebox.showerror("错误", "环境名称和整体描述不能为空!") - return - - try: - env_uuid = self.agent.db.create_environment( - name=name, - overall_description=desc, - atmosphere=atmosphere_entry.get().strip(), - lighting=lighting_entry.get().strip(), - sounds=sounds_entry.get().strip(), - smells=smells_entry.get().strip() - ) - - # 如果是第一个环境,自动设为激活 - all_envs = self.agent.db.get_all_environments() - if len(all_envs) == 1: - self.agent.db.set_active_environment(env_uuid) - - messagebox.showinfo("成功", f"环境创建成功!\nUUID: {env_uuid[:8]}...") - dialog.destroy() - self.refresh_environment_display() - except Exception as e: - messagebox.showerror("错误", f"创建环境失败: {e}") - - ttk.Button(button_frame, text="保存", command=save_environment, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def add_new_object(self): - """ - 添加新物体到当前激活的环境 - """ - # 检查是否有激活的环境 - active_env = self.agent.db.get_active_environment() - if not active_env: - messagebox.showerror("错误", "请先创建并激活一个环境!") - return - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title(f"添加物体到环境: {active_env['name']}") - dialog.geometry("600x500") - dialog.transient(self.root) - dialog.grab_set() - - # 物体名称 - ttk.Label(dialog, text="物体名称:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - name_entry = ttk.Entry(dialog, width=70) - name_entry.pack(pady=5, padx=10, fill=tk.X) - - # 物体描述 - ttk.Label(dialog, text="物体描述:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - desc_text = scrolledtext.ScrolledText(dialog, height=6, width=70, wrap=tk.WORD) - desc_text.pack(pady=5, padx=10, fill=tk.BOTH, expand=True) - - # 位置 - ttk.Label(dialog, text="位置:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - position_entry = ttk.Entry(dialog, width=70) - position_entry.pack(pady=5, padx=10, fill=tk.X) - - # 属性 - ttk.Label(dialog, text="属性:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - properties_entry = ttk.Entry(dialog, width=70) - properties_entry.pack(pady=5, padx=10, fill=tk.X) - - # 交互提示 - ttk.Label(dialog, text="交互提示:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - hints_entry = ttk.Entry(dialog, width=70) - hints_entry.pack(pady=5, padx=10, fill=tk.X) - - # 优先级 - priority_frame = ttk.Frame(dialog) - priority_frame.pack(pady=10, padx=10, fill=tk.X) - ttk.Label(priority_frame, text="优先级 (0-100):", font=("微软雅黑", 10)).pack(side=tk.LEFT) - priority_var = tk.IntVar(value=50) - priority_spinbox = ttk.Spinbox(priority_frame, from_=0, to=100, textvariable=priority_var, width=10) - priority_spinbox.pack(side=tk.LEFT, padx=10) - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=20) - - def save_object(): - name = name_entry.get().strip() - desc = desc_text.get("1.0", tk.END).strip() - - if not name or not desc: - messagebox.showerror("错误", "物体名称和描述不能为空!") - return - - try: - obj_uuid = self.agent.db.add_environment_object( - environment_uuid=active_env['uuid'], - name=name, - description=desc, - position=position_entry.get().strip(), - properties=properties_entry.get().strip(), - interaction_hints=hints_entry.get().strip(), - priority=priority_var.get() - ) - - messagebox.showinfo("成功", f"物体添加成功!\nUUID: {obj_uuid[:8]}...") - dialog.destroy() - self.refresh_environment_display() - except Exception as e: - messagebox.showerror("错误", f"添加物体失败: {e}") - - ttk.Button(button_frame, text="保存", command=save_object, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def show_vision_logs(self): - """ - 显示视觉工具使用记录 - """ - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("视觉工具使用记录") - dialog.geometry("800x600") - dialog.transient(self.root) - - # 工具栏 - toolbar = ttk.Frame(dialog) - toolbar.pack(fill=tk.X, padx=10, pady=10) - - ttk.Label(toolbar, text="最近50条记录", font=("微软雅黑", 10, "bold")).pack(side=tk.LEFT) - - # 日志显示 - log_text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=("微软雅黑", 9)) - log_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - try: - logs = self.agent.db.get_vision_tool_logs(limit=50) - - if not logs: - log_text.insert(tk.END, "暂无视觉工具使用记录\n\n") - log_text.insert(tk.END, "💡 提示: 当用户询问周围环境时,视觉工具会自动触发并记录") - else: - log_text.insert(tk.END, f"共有 {len(logs)} 条记录\n") - log_text.insert(tk.END, "=" * 80 + "\n\n") - - for i, log in enumerate(logs, 1): - log_text.insert(tk.END, f"【记录 {i}】\n") - log_text.insert(tk.END, f"时间: {log['created_at']}\n") - log_text.insert(tk.END, f"触发方式: {log['triggered_by']}\n") - log_text.insert(tk.END, f"用户查询: {log['query']}\n") - - if log.get('environment_uuid'): - env = self.agent.db.get_environment(log['environment_uuid']) - env_name = env['name'] if env else "已删除的环境" - log_text.insert(tk.END, f"环境: {env_name}\n") - - if log.get('objects_viewed'): - log_text.insert(tk.END, f"查看的物体: {log['objects_viewed']}\n") - - if log.get('context_provided'): - preview = log['context_provided'][:100] - log_text.insert(tk.END, f"上下文预览: {preview}...\n") - - log_text.insert(tk.END, "\n" + "-" * 80 + "\n\n") - except Exception as e: - log_text.insert(tk.END, f"加载日志时出错: {e}") - - log_text.config(state=tk.DISABLED) - - # 关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy, width=15).pack(pady=10) - - def create_default_environment(self): - """ - 创建默认环境(小可的房间) - """ - result = messagebox.askyesno( - "确认", - "将创建默认示例环境「小可的房间」\n包含7个预设物体\n\n确定要创建吗?" - ) - - if result: - try: - env_uuid = self.agent.vision_tool.create_default_environment() - messagebox.showinfo( - "成功", - f"默认环境创建成功!\n\n环境: 小可的房间\nUUID: {env_uuid[:8]}...\n物体数量: 7个\n\n该环境已自动设为激活状态。" - ) - self.refresh_environment_display() - except Exception as e: - messagebox.showerror("错误", f"创建默认环境失败: {e}") - - def manage_environment_connections(self): - """ - 管理环境连接关系 - """ - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("环境连接管理") - dialog.geometry("900x600") - dialog.transient(self.root) - - # 标题 - title_label = ttk.Label( - dialog, - text="🔗 环境连接管理", - font=("微软雅黑", 12, "bold") - ) - title_label.pack(pady=10) - - # 工具栏 - toolbar = ttk.Frame(dialog) - toolbar.pack(fill=tk.X, padx=10, pady=5) - - ttk.Button( - toolbar, - text="➕ 新建连接", - command=self.create_environment_connection_dialog, - width=15 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - toolbar, - text="🔄 刷新", - command=lambda: self.refresh_connections_display(connections_text), - width=10 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - toolbar, - text="🗺️ 查看关系图", - command=self.show_environment_relationship_map, - width=15 - ).pack(side=tk.LEFT, padx=2) - - # 连接列表显示区域 - connections_text = scrolledtext.ScrolledText( - dialog, - wrap=tk.WORD, - font=("微软雅黑", 9), - bg="#f9f9f9" - ) - connections_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - # 初始加载 - self.refresh_connections_display(connections_text) - - # 关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy, width=15).pack(pady=10) - - def refresh_connections_display(self, text_widget): - """ - 刷新连接显示 - """ - try: - text_widget.config(state=tk.NORMAL) - text_widget.delete(1.0, tk.END) - - connections = self.agent.db.get_all_environment_connections() - - if not connections: - text_widget.insert(tk.END, "暂无环境连接。\n\n") - text_widget.insert(tk.END, "💡 提示: 点击「新建连接」创建环境之间的连接关系。") - else: - text_widget.insert(tk.END, f"共有 {len(connections)} 个环境连接\n") - text_widget.insert(tk.END, "=" * 80 + "\n\n") - - for i, conn in enumerate(connections, 1): - from_env = self.agent.db.get_environment(conn['from_environment_uuid']) - to_env = self.agent.db.get_environment(conn['to_environment_uuid']) - - text_widget.insert(tk.END, f"【连接 {i}】\n") - text_widget.insert(tk.END, f"UUID: {conn['uuid'][:8]}...\n") - text_widget.insert(tk.END, f"起点: {from_env['name'] if from_env else '未知'}\n") - text_widget.insert(tk.END, f"终点: {to_env['name'] if to_env else '未知'}\n") - - # 方向图示 - if conn['direction'] == 'bidirectional': - direction_str = f"{from_env['name'] if from_env else '?'} ⟷ {to_env['name'] if to_env else '?'}" - else: - direction_str = f"{from_env['name'] if from_env else '?'} → {to_env['name'] if to_env else '?'}" - text_widget.insert(tk.END, f"方向: {direction_str}\n") - - text_widget.insert(tk.END, f"类型: {conn['connection_type']}\n") - if conn.get('description'): - text_widget.insert(tk.END, f"描述: {conn['description']}\n") - text_widget.insert(tk.END, f"创建时间: {conn['created_at']}\n") - text_widget.insert(tk.END, "\n" + "-" * 80 + "\n\n") - - text_widget.config(state=tk.DISABLED) - except Exception as e: - text_widget.config(state=tk.NORMAL) - text_widget.insert(tk.END, f"刷新连接显示时出错: {e}") - text_widget.config(state=tk.DISABLED) - - def create_environment_connection_dialog(self): - """ - 创建环境连接对话框 - """ - # 获取所有环境 - environments = self.agent.db.get_all_environments() - if len(environments) < 2: - messagebox.showerror("错误", "至少需要2个环境才能创建连接!\n请先创建更多环境。") - return - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("创建环境连接") - dialog.geometry("500x450") - dialog.transient(self.root) - dialog.grab_set() - - # 起始环境 - ttk.Label(dialog, text="起始环境:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - from_env_var = tk.StringVar() - from_env_combo = ttk.Combobox(dialog, textvariable=from_env_var, width=50, state="readonly") - from_env_combo['values'] = [f"{env['name']} ({env['uuid'][:8]}...)" for env in environments] - from_env_combo.pack(pady=5, padx=10, fill=tk.X) - - # 目标环境 - ttk.Label(dialog, text="目标环境:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - to_env_var = tk.StringVar() - to_env_combo = ttk.Combobox(dialog, textvariable=to_env_var, width=50, state="readonly") - to_env_combo['values'] = [f"{env['name']} ({env['uuid'][:8]}...)" for env in environments] - to_env_combo.pack(pady=5, padx=10, fill=tk.X) - - # 连接类型 - ttk.Label(dialog, text="连接类型:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - conn_type_var = tk.StringVar(value="normal") - conn_type_combo = ttk.Combobox(dialog, textvariable=conn_type_var, width=50, state="readonly") - conn_type_combo['values'] = ['normal', 'door', 'portal', 'stairs', 'corridor', 'window', 'other'] - conn_type_combo.pack(pady=5, padx=10, fill=tk.X) - - # 方向 - ttk.Label(dialog, text="连接方向:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - direction_var = tk.StringVar(value="bidirectional") - direction_frame = ttk.Frame(dialog) - direction_frame.pack(pady=5, padx=10, fill=tk.X) - ttk.Radiobutton(direction_frame, text="双向 (⟷)", variable=direction_var, value="bidirectional").pack(side=tk.LEFT, padx=10) - ttk.Radiobutton(direction_frame, text="单向 (→)", variable=direction_var, value="one_way").pack(side=tk.LEFT, padx=10) - - # 描述 - ttk.Label(dialog, text="连接描述:", font=("微软雅黑", 10)).pack(pady=(10, 0), padx=10, anchor=tk.W) - desc_text = scrolledtext.ScrolledText(dialog, height=4, width=50, wrap=tk.WORD) - desc_text.pack(pady=5, padx=10, fill=tk.BOTH, expand=True) - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=20) - - def save_connection(): - from_idx = from_env_combo.current() - to_idx = to_env_combo.current() - - if from_idx < 0 or to_idx < 0: - messagebox.showerror("错误", "请选择起始环境和目标环境!") - return - - if from_idx == to_idx: - messagebox.showerror("错误", "起始环境和目标环境不能相同!") - return - - from_env = environments[from_idx] - to_env = environments[to_idx] - - try: - conn_uuid = self.agent.db.create_environment_connection( - from_env_uuid=from_env['uuid'], - to_env_uuid=to_env['uuid'], - connection_type=conn_type_var.get(), - direction=direction_var.get(), - description=desc_text.get("1.0", tk.END).strip() - ) - - messagebox.showinfo("成功", f"环境连接创建成功!\n\n{from_env['name']} → {to_env['name']}\nUUID: {conn_uuid[:8]}...") - dialog.destroy() - except ValueError as e: - messagebox.showerror("错误", str(e)) - except Exception as e: - messagebox.showerror("错误", f"创建连接失败: {e}") - - ttk.Button(button_frame, text="保存", command=save_connection, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def show_environment_relationship_map(self): - """ - 显示环境关系图 - """ - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("环境关系图") - dialog.geometry("800x600") - dialog.transient(self.root) - - # 标题 - title_label = ttk.Label( - dialog, - text="🗺️ 环境关系图", - font=("微软雅黑", 12, "bold") - ) - title_label.pack(pady=10) - - # 创建Canvas显示关系图 - canvas_frame = ttk.Frame(dialog) - canvas_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - canvas = Canvas(canvas_frame, bg='#f8f9fa', highlightthickness=0) - canvas.pack(fill=tk.BOTH, expand=True) - - # 绘制关系图 - self.draw_environment_relationship_map(canvas) - - # 关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy, width=15).pack(pady=10) - - def draw_environment_relationship_map(self, canvas): - """ - 在Canvas上绘制环境关系图 - """ - try: - # 获取所有环境和连接 - environments = self.agent.db.get_all_environments() - connections = self.agent.db.get_all_environment_connections() - - if not environments: - canvas.create_text( - 400, 300, - text="暂无环境数据", - font=('微软雅黑', 12), - fill='#999999' - ) - return - - # 计算布局 - width = canvas.winfo_width() if canvas.winfo_width() > 1 else 800 - height = canvas.winfo_height() if canvas.winfo_height() > 1 else 600 - - # 使用简单的圆形布局 - center_x = width // 2 - center_y = height // 2 - radius = min(width, height) // 3 - - # 计算每个环境的位置 - env_positions = {} - angle_step = 2 * 3.14159 / len(environments) - for i, env in enumerate(environments): - angle = i * angle_step - x = center_x + radius * math.cos(angle) - y = center_y + radius * math.sin(angle) - env_positions[env['uuid']] = (x, y) - - # 绘制连接线 - for conn in connections: - from_uuid = conn['from_environment_uuid'] - to_uuid = conn['to_environment_uuid'] - - if from_uuid in env_positions and to_uuid in env_positions: - from_pos = env_positions[from_uuid] - to_pos = env_positions[to_uuid] - - # 绘制线条 - if conn['direction'] == 'bidirectional': - canvas.create_line( - from_pos[0], from_pos[1], - to_pos[0], to_pos[1], - fill='#4ECDC4', width=2, - arrow=tk.BOTH, arrowshape=(10, 12, 5) - ) - else: - canvas.create_line( - from_pos[0], from_pos[1], - to_pos[0], to_pos[1], - fill='#45B7D1', width=2, - arrow=tk.LAST, arrowshape=(10, 12, 5) - ) - - # 获取当前激活的环境 - active_env = self.agent.db.get_active_environment() - active_uuid = active_env['uuid'] if active_env else None - - # 绘制环境节点 - for env in environments: - x, y = env_positions[env['uuid']] - - # 节点颜色 - if env['uuid'] == active_uuid: - color = '#FF6B6B' # 激活的环境用红色 - else: - color = '#98D8C8' - - # 绘制圆形节点 - radius_node = 30 - canvas.create_oval( - x - radius_node, y - radius_node, - x + radius_node, y + radius_node, - fill=color, outline='white', width=3 - ) - - # 绘制环境名称 - canvas.create_text( - x, y + radius_node + 20, - text=env['name'], - font=('微软雅黑', 9, 'bold'), - fill='#333333' - ) - - # 如果是激活的环境,添加标记 - if env['uuid'] == active_uuid: - canvas.create_text( - x, y, - text="✓", - font=('Arial', 16, 'bold'), - fill='white' - ) - - # 添加图例 - legend_x = 20 - legend_y = 20 - canvas.create_text(legend_x, legend_y, text="图例:", font=('微软雅黑', 9, 'bold'), anchor=tk.W) - canvas.create_oval(legend_x, legend_y + 20, legend_x + 20, legend_y + 40, fill='#FF6B6B', outline='white', width=2) - canvas.create_text(legend_x + 30, legend_y + 30, text="当前环境", font=('微软雅黑', 8), anchor=tk.W) - canvas.create_oval(legend_x, legend_y + 50, legend_x + 20, legend_y + 70, fill='#98D8C8', outline='white', width=2) - canvas.create_text(legend_x + 30, legend_y + 60, text="其他环境", font=('微软雅黑', 8), anchor=tk.W) - - except Exception as e: - canvas.create_text( - 400, 300, - text=f"绘制关系图时出错: {e}", - font=('微软雅黑', 10), - fill='red' - ) - - def switch_active_environment_dialog(self): - """ - 切换当前激活环境的对话框 - """ - # 获取所有环境 - environments = self.agent.db.get_all_environments() - if not environments: - messagebox.showerror("错误", "没有可用的环境!\n请先创建环境。") - return - - current_env = self.agent.db.get_active_environment() - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("切换环境") - dialog.geometry("500x400") - dialog.transient(self.root) - dialog.grab_set() - - ttk.Label( - dialog, - text="选择要切换到的环境:", - font=("微软雅黑", 10, "bold") - ).pack(pady=10) - - if current_env: - ttk.Label( - dialog, - text=f"当前环境: {current_env['name']}", - font=("微软雅黑", 9), - foreground="#0066cc" - ).pack(pady=5) - - # 环境列表 - listbox_frame = ttk.Frame(dialog) - listbox_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - scrollbar = ttk.Scrollbar(listbox_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - env_listbox = tk.Listbox( - listbox_frame, - font=("微软雅黑", 9), - yscrollcommand=scrollbar.set - ) - env_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.config(command=env_listbox.yview) - - # 填充环境列表 - env_map = {} - for i, env in enumerate(environments): - label = f"{env['name']}" - if current_env and env['uuid'] == current_env['uuid']: - label += " (当前)" - env_listbox.insert(tk.END, label) - env_map[i] = env - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=10) - - def do_switch(): - selection = env_listbox.curselection() - if not selection: - messagebox.showwarning("警告", "请选择一个环境!") - return - - selected_env = env_map[selection[0]] - - # 如果是当前环境,不需要切换 - if current_env and selected_env['uuid'] == current_env['uuid']: - messagebox.showinfo("提示", "已经在该环境中了!") - dialog.destroy() - return - - # 如果有当前环境,检查是否可以切换 - if current_env: - can_switch = self.agent.db.can_move_to_environment( - current_env['uuid'], - selected_env['uuid'] - ) - if not can_switch: - result = messagebox.askyesno( - "警告", - f"环境「{current_env['name']}」与「{selected_env['name']}」没有建立连接!\n\n是否仍然要切换?" - ) - if not result: - return - - # 执行切换 - try: - success = self.agent.db.set_active_environment(selected_env['uuid']) - if success: - messagebox.showinfo("成功", f"已切换到环境: {selected_env['name']}") - self.refresh_environment_display() - dialog.destroy() - else: - messagebox.showerror("错误", "切换环境失败!") - except Exception as e: - messagebox.showerror("错误", f"切换环境时出错: {e}") - - ttk.Button(button_frame, text="切换", command=do_switch, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - # ==================== 域管理相关方法 ==================== - - def create_domain_dialog(self): - """创建域的对话框""" - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - dialog = tk.Toplevel(self.root) - dialog.title("创建环境域") - dialog.geometry("600x450") - dialog.transient(self.root) - dialog.grab_set() - - # 标题 - ttk.Label( - dialog, - text="创建环境域(Domain)", - font=("微软雅黑", 12, "bold") - ).pack(pady=10) - - ttk.Label( - dialog, - text="域是多个环境的集合,如'小可家'包含房间、客厅、厨房", - font=("微软雅黑", 9), - foreground="#666666" - ).pack(pady=5) - - # 输入框架 - input_frame = ttk.Frame(dialog) - input_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) - - # 域名称 - ttk.Label(input_frame, text="域名称:", font=("微软雅黑", 10)).grid(row=0, column=0, sticky=tk.W, pady=10) - name_entry = ttk.Entry(input_frame, width=50, font=("微软雅黑", 10)) - name_entry.grid(row=0, column=1, pady=10, padx=10, sticky=tk.W+tk.E) - - # 域描述 - ttk.Label(input_frame, text="域描述:", font=("微软雅黑", 10)).grid(row=1, column=0, sticky=tk.NW, pady=10) - desc_text = scrolledtext.ScrolledText(input_frame, width=50, height=6, wrap=tk.WORD, font=("微软雅黑", 9)) - desc_text.grid(row=1, column=1, pady=10, padx=10, sticky=tk.W+tk.E) - - # 默认环境 - ttk.Label(input_frame, text="默认环境:", font=("微软雅黑", 10)).grid(row=2, column=0, sticky=tk.W, pady=10) - - all_envs = self.agent.db.get_all_environments() - env_names = ["(暂不设置)"] + [env['name'] for env in all_envs] - env_combo = ttk.Combobox(input_frame, values=env_names, state="readonly", width=47, font=("微软雅黑", 9)) - env_combo.set("(暂不设置)") - env_combo.grid(row=2, column=1, pady=10, padx=10, sticky=tk.W+tk.E) - - input_frame.columnconfigure(1, weight=1) - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=20) - - def save_domain(): - name = name_entry.get().strip() - description = desc_text.get("1.0", tk.END).strip() - default_env_name = env_combo.get() - - if not name: - messagebox.showwarning("警告", "域名称不能为空!") - return - - # 获取默认环境UUID - default_env_uuid = None - if default_env_name != "(暂不设置)": - for env in all_envs: - if env['name'] == default_env_name: - default_env_uuid = env['uuid'] - break - - try: - domain_uuid = self.agent.db.create_domain( - name=name, - description=description, - default_environment_uuid=default_env_uuid - ) - messagebox.showinfo("成功", f"域创建成功!\n域名: {name}") - self.add_system_message(f"🏘️ 创建了新域: {name}") - self.refresh_environment_display() - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"创建域失败: {str(e)}") - - ttk.Button(button_frame, text="创建", command=save_domain, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def edit_domain_dialog(self): - """编辑域的对话框""" - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - # 获取所有域 - domains = self.agent.db.get_all_domains() - if not domains: - messagebox.showinfo("提示", "还没有创建任何域。\n请先使用'创建域'功能创建域。") - return - - # 选择域对话框 - select_dialog = tk.Toplevel(self.root) - select_dialog.title("选择要编辑的域") - select_dialog.geometry("500x400") - select_dialog.transient(self.root) - select_dialog.grab_set() - - ttk.Label( - select_dialog, - text="选择要编辑的域:", - font=("微软雅黑", 10, "bold") - ).pack(pady=10) - - # 域列表 - listbox_frame = ttk.Frame(select_dialog) - listbox_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - scrollbar = ttk.Scrollbar(listbox_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - domain_listbox = tk.Listbox( - listbox_frame, - font=("微软雅黑", 9), - yscrollcommand=scrollbar.set - ) - domain_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.config(command=domain_listbox.yview) - - # 填充域列表 - domain_map = {} - for i, domain in enumerate(domains): - envs = self.agent.db.get_domain_environments(domain['uuid']) - label = f"{domain['name']} ({len(envs)}个环境)" - domain_listbox.insert(tk.END, label) - domain_map[i] = domain - - def do_edit(): - selection = domain_listbox.curselection() - if not selection: - messagebox.showwarning("警告", "请选择一个域!") - return - - selected_domain = domain_map[selection[0]] - select_dialog.destroy() - self._show_edit_domain_form(selected_domain) - - # 按钮 - button_frame = ttk.Frame(select_dialog) - button_frame.pack(pady=10) - - ttk.Button(button_frame, text="编辑", command=do_edit, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=select_dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def _show_edit_domain_form(self, domain): - """显示编辑域的表单""" - dialog = tk.Toplevel(self.root) - dialog.title(f"编辑域: {domain['name']}") - dialog.geometry("600x450") - dialog.transient(self.root) - dialog.grab_set() - - # 标题 - ttk.Label( - dialog, - text=f"编辑域: {domain['name']}", - font=("微软雅黑", 12, "bold") - ).pack(pady=10) - - # 输入框架 - input_frame = ttk.Frame(dialog) - input_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) - - # 域名称 - ttk.Label(input_frame, text="域名称:", font=("微软雅黑", 10)).grid(row=0, column=0, sticky=tk.W, pady=10) - name_entry = ttk.Entry(input_frame, width=50, font=("微软雅黑", 10)) - name_entry.insert(0, domain['name']) - name_entry.grid(row=0, column=1, pady=10, padx=10, sticky=tk.W+tk.E) - - # 域描述 - ttk.Label(input_frame, text="域描述:", font=("微软雅黑", 10)).grid(row=1, column=0, sticky=tk.NW, pady=10) - desc_text = scrolledtext.ScrolledText(input_frame, width=50, height=6, wrap=tk.WORD, font=("微软雅黑", 9)) - desc_text.insert("1.0", domain.get('description', '')) - desc_text.grid(row=1, column=1, pady=10, padx=10, sticky=tk.W+tk.E) - - # 默认环境 - ttk.Label(input_frame, text="默认环境:", font=("微软雅黑", 10)).grid(row=2, column=0, sticky=tk.W, pady=10) - - all_envs = self.agent.db.get_all_environments() - env_names = ["(暂不设置)"] + [env['name'] for env in all_envs] - env_combo = ttk.Combobox(input_frame, values=env_names, state="readonly", width=47, font=("微软雅黑", 9)) - - # 设置当前默认环境 - if domain.get('default_environment_uuid'): - default_env = self.agent.db.get_environment(domain['default_environment_uuid']) - if default_env: - env_combo.set(default_env['name']) - else: - env_combo.set("(暂不设置)") - else: - env_combo.set("(暂不设置)") - - env_combo.grid(row=2, column=1, pady=10, padx=10, sticky=tk.W+tk.E) - - input_frame.columnconfigure(1, weight=1) - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=20) - - def save_changes(): - name = name_entry.get().strip() - description = desc_text.get("1.0", tk.END).strip() - default_env_name = env_combo.get() - - if not name: - messagebox.showwarning("警告", "域名称不能为空!") - return - - # 获取默认环境UUID - default_env_uuid = None - if default_env_name != "(暂不设置)": - for env in all_envs: - if env['name'] == default_env_name: - default_env_uuid = env['uuid'] - break - - try: - self.agent.db.update_domain( - domain['uuid'], - name=name, - description=description, - default_environment_uuid=default_env_uuid - ) - messagebox.showinfo("成功", f"域更新成功!\n域名: {name}") - self.add_system_message(f"🏘️ 更新了域: {name}") - self.refresh_environment_display() - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"更新域失败: {str(e)}") - - ttk.Button(button_frame, text="保存", command=save_changes, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def manage_domain_environments_dialog(self): - """管理域中的环境""" - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - # 获取所有域 - domains = self.agent.db.get_all_domains() - if not domains: - messagebox.showinfo("提示", "还没有创建任何域。\n请先使用'创建域'功能创建域。") - return - - # 选择域对话框 - select_dialog = tk.Toplevel(self.root) - select_dialog.title("选择要管理的域") - select_dialog.geometry("500x400") - select_dialog.transient(self.root) - select_dialog.grab_set() - - ttk.Label( - select_dialog, - text="选择要管理环境的域:", - font=("微软雅黑", 10, "bold") - ).pack(pady=10) - - # 域列表 - listbox_frame = ttk.Frame(select_dialog) - listbox_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - scrollbar = ttk.Scrollbar(listbox_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - domain_listbox = tk.Listbox( - listbox_frame, - font=("微软雅黑", 9), - yscrollcommand=scrollbar.set - ) - domain_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.config(command=domain_listbox.yview) - - # 填充域列表 - domain_map = {} - for i, domain in enumerate(domains): - envs = self.agent.db.get_domain_environments(domain['uuid']) - label = f"{domain['name']} ({len(envs)}个环境)" - domain_listbox.insert(tk.END, label) - domain_map[i] = domain - - def do_manage(): - selection = domain_listbox.curselection() - if not selection: - messagebox.showwarning("警告", "请选择一个域!") - return - - selected_domain = domain_map[selection[0]] - select_dialog.destroy() - self._show_manage_domain_envs_form(selected_domain) - - # 按钮 - button_frame = ttk.Frame(select_dialog) - button_frame.pack(pady=10) - - ttk.Button(button_frame, text="管理", command=do_manage, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=select_dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - def _show_manage_domain_envs_form(self, domain): - """显示管理域环境的表单""" - dialog = tk.Toplevel(self.root) - dialog.title(f"管理域的环境: {domain['name']}") - dialog.geometry("800x550") - dialog.transient(self.root) - dialog.grab_set() - - # 标题 - ttk.Label( - dialog, - text=f"域: {domain['name']}", - font=("微软雅黑", 12, "bold") - ).pack(pady=10) - - ttk.Label( - dialog, - text=domain.get('description', ''), - font=("微软雅黑", 9), - foreground="#666666" - ).pack(pady=5) - - # 主框架 - main_frame = ttk.Frame(dialog) - main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - # 左侧:域中的环境 - left_frame = ttk.Frame(main_frame) - left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5) - - ttk.Label(left_frame, text="域中的环境:", font=("微软雅黑", 10, "bold")).pack(pady=5) - - domain_env_frame = ttk.Frame(left_frame) - domain_env_frame.pack(fill=tk.BOTH, expand=True) - - scrollbar1 = ttk.Scrollbar(domain_env_frame) - domain_env_list = tk.Listbox(domain_env_frame, yscrollcommand=scrollbar1.set, font=("微软雅黑", 9)) - scrollbar1.config(command=domain_env_list.yview) - scrollbar1.pack(side=tk.RIGHT, fill=tk.Y) - domain_env_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - # 中间:操作按钮 - middle_frame = ttk.Frame(main_frame) - middle_frame.pack(side=tk.LEFT, padx=15) - - ttk.Button(middle_frame, text="← 添加", command=lambda: add_to_domain(), width=12).pack(pady=10) - ttk.Button(middle_frame, text="移除 →", command=lambda: remove_from_domain(), width=12).pack(pady=10) - ttk.Button(middle_frame, text="🔄 刷新", command=lambda: refresh_lists(), width=12).pack(pady=10) - - # 右侧:所有环境 - right_frame = ttk.Frame(main_frame) - right_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5) - - ttk.Label(right_frame, text="所有环境:", font=("微软雅黑", 10, "bold")).pack(pady=5) - - all_env_frame = ttk.Frame(right_frame) - all_env_frame.pack(fill=tk.BOTH, expand=True) - - scrollbar2 = ttk.Scrollbar(all_env_frame) - all_env_list = tk.Listbox(all_env_frame, yscrollcommand=scrollbar2.set, font=("微软雅黑", 9)) - scrollbar2.config(command=all_env_list.yview) - scrollbar2.pack(side=tk.RIGHT, fill=tk.Y) - all_env_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - # 存储环境UUID - domain_env_data = {} - all_env_data = {} - - def refresh_lists(): - """刷新环境列表""" - domain_env_list.delete(0, tk.END) - all_env_list.delete(0, tk.END) - domain_env_data.clear() - all_env_data.clear() - - # 获取域中的环境 - domain_envs = self.agent.db.get_domain_environments(domain['uuid']) - for env in domain_envs: - domain_env_list.insert(tk.END, env['name']) - domain_env_data[env['name']] = env['uuid'] - - # 获取所有环境(排除已在域中的) - all_envs = self.agent.db.get_all_environments() - domain_env_uuids = set(domain_env_data.values()) - for env in all_envs: - if env['uuid'] not in domain_env_uuids: - all_env_list.insert(tk.END, env['name']) - all_env_data[env['name']] = env['uuid'] - - def add_to_domain(): - """添加环境到域""" - selection = all_env_list.curselection() - if not selection: - messagebox.showwarning("警告", "请先选择一个环境!") - return - - env_name = all_env_list.get(selection[0]) - env_uuid = all_env_data[env_name] - - try: - self.agent.db.add_environment_to_domain(domain['uuid'], env_uuid) - refresh_lists() - self.add_system_message(f"🏘️ 将环境 '{env_name}' 添加到域 '{domain['name']}'") - messagebox.showinfo("成功", f"已添加环境 '{env_name}' 到域") - except Exception as e: - messagebox.showerror("错误", f"添加失败: {str(e)}") - - def remove_from_domain(): - """从域中移除环境""" - selection = domain_env_list.curselection() - if not selection: - messagebox.showwarning("警告", "请先选择一个环境!") - return - - env_name = domain_env_list.get(selection[0]) - env_uuid = domain_env_data[env_name] - - result = messagebox.askyesno("确认", f"确定要从域中移除环境 '{env_name}' 吗?") - if result: - try: - self.agent.db.remove_environment_from_domain(domain['uuid'], env_uuid) - refresh_lists() - self.add_system_message(f"🏘️ 从域 '{domain['name']}' 移除了环境 '{env_name}'") - messagebox.showinfo("成功", f"已从域中移除环境 '{env_name}'") - except Exception as e: - messagebox.showerror("错误", f"移除失败: {str(e)}") - - # 初始加载 - refresh_lists() - - # 底部关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy, width=15).pack(pady=10) - - def switch_to_domain_dialog(self): - """切换到域的对话框""" - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - # 获取所有域 - domains = self.agent.db.get_all_domains() - if not domains: - messagebox.showinfo("提示", "还没有创建任何域。\n请先使用'创建域'功能创建域。") - return - - # 获取当前环境和域 - current_env = self.agent.db.get_active_environment() - current_domain = None - if current_env: - current_domains = self.agent.db.get_environment_domains(current_env['uuid']) - if current_domains: - current_domain = current_domains[0] - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("切换域") - dialog.geometry("550x450") - dialog.transient(self.root) - dialog.grab_set() - - ttk.Label( - dialog, - text="选择要切换到的域:", - font=("微软雅黑", 10, "bold") - ).pack(pady=10) - - if current_domain: - ttk.Label( - dialog, - text=f"当前域: {current_domain['name']}", - font=("微软雅黑", 9), - foreground="#0066cc" - ).pack(pady=5) - - # 域列表 - listbox_frame = ttk.Frame(dialog) - listbox_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - scrollbar = ttk.Scrollbar(listbox_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - domain_listbox = tk.Listbox( - listbox_frame, - font=("微软雅黑", 9), - yscrollcommand=scrollbar.set - ) - domain_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.config(command=domain_listbox.yview) - - # 填充域列表 - domain_map = {} - for i, domain in enumerate(domains): - envs = self.agent.db.get_domain_environments(domain['uuid']) - default_env = None - if domain.get('default_environment_uuid'): - default_env = self.agent.db.get_environment(domain['default_environment_uuid']) - - label = f"{domain['name']} ({len(envs)}个环境" - if default_env: - label += f", 默认: {default_env['name']}" - label += ")" - - if current_domain and domain['uuid'] == current_domain['uuid']: - label += " (当前)" - - domain_listbox.insert(tk.END, label) - domain_map[i] = domain - - # 按钮 - button_frame = ttk.Frame(dialog) - button_frame.pack(pady=10) - - def do_switch(): - selection = domain_listbox.curselection() - if not selection: - messagebox.showwarning("警告", "请选择一个域!") - return - - selected_domain = domain_map[selection[0]] - - # 如果是当前域,不需要切换 - if current_domain and selected_domain['uuid'] == current_domain['uuid']: - messagebox.showinfo("提示", "已经在该域中了!") - dialog.destroy() - return - - try: - # 使用vision_tool的切换域方法 - success = self.agent.vision_tool.switch_to_domain(selected_domain['uuid']) - - if success: - new_env = self.agent.db.get_active_environment() - messagebox.showinfo( - "成功", - f"已切换到域: {selected_domain['name']}\n当前位置: {new_env['name']}" - ) - self.add_system_message( - f"🎯 切换到域: {selected_domain['name']} (位置: {new_env['name']})" - ) - self.refresh_environment_display() - dialog.destroy() - else: - messagebox.showerror("错误", "切换域失败!请检查域的配置。") - except Exception as e: - messagebox.showerror("错误", f"切换域时出错: {str(e)}") - - ttk.Button(button_frame, text="切换", command=do_switch, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - # ==================== 个性化表达相关方法 ==================== - - def learn_user_expressions_now(self): - """ - 立即学习用户表达习惯 - """ - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - result = messagebox.askyesno( - "确认", - "将分析最近的对话记录,学习用户的表达习惯。\n\n确定要开始学习吗?" - ) - - if not result: - return - - try: - self.update_status("学习用户表达习惯中...", "orange") - self.root.update() - - learned_habits = self.agent.learn_user_expressions_now() - - if learned_habits: - habit_list = "\n".join([ - f"• '{h['expression_pattern']}' => {h['meaning']}" - for h in learned_habits[:5] # 最多显示5个 - ]) - if len(learned_habits) > 5: - habit_list += f"\n... 还有 {len(learned_habits) - 5} 个" - - messagebox.showinfo( - "学习完成", - f"成功学习到 {len(learned_habits)} 个用户表达习惯:\n\n{habit_list}" - ) - self.add_system_message(f"🎯 已学习到 {len(learned_habits)} 个用户表达习惯") - else: - messagebox.showinfo("学习完成", "未发现新的表达习惯。") - self.add_system_message("🎯 未发现新的用户表达习惯") - - self.update_status("就绪", "green") - - except Exception as e: - self.update_status("错误", "red") - messagebox.showerror("错误", f"学习用户表达习惯时出错:{str(e)}") - - def add_agent_expression_dialog(self): - """ - 添加智能体表达对话框 - """ - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("添加智能体个性化表达") - dialog.geometry("500x350") - dialog.transient(self.root) - dialog.grab_set() - - # 主容器 - container = ttk.Frame(dialog, padding=20) - container.pack(fill=tk.BOTH, expand=True) - - # 说明 - ttk.Label( - container, - text="添加智能体个性化表达", - font=("微软雅黑", 12, "bold") - ).pack(pady=(0, 10)) - - ttk.Label( - container, - text="定义智能体在对话中可以使用的个性化表达方式", - font=("微软雅黑", 9), - foreground="#666666" - ).pack(pady=(0, 15)) - - # 表达方式 - ttk.Label(container, text="表达方式:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - expression_entry = ttk.Entry(container, font=("微软雅黑", 10), width=40) - expression_entry.pack(fill=tk.X, pady=(0, 10)) - expression_entry.insert(0, "例如: wc、hhh、orz") - - # 含义 - ttk.Label(container, text="含义:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - meaning_text = scrolledtext.ScrolledText(container, height=4, font=("微软雅黑", 9)) - meaning_text.pack(fill=tk.X, pady=(0, 10)) - meaning_text.insert(tk.END, "例如: 表示对突发事情的感叹") - - # 分类 - ttk.Label(container, text="分类:", font=("微软雅黑", 10)).pack(anchor=tk.W, pady=(0, 5)) - category_var = tk.StringVar(value="通用") - category_combo = ttk.Combobox( - container, - textvariable=category_var, - font=("微软雅黑", 10), - width=38, - state="readonly" - ) - category_combo['values'] = ['通用', '感叹词', '网络用语', '表情替代', '语气词', '口头禅'] - category_combo.pack(fill=tk.X, pady=(0, 15)) - - # 按钮 - button_frame = ttk.Frame(container) - button_frame.pack(pady=10) - - def save_expression(): - expression = expression_entry.get().strip() - meaning = meaning_text.get("1.0", tk.END).strip() - category = category_var.get() - - # 清除示例文本 - if expression.startswith("例如"): - expression = "" - if meaning.startswith("例如"): - meaning = "" - - if not expression or not meaning: - messagebox.showwarning("输入错误", "表达方式和含义不能为空") - return - - try: - self.agent.add_agent_expression(expression, meaning, category) - messagebox.showinfo("成功", f"已添加智能体表达:\n'{expression}' => '{meaning}'") - self.add_system_message(f"✨ 已添加智能体表达: '{expression}'") - dialog.destroy() - except Exception as e: - messagebox.showerror("错误", f"添加表达失败: {str(e)}") - - ttk.Button(button_frame, text="保存", command=save_expression, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="取消", command=dialog.destroy, width=15).pack(side=tk.LEFT, padx=5) - - # 清除示例文本的事件处理 - def clear_entry_example(event): - if expression_entry.get().startswith("例如"): - expression_entry.delete(0, tk.END) - - def clear_text_example(event): - if meaning_text.get("1.0", tk.END).strip().startswith("例如"): - meaning_text.delete("1.0", tk.END) - - expression_entry.bind("", clear_entry_example) - meaning_text.bind("", clear_text_example) - - def show_expression_style(self): - """ - 显示表达风格详情 - """ - if not self.agent: - messagebox.showerror("错误", "聊天代理未初始化") - return - - # 创建对话框 - dialog = tk.Toplevel(self.root) - dialog.title("个性化表达风格") - dialog.geometry("700x600") - dialog.transient(self.root) - - # 标题 - title_frame = ttk.Frame(dialog, padding=10) - title_frame.pack(fill=tk.X) - - ttk.Label( - title_frame, - text="🎨 个性化表达风格", - font=("微软雅黑", 12, "bold") - ).pack() - - # 统计信息 - stats = self.agent.get_expression_statistics() - stats_text = ( - f"智能体表达: {stats['agent_expressions']['total']} 个 " - f"(总使用次数: {stats['agent_expressions']['total_usage']}) | " - f"用户习惯: {stats['user_habits']['total']} 个 " - f"(高置信度: {stats['user_habits']['high_confidence']})" - ) - ttk.Label(title_frame, text=stats_text, font=("微软雅黑", 9)).pack() - - # 工具栏 - toolbar = ttk.Frame(dialog) - toolbar.pack(fill=tk.X, padx=10, pady=5) - - ttk.Button( - toolbar, - text="🔄 刷新", - command=lambda: self.refresh_expression_display(text_widget), - width=10 - ).pack(side=tk.LEFT, padx=2) - - def add_expression_and_refresh(): - self.add_agent_expression_dialog() - self.refresh_expression_display(text_widget) - - def learn_and_refresh(): - self.learn_user_expressions_now() - self.refresh_expression_display(text_widget) - - ttk.Button( - toolbar, - text="➕ 添加表达", - command=add_expression_and_refresh, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - toolbar, - text="🎯 立即学习", - command=learn_and_refresh, - width=12 - ).pack(side=tk.LEFT, padx=2) - - ttk.Button( - toolbar, - text="🗑️ 清空用户习惯", - command=lambda: self.clear_user_expression_habits(text_widget), - width=15 - ).pack(side=tk.LEFT, padx=2) - - # 显示区域 - text_widget = scrolledtext.ScrolledText( - dialog, - wrap=tk.WORD, - font=("微软雅黑", 10), - bg="#f9f9f9" - ) - text_widget.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - # 初始加载 - self.refresh_expression_display(text_widget) - - # 关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy, width=15).pack(pady=10) - - def refresh_expression_display(self, text_widget): - """ - 刷新表达风格显示 - - Args: - text_widget: 文本组件 - """ - if not self.agent: - return - - text_widget.config(state=tk.NORMAL) - text_widget.delete(1.0, tk.END) - - # 智能体表达 - agent_expressions = self.agent.get_agent_expressions() - text_widget.insert(tk.END, "【智能体个性化表达】\n", "title") - text_widget.insert(tk.END, "以下表达方式会在智能体回复时自然使用:\n\n") - - if agent_expressions: - for expr in agent_expressions: - text_widget.insert(tk.END, f" ✨ '{expr['expression']}' => {expr['meaning']}\n") - uuid_value = expr.get('uuid', '') - display_uuid = uuid_value[:8] if isinstance(uuid_value, str) and len(uuid_value) >= 8 else (uuid_value if uuid_value else "N/A") - text_widget.insert(tk.END, f" 分类: {expr['category']} | 使用次数: {expr['usage_count']} | UUID: {display_uuid}...\n\n") - else: - text_widget.insert(tk.END, " 暂无智能体表达。点击「添加表达」创建新的表达方式。\n\n") - - text_widget.insert(tk.END, "\n" + "=" * 60 + "\n\n") - - # 用户表达习惯 - user_habits = self.agent.get_user_expression_habits() - text_widget.insert(tk.END, "【用户表达习惯(自动学习)】\n", "title") - text_widget.insert(tk.END, "以下是从对话中学习到的用户表达习惯:\n\n") - - if user_habits: - for habit in user_habits: - confidence_icon = "🟢" if habit['confidence'] >= 0.8 else "🟡" if habit['confidence'] >= 0.5 else "🔴" - text_widget.insert(tk.END, f" {confidence_icon} '{habit['expression_pattern']}' => {habit['meaning']}\n") - text_widget.insert(tk.END, f" 频率: {habit['frequency']} | 置信度: {habit['confidence']:.2f} | 学习于第 {habit.get('learned_from_rounds', '未记录')} 轮\n\n") - else: - text_widget.insert(tk.END, " 暂无用户表达习惯。对话10轮后会自动学习,或点击「立即学习」。\n\n") - - text_widget.config(state=tk.DISABLED) - - def clear_user_expression_habits(self, text_widget=None): - """ - 清空用户表达习惯 - - Args: - text_widget: 文本组件(可选,用于刷新显示) - """ - if not self.agent: - return - - result = messagebox.askyesno( - "确认", - "确定要清空所有用户表达习惯吗?\n此操作不可恢复。" - ) - - if result: - success = self.agent.clear_user_expression_habits() - if success: - messagebox.showinfo("成功", "用户表达习惯已清空") - self.add_system_message("🗑️ 用户表达习惯已清空") - if text_widget: - self.refresh_expression_display(text_widget) - else: - messagebox.showerror("错误", "清空用户表达习惯失败") - - def show_about(self): - """ - 显示关于对话框 - """ - about_text = """ -智能对话代理 v3.1 个性化表达版 -基于LangChain和Python开发 - -功能特性: -• 角色扮演对话 -• 三层记忆系统(短期+长期+知识库) -• 短期记忆:最近20轮详细对话 -• 长期记忆:自动主题概括(每20轮) -• 知识库:自动知识提取(每5轮) -• 个性化表达:智能体表达定制 -• 用户习惯学习:自动学习用户表达(每10轮) -• 对话主题时间线可视化 -• 知识库搜索和分类管理 -• 对话历史持久化 -• 可视化调试界面 - -技术栈: Python + Tkinter + LangChain -开发: 2025 - """ - messagebox.showinfo("关于", about_text) - - -def main(): - """ - 主函数 - """ - root = tk.Tk() - - style = ttk.Style() - try: - style.theme_use('clam') - except: - pass - - app = EnhancedChatDebugGUI(root) - root.mainloop() - - -if __name__ == '__main__': - main() - diff --git a/src/gui/nps_config_dialog.py b/src/gui/nps_config_dialog.py deleted file mode 100644 index e501846..0000000 --- a/src/gui/nps_config_dialog.py +++ /dev/null @@ -1,358 +0,0 @@ -""" -NPS插件配置对话框 -提供可视化的插件配置管理界面 -""" - -import os -import tkinter as tk -from tkinter import ttk, messagebox, scrolledtext -from typing import Dict, Any, Optional -from src.nps.nps_config_manager import NPSConfigManager -from src.nps.nps_registry import NPSTool - - -class NPSPluginConfigDialog: - """ - NPS插件配置对话框 - 允许用户可视化地编辑插件配置 - """ - - def __init__(self, parent, tool: NPSTool, config_manager: NPSConfigManager): - """ - 初始化配置对话框 - - Args: - parent: 父窗口 - tool: NPS工具对象 - config_manager: 配置管理器 - """ - self.parent = parent - self.tool = tool - self.config_manager = config_manager - self.config_widgets = {} - self.result = None - - # 创建对话框窗口 - self.dialog = tk.Toplevel(parent) - self.dialog.title(f"配置 - {tool.name}") - self.dialog.geometry("600x500") - self.dialog.transient(parent) - self.dialog.grab_set() - - # 加载当前配置 - self.current_config = self.config_manager.get_plugin_config(tool.tool_id) - - # 创建界面 - self.create_widgets() - - # 居中显示 - self.center_dialog() - - def center_dialog(self): - """使对话框居中显示""" - self.dialog.update_idletasks() - width = self.dialog.winfo_width() - height = self.dialog.winfo_height() - x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) - y = (self.dialog.winfo_screenheight() // 2) - (height // 2) - self.dialog.geometry(f'{width}x{height}+{x}+{y}') - - def create_widgets(self): - """创建所有界面组件""" - # 顶部信息区 - info_frame = ttk.Frame(self.dialog, padding=10) - info_frame.pack(fill=tk.X) - - ttk.Label(info_frame, text=f"🔧 {self.tool.name}", - font=("微软雅黑", 12, "bold")).pack(anchor=tk.W) - ttk.Label(info_frame, text=f"工具ID: {self.tool.tool_id}", - font=("微软雅黑", 9), foreground="gray").pack(anchor=tk.W) - ttk.Label(info_frame, text=f"版本: {self.tool.version}", - font=("微软雅黑", 9), foreground="gray").pack(anchor=tk.W) - - ttk.Separator(self.dialog, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) - - # 配置区域(使用滚动容器) - canvas_frame = ttk.Frame(self.dialog) - canvas_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - # 创建Canvas和滚动条 - canvas = tk.Canvas(canvas_frame, highlightthickness=0) - scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL, command=canvas.yview) - - # 创建配置内容容器 - self.config_frame = ttk.Frame(canvas) - - # 配置滚动 - canvas.configure(yscrollcommand=scrollbar.set) - canvas.create_window((0, 0), window=self.config_frame, anchor=tk.NW) - - # 布局 - canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # 更新滚动区域 - self.config_frame.bind('', - lambda e: canvas.configure(scrollregion=canvas.bbox('all'))) - - # 创建配置项 - self.create_config_fields() - - # 按钮区域 - button_frame = ttk.Frame(self.dialog, padding=10) - button_frame.pack(fill=tk.X, side=tk.BOTTOM) - - ttk.Button(button_frame, text="💾 保存", - command=self.save_config, width=12).pack(side=tk.RIGHT, padx=2) - ttk.Button(button_frame, text="❌ 取消", - command=self.cancel, width=12).pack(side=tk.RIGHT, padx=2) - ttk.Button(button_frame, text="🔄 重置", - command=self.reset_config, width=12).pack(side=tk.RIGHT, padx=2) - ttk.Button(button_frame, text="📤 导出", - command=self.export_config, width=12).pack(side=tk.LEFT, padx=2) - ttk.Button(button_frame, text="📥 导入", - command=self.import_config, width=12).pack(side=tk.LEFT, padx=2) - - def create_config_fields(self): - """创建配置字段""" - # 根据工具ID创建特定的配置字段 - if self.tool.tool_id == 'websearch': - self.create_websearch_fields() - elif self.tool.tool_id == 'systime': - self.create_systime_fields() - else: - self.create_generic_fields() - - # 通用字段:启用/禁用 - self.create_enabled_field() - - def create_websearch_fields(self): - """创建网络搜索工具的配置字段""" - row = 0 - - # API Key - ttk.Label(self.config_frame, text="SerpAPI Key:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - api_key_frame = ttk.Frame(self.config_frame) - api_key_frame.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=5) - - api_key_var = tk.StringVar(value=self.current_config.get('api_key', '${SERPAPI_API_KEY}')) - api_key_entry = ttk.Entry(api_key_frame, textvariable=api_key_var, show="*", width=40) - api_key_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # 显示/隐藏按钮 - show_btn = ttk.Button(api_key_frame, text="👁", width=3, - command=lambda: self.toggle_password_visibility(api_key_entry)) - show_btn.pack(side=tk.LEFT, padx=2) - - self.config_widgets['api_key'] = api_key_var - - ttk.Label(self.config_frame, text="提示:可使用${ENV_VAR}引用环境变量", - font=("微软雅黑", 8), foreground="gray").grid( - row=row+1, column=1, sticky=tk.W, padx=5, pady=0) - row += 2 - - # 搜索引擎 - ttk.Label(self.config_frame, text="搜索引擎:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - engine_var = tk.StringVar(value=self.current_config.get('engine', 'google')) - engine_combo = ttk.Combobox(self.config_frame, textvariable=engine_var, - values=['google', 'bing', 'yahoo'], width=37, state='readonly') - engine_combo.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=5) - self.config_widgets['engine'] = engine_var - row += 1 - - # 结果数量 - ttk.Label(self.config_frame, text="结果数量:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - num_results_var = tk.IntVar(value=self.current_config.get('num_results', 5)) - num_results_spin = ttk.Spinbox(self.config_frame, from_=1, to=20, - textvariable=num_results_var, width=37) - num_results_spin.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=5) - self.config_widgets['num_results'] = num_results_var - row += 1 - - # 语言 - ttk.Label(self.config_frame, text="语言:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - language_var = tk.StringVar(value=self.current_config.get('language', 'zh-cn')) - language_combo = ttk.Combobox(self.config_frame, textvariable=language_var, - values=['zh-cn', 'en', 'ja', 'ko'], width=37, state='readonly') - language_combo.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=5) - self.config_widgets['language'] = language_var - row += 1 - - # 配置列权重 - self.config_frame.columnconfigure(1, weight=1) - - def create_systime_fields(self): - """创建系统时间工具的配置字段""" - row = 0 - - # 时区 - ttk.Label(self.config_frame, text="时区:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - timezone_var = tk.StringVar(value=self.current_config.get('timezone', 'Asia/Shanghai')) - timezone_combo = ttk.Combobox(self.config_frame, textvariable=timezone_var, - values=['Asia/Shanghai', 'UTC', 'America/New_York', - 'Europe/London', 'Asia/Tokyo'], - width=37) - timezone_combo.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=5) - self.config_widgets['timezone'] = timezone_var - row += 1 - - # 时间格式 - ttk.Label(self.config_frame, text="时间格式:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - format_var = tk.StringVar(value=self.current_config.get('format', '%Y-%m-%d %H:%M:%S')) - format_entry = ttk.Entry(self.config_frame, textvariable=format_var, width=40) - format_entry.grid(row=row, column=1, sticky=tk.EW, padx=5, pady=5) - self.config_widgets['format'] = format_var - - ttk.Label(self.config_frame, text="例如: %Y-%m-%d %H:%M:%S", - font=("微软雅黑", 8), foreground="gray").grid( - row=row+1, column=1, sticky=tk.W, padx=5, pady=0) - row += 2 - - # 配置列权重 - self.config_frame.columnconfigure(1, weight=1) - - def create_generic_fields(self): - """创建通用配置字段(用于未知的插件)""" - row = 0 - - ttk.Label(self.config_frame, text="自定义配置:", - font=("微软雅黑", 10, "bold")).grid(row=row, column=0, sticky=tk.W, padx=5, pady=5) - - # JSON文本编辑器 - config_text = scrolledtext.ScrolledText(self.config_frame, height=10, width=50, - font=("Consolas", 10)) - config_text.grid(row=row+1, column=0, columnspan=2, sticky=tk.NSEW, padx=5, pady=5) - - # 加载当前配置 - import json - config_json = json.dumps(self.current_config, indent=4, ensure_ascii=False) - config_text.insert("1.0", config_json) - - self.config_widgets['_json_config'] = config_text - - self.config_frame.rowconfigure(row+1, weight=1) - self.config_frame.columnconfigure(1, weight=1) - - def create_enabled_field(self): - """创建启用/禁用字段""" - separator = ttk.Separator(self.config_frame, orient=tk.HORIZONTAL) - separator.grid(row=100, column=0, columnspan=2, sticky=tk.EW, padx=5, pady=10) - - enabled_var = tk.BooleanVar(value=self.current_config.get('enabled', True)) - enabled_check = ttk.Checkbutton(self.config_frame, text="✓ 启用此插件", - variable=enabled_var) - enabled_check.grid(row=101, column=0, columnspan=2, sticky=tk.W, padx=5, pady=5) - self.config_widgets['enabled'] = enabled_var - - def toggle_password_visibility(self, entry_widget): - """切换密码可见性""" - current_show = entry_widget.cget('show') - if current_show == '*': - entry_widget.config(show='') - else: - entry_widget.config(show='*') - - def save_config(self): - """保存配置""" - try: - # 收集配置 - new_config = {} - - # 如果是JSON编辑模式 - if '_json_config' in self.config_widgets: - import json - json_text = self.config_widgets['_json_config'].get("1.0", tk.END).strip() - try: - new_config = json.loads(json_text) - except json.JSONDecodeError as e: - messagebox.showerror("错误", f"JSON格式错误:{str(e)}") - return - else: - # 收集各个字段的值 - for key, widget in self.config_widgets.items(): - if key != 'enabled': - value = widget.get() - new_config[key] = value - - # 添加enabled字段 - new_config['enabled'] = self.config_widgets['enabled'].get() - - # 保存配置 - if self.config_manager.set_plugin_config(self.tool.tool_id, new_config): - messagebox.showinfo("成功", "配置已保存") - self.result = new_config - self.dialog.destroy() - else: - messagebox.showerror("错误", "保存配置失败") - - except Exception as e: - messagebox.showerror("错误", f"保存配置时出错:{str(e)}") - - def reset_config(self): - """重置配置为默认值""" - if messagebox.askyesno("确认", "确定要重置配置为默认值吗?"): - self.current_config = {} - # 重新创建配置字段 - for widget in self.config_frame.winfo_children(): - widget.destroy() - self.config_widgets = {} - self.create_config_fields() - - def cancel(self): - """取消并关闭对话框""" - self.result = None - self.dialog.destroy() - - def export_config(self): - """导出配置到文件""" - from tkinter import filedialog - filename = filedialog.asksaveasfilename( - title="导出配置", - defaultextension=".json", - filetypes=[("JSON文件", "*.json"), ("所有文件", "*.*")], - initialfile=f"{self.tool.tool_id}_config.json" - ) - - if filename: - if self.config_manager.export_config(self.tool.tool_id, filename): - messagebox.showinfo("成功", f"配置已导出到:\n{filename}") - else: - messagebox.showerror("错误", "导出配置失败") - - def import_config(self): - """从文件导入配置""" - from tkinter import filedialog - filename = filedialog.askopenfilename( - title="导入配置", - filetypes=[("JSON文件", "*.json"), ("所有文件", "*.*")] - ) - - if filename: - if self.config_manager.import_config(self.tool.tool_id, filename): - messagebox.showinfo("成功", "配置已导入") - # 重新加载配置 - self.current_config = self.config_manager.get_plugin_config(self.tool.tool_id) - # 重新创建配置字段 - for widget in self.config_frame.winfo_children(): - widget.destroy() - self.config_widgets = {} - self.create_config_fields() - else: - messagebox.showerror("错误", "导入配置失败") - - def show(self) -> Optional[Dict[str, Any]]: - """显示对话框并等待用户操作""" - self.dialog.wait_window() - return self.result diff --git a/src/gui/nps_gui.py b/src/gui/nps_gui.py deleted file mode 100644 index 8b33606..0000000 --- a/src/gui/nps_gui.py +++ /dev/null @@ -1,923 +0,0 @@ -""" -NPS (Neo Plugin System) 工具管理GUI模块 -提供可视化界面管理NPS工具的配置和状态 -""" - -import os -import json -import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox, simpledialog, filedialog -from typing import Dict, Any, List -from datetime import datetime -from src.nps.nps_registry import NPSRegistry, NPSTool -from src.nps.nps_config_manager import NPSConfigManager -from src.gui.nps_config_dialog import NPSPluginConfigDialog - -# 配置常量 -DEFAULT_REFRESH_INTERVAL = 3000 # 默认自动刷新间隔(毫秒) - - -class NPSManagerGUI: - """ - NPS 工具管理GUI界面 - 提供NPS工具的可视化管理功能 - """ - - def __init__(self, parent_frame, nps_registry: NPSRegistry = None): - """ - 初始化NPS管理GUI - - Args: - parent_frame: 父容器 - nps_registry: NPS工具注册表实例 - """ - self.parent = parent_frame - self.registry = nps_registry or NPSRegistry() - - # 初始化配置管理器 - self.config_manager = NPSConfigManager() - - # 如果注册表为空,扫描并注册工具 - if not self.registry.get_all_tools(): - self.registry.scan_and_register() - - # 自动刷新相关 - self.auto_refresh_enabled = True - self.refresh_interval = DEFAULT_REFRESH_INTERVAL - self.refresh_job = None - - # 创建界面 - self.create_widgets() - - # 首次刷新数据 - self.refresh_tools() - - # 启动自动刷新 - self.start_auto_refresh() - - # 绑定窗口关闭事件 - self.parent.bind('', self.on_destroy) - - def on_destroy(self, event=None): - """窗口销毁时的清理工作""" - self.stop_auto_refresh() - - def create_widgets(self): - """创建所有GUI组件""" - # 顶部工具栏 - toolbar = ttk.Frame(self.parent) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(toolbar, text="🔧 NPS工具管理", font=("微软雅黑", 12, "bold")).pack(side=tk.LEFT, padx=5) - - ttk.Button(toolbar, text="🔄 刷新", command=self.refresh_tools, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="⚙️ 高级配置", command=self.edit_advanced_config, width=12).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="➕ 创建工具", command=self.create_new_tool, width=12).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="📥 导入工具", command=self.import_tool, width=12).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="📊 统计信息", command=self.show_statistics, width=12).pack(side=tk.LEFT, padx=2) - - # 自动刷新开关 - self.auto_refresh_btn = ttk.Button(toolbar, text="⏸ 暂停刷新", command=self.toggle_auto_refresh, width=12) - self.auto_refresh_btn.pack(side=tk.LEFT, padx=2) - - # 最后刷新时间 - self.last_refresh_label = ttk.Label(toolbar, text="", font=("微软雅黑", 8), foreground="gray") - self.last_refresh_label.pack(side=tk.RIGHT, padx=5) - - # 分割线 - ttk.Separator(self.parent, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=5, pady=2) - - # 工具列表区域 - list_frame = ttk.Frame(self.parent) - list_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 滚动条 - scrollbar_y = ttk.Scrollbar(list_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(list_frame, orient=tk.HORIZONTAL) - - # Treeview - self.tree = ttk.Treeview( - list_frame, - columns=("tool_id", "name", "description", "version", "author", "enabled", "keywords"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - # 配置列 - self.tree.heading("tool_id", text="工具ID") - self.tree.heading("name", text="名称") - self.tree.heading("description", text="功能描述") - self.tree.heading("version", text="版本") - self.tree.heading("author", text="作者") - self.tree.heading("enabled", text="状态") - self.tree.heading("keywords", text="关键词") - - self.tree.column("tool_id", width=100, minwidth=80, stretch=False) - self.tree.column("name", width=120, minwidth=100, stretch=False) - self.tree.column("description", width=250, minwidth=200, stretch=True) - self.tree.column("version", width=70, minwidth=60, stretch=False) - self.tree.column("author", width=100, minwidth=80, stretch=False) - self.tree.column("enabled", width=70, minwidth=60, stretch=False) - self.tree.column("keywords", width=200, minwidth=150, stretch=True) - - scrollbar_y.config(command=self.tree.yview) - scrollbar_x.config(command=self.tree.xview) - - # 布局 - self.tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - list_frame.grid_rowconfigure(0, weight=1) - list_frame.grid_columnconfigure(0, weight=1) - - # 双击查看详情 - self.tree.bind("", lambda e: self.view_tool_details()) - - # 右键菜单 - self.context_menu = tk.Menu(self.tree, tearoff=0) - self.context_menu.add_command(label="📋 查看详情", command=self.view_tool_details) - self.context_menu.add_command(label="✏️ 编辑配置", command=self.edit_tool_config) - self.context_menu.add_command(label="⚙️ 高级配置", command=self.edit_advanced_config) - self.context_menu.add_separator() - self.context_menu.add_command(label="✓ 启用", command=self.enable_tool) - self.context_menu.add_command(label="✗ 禁用", command=self.disable_tool) - self.context_menu.add_separator() - self.context_menu.add_command(label="🧪 测试工具", command=self.test_tool) - self.context_menu.add_command(label="📤 导出工具", command=self.export_tool) - self.context_menu.add_separator() - self.context_menu.add_command(label="🗑️ 删除工具", command=self.delete_tool) - - self.tree.bind("", self.show_context_menu) - - # 底部工具详情区域 - detail_frame = ttk.LabelFrame(self.parent, text="📝 工具详情", padding=5) - detail_frame.pack(fill=tk.X, padx=5, pady=5) - - self.detail_text = scrolledtext.ScrolledText( - detail_frame, - wrap=tk.WORD, - font=("Consolas", 9), - height=8, - bg="#f9f9f9", - relief=tk.FLAT - ) - self.detail_text.pack(fill=tk.BOTH, expand=True) - self.detail_text.config(state=tk.DISABLED) - - # 绑定选择事件 - self.tree.bind("<>", self.on_tool_select) - - # 底部统计区域 - stats_frame = ttk.Frame(self.parent) - stats_frame.pack(fill=tk.X, padx=5, pady=5) - - self.stats_label = ttk.Label(stats_frame, text="", font=("微软雅黑", 9)) - self.stats_label.pack(side=tk.LEFT, padx=5) - - def refresh_tools(self): - """刷新工具列表""" - # 清空现有内容 - for item in self.tree.get_children(): - self.tree.delete(item) - - try: - # 获取所有工具 - tools = self.registry.get_all_tools() - - for tool in tools: - # 状态显示 - status = "✓ 启用" if tool.enabled else "✗ 禁用" - - # 关键词显示 - keywords = ", ".join(tool.keywords[:5]) # 最多显示5个关键词 - if len(tool.keywords) > 5: - keywords += f" +{len(tool.keywords)-5}..." - - # 插入数据 - self.tree.insert("", tk.END, values=( - tool.tool_id, - tool.name, - tool.description[:50] + "..." if len(tool.description) > 50 else tool.description, - tool.version, - tool.author, - status, - keywords - )) - - # 更新统计 - stats = self.registry.get_statistics() - self.stats_label.config( - text=f"总计: {stats['total_tools']} 个工具 | " - f"已启用: {stats['enabled_tools']} | " - f"已禁用: {stats['disabled_tools']}" - ) - - # 更新最后刷新时间 - self.last_refresh_label.config( - text=f"最后刷新: {datetime.now().strftime('%H:%M:%S')}" - ) - - except Exception as e: - messagebox.showerror("错误", f"刷新工具列表失败: {str(e)}") - - def on_tool_select(self, event=None): - """工具选择事件""" - selection = self.tree.selection() - if not selection: - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if tool: - self.show_tool_detail(tool) - - def show_tool_detail(self, tool: NPSTool): - """显示工具详情""" - self.detail_text.config(state=tk.NORMAL) - self.detail_text.delete("1.0", tk.END) - - detail = f"""工具ID: {tool.tool_id} -名称: {tool.name} -版本: {tool.version} -作者: {tool.author} -状态: {'启用' if tool.enabled else '禁用'} - -功能描述: -{tool.description} - -触发关键词: -{', '.join(tool.keywords) if tool.keywords else '无'} -""" - - self.detail_text.insert("1.0", detail) - self.detail_text.config(state=tk.DISABLED) - - def show_context_menu(self, event): - """显示右键菜单""" - item = self.tree.identify_row(event.y) - if item: - self.tree.selection_set(item) - self.context_menu.post(event.x_root, event.y_root) - - def view_tool_details(self): - """查看工具详情""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("提示", "请先选择一个工具") - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if tool: - # 创建详情对话框 - dialog = tk.Toplevel(self.parent) - dialog.title(f"工具详情 - {tool.name}") - dialog.geometry("500x400") - dialog.transient(self.parent) - - # 详情内容 - text = scrolledtext.ScrolledText( - dialog, - wrap=tk.WORD, - font=("Consolas", 10), - bg="#f9f9f9" - ) - text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - detail = f"""═══════════════════════════════════════ - NPS工具详细信息 -═══════════════════════════════════════ - -【基本信息】 -工具ID: {tool.tool_id} -名称: {tool.name} -版本: {tool.version} -作者: {tool.author} -状态: {'✓ 启用' if tool.enabled else '✗ 禁用'} - -【功能描述】 -{tool.description} - -【触发关键词】 -{', '.join(tool.keywords) if tool.keywords else '无'} - -【配置文件】 -{os.path.join(self.registry.tools_dir, tool.tool_id + '.NPS')} - -【模块文件】 -{os.path.join(self.registry.tools_dir, tool.tool_id + '.py')} -""" - - text.insert("1.0", detail) - text.config(state=tk.DISABLED) - - # 关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy).pack(pady=10) - - def edit_tool_config(self): - """编辑工具配置""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("提示", "请先选择一个工具") - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if not tool: - return - - # 创建编辑对话框 - dialog = tk.Toplevel(self.parent) - dialog.title(f"编辑工具配置 - {tool.name}") - dialog.geometry("500x500") - dialog.transient(self.parent) - dialog.grab_set() - - # 表单区域 - form_frame = ttk.Frame(dialog, padding=10) - form_frame.pack(fill=tk.BOTH, expand=True) - - # 工具ID (只读) - ttk.Label(form_frame, text="工具ID:").grid(row=0, column=0, sticky=tk.W, pady=5) - id_entry = ttk.Entry(form_frame, width=40) - id_entry.grid(row=0, column=1, pady=5, sticky=tk.W) - id_entry.insert(0, tool.tool_id) - id_entry.config(state="readonly") - - # 名称 - ttk.Label(form_frame, text="名称:").grid(row=1, column=0, sticky=tk.W, pady=5) - name_var = tk.StringVar(value=tool.name) - name_entry = ttk.Entry(form_frame, textvariable=name_var, width=40) - name_entry.grid(row=1, column=1, pady=5, sticky=tk.W) - - # 版本 - ttk.Label(form_frame, text="版本:").grid(row=2, column=0, sticky=tk.W, pady=5) - version_var = tk.StringVar(value=tool.version) - version_entry = ttk.Entry(form_frame, textvariable=version_var, width=40) - version_entry.grid(row=2, column=1, pady=5, sticky=tk.W) - - # 作者 - ttk.Label(form_frame, text="作者:").grid(row=3, column=0, sticky=tk.W, pady=5) - author_var = tk.StringVar(value=tool.author) - author_entry = ttk.Entry(form_frame, textvariable=author_var, width=40) - author_entry.grid(row=3, column=1, pady=5, sticky=tk.W) - - # 描述 - ttk.Label(form_frame, text="描述:").grid(row=4, column=0, sticky=tk.NW, pady=5) - desc_text = tk.Text(form_frame, width=40, height=4) - desc_text.grid(row=4, column=1, pady=5, sticky=tk.W) - desc_text.insert("1.0", tool.description) - - # 关键词 - ttk.Label(form_frame, text="关键词:").grid(row=5, column=0, sticky=tk.NW, pady=5) - keywords_text = tk.Text(form_frame, width=40, height=3) - keywords_text.grid(row=5, column=1, pady=5, sticky=tk.W) - keywords_text.insert("1.0", ", ".join(tool.keywords)) - ttk.Label(form_frame, text="(逗号分隔)", font=("微软雅黑", 8), foreground="gray").grid(row=5, column=1, sticky=tk.E, pady=5) - - # 启用状态 - enabled_var = tk.BooleanVar(value=tool.enabled) - enabled_check = ttk.Checkbutton(form_frame, text="启用此工具", variable=enabled_var) - enabled_check.grid(row=6, column=1, sticky=tk.W, pady=10) - - def save_config(): - """保存配置""" - try: - # 读取.NPS文件 - nps_path = os.path.join(self.registry.tools_dir, f"{tool.tool_id}.NPS") - if os.path.exists(nps_path): - with open(nps_path, 'r', encoding='utf-8') as f: - config = json.load(f) - else: - config = {} - - # 更新配置 - config['name'] = name_var.get() - config['version'] = version_var.get() - config['author'] = author_var.get() - config['description'] = desc_text.get("1.0", tk.END).strip() - - # 解析关键词 - keywords_str = keywords_text.get("1.0", tk.END).strip() - config['keywords'] = [k.strip() for k in keywords_str.split(',') if k.strip()] - - config['enabled'] = enabled_var.get() - - # 保存文件 - with open(nps_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=4) - - # 更新内存中的工具 - tool.name = config['name'] - tool.version = config['version'] - tool.author = config['author'] - tool.description = config['description'] - tool.keywords = config['keywords'] - tool.enabled = config['enabled'] - - messagebox.showinfo("成功", "配置已保存") - dialog.destroy() - self.refresh_tools() - - except Exception as e: - messagebox.showerror("错误", f"保存配置失败: {str(e)}") - - # 按钮区域 - btn_frame = ttk.Frame(dialog) - btn_frame.pack(fill=tk.X, padx=10, pady=10) - - ttk.Button(btn_frame, text="保存", command=save_config, width=10).pack(side=tk.LEFT, padx=5) - ttk.Button(btn_frame, text="取消", command=dialog.destroy, width=10).pack(side=tk.LEFT, padx=5) - - def edit_advanced_config(self): - """打开高级配置对话框""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("提示", "请先选择一个工具") - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if not tool: - return - - # 打开高级配置对话框 - dialog = NPSPluginConfigDialog(self.parent, tool, self.config_manager) - result = dialog.show() - - # 如果配置被修改,刷新工具列表 - if result is not None: - # 更新工具的enabled状态 - if 'enabled' in result: - tool.enabled = result['enabled'] - self._save_tool_enabled_state(tool_id, result['enabled']) - - self.refresh_tools() - messagebox.showinfo("成功", f"工具 {tool.name} 的配置已更新") - - def enable_tool(self): - """启用工具""" - selection = self.tree.selection() - if not selection: - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if tool: - tool.enabled = True - self._save_tool_enabled_state(tool_id, True) - self.refresh_tools() - - def disable_tool(self): - """禁用工具""" - selection = self.tree.selection() - if not selection: - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if tool: - tool.enabled = False - self._save_tool_enabled_state(tool_id, False) - self.refresh_tools() - - def _save_tool_enabled_state(self, tool_id: str, enabled: bool): - """保存工具启用状态到.NPS文件""" - try: - nps_path = os.path.join(self.registry.tools_dir, f"{tool_id}.NPS") - if os.path.exists(nps_path): - with open(nps_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - config['enabled'] = enabled - - with open(nps_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=4) - except Exception as e: - messagebox.showerror("错误", f"保存状态失败: {str(e)}") - - def test_tool(self): - """测试工具""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("提示", "请先选择一个工具") - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - tool = self.registry.get_tool(tool_id) - if not tool: - return - - # 创建测试对话框 - dialog = tk.Toplevel(self.parent) - dialog.title(f"测试工具 - {tool.name}") - dialog.geometry("500x400") - dialog.transient(self.parent) - - # 输入区域 - input_frame = ttk.LabelFrame(dialog, text="测试输入", padding=10) - input_frame.pack(fill=tk.X, padx=10, pady=10) - - ttk.Label(input_frame, text="模拟用户输入:").pack(anchor=tk.W) - test_input = ttk.Entry(input_frame, width=60) - test_input.pack(fill=tk.X, pady=5) - - # 根据工具的关键词生成默认测试输入 - default_test = "" - if tool.keywords: - # 使用第一个关键词构建测试输入 - default_test = f"请告诉我关于{tool.keywords[0]}的信息" - else: - default_test = f"测试{tool.name}" - test_input.insert(0, default_test) - - # 结果区域 - result_frame = ttk.LabelFrame(dialog, text="执行结果", padding=10) - result_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - result_text = scrolledtext.ScrolledText( - result_frame, - wrap=tk.WORD, - font=("Consolas", 10), - bg="#f9f9f9" - ) - result_text.pack(fill=tk.BOTH, expand=True) - - def run_test(): - """执行测试""" - user_input = test_input.get() - - result_text.config(state=tk.NORMAL) - result_text.delete("1.0", tk.END) - - try: - result = tool.execute({'user_input': user_input}) - - if result['success']: - result_text.insert(tk.END, "✓ 执行成功\n\n") - result_text.insert(tk.END, f"工具: {result['tool_name']}\n") - result_text.insert(tk.END, f"工具ID: {result['tool_id']}\n\n") - result_text.insert(tk.END, "返回结果:\n") - result_text.insert(tk.END, json.dumps(result['result'], ensure_ascii=False, indent=2)) - else: - result_text.insert(tk.END, "✗ 执行失败\n\n") - result_text.insert(tk.END, f"错误: {result.get('error', '未知错误')}") - - except Exception as e: - result_text.insert(tk.END, f"✗ 执行异常\n\n错误: {str(e)}") - - result_text.config(state=tk.DISABLED) - - # 按钮 - btn_frame = ttk.Frame(dialog) - btn_frame.pack(fill=tk.X, padx=10, pady=10) - - ttk.Button(btn_frame, text="🧪 执行测试", command=run_test, width=15).pack(side=tk.LEFT, padx=5) - ttk.Button(btn_frame, text="关闭", command=dialog.destroy, width=10).pack(side=tk.LEFT, padx=5) - - def export_tool(self): - """导出工具""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("提示", "请先选择一个工具") - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - - # 选择导出目录 - export_dir = filedialog.askdirectory(title="选择导出目录") - if not export_dir: - return - - try: - # 复制.NPS文件和.py文件 - import shutil - - nps_path = os.path.join(self.registry.tools_dir, f"{tool_id}.NPS") - py_path = os.path.join(self.registry.tools_dir, f"{tool_id}.py") - - if os.path.exists(nps_path): - shutil.copy(nps_path, os.path.join(export_dir, f"{tool_id}.NPS")) - - if os.path.exists(py_path): - shutil.copy(py_path, os.path.join(export_dir, f"{tool_id}.py")) - - messagebox.showinfo("成功", f"工具已导出到: {export_dir}") - - except Exception as e: - messagebox.showerror("错误", f"导出失败: {str(e)}") - - def delete_tool(self): - """删除工具""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("提示", "请先选择一个工具") - return - - item = self.tree.item(selection[0]) - tool_id = item['values'][0] - tool_name = item['values'][1] - - # 确认删除 - if not messagebox.askyesno("确认删除", f"确定要删除工具「{tool_name}」吗?\n\n这将删除工具的配置文件和代码文件。"): - return - - try: - # 删除文件 - nps_path = os.path.join(self.registry.tools_dir, f"{tool_id}.NPS") - py_path = os.path.join(self.registry.tools_dir, f"{tool_id}.py") - - if os.path.exists(nps_path): - os.remove(nps_path) - - if os.path.exists(py_path): - os.remove(py_path) - - # 从注册表中删除 - self.registry.unregister_tool(tool_id) - - messagebox.showinfo("成功", "工具已删除") - self.refresh_tools() - - except Exception as e: - messagebox.showerror("错误", f"删除失败: {str(e)}") - - def create_new_tool(self): - """创建新工具""" - # 创建向导对话框 - dialog = tk.Toplevel(self.parent) - dialog.title("创建新工具") - dialog.geometry("600x600") - dialog.transient(self.parent) - dialog.grab_set() - - # 表单区域 - form_frame = ttk.Frame(dialog, padding=10) - form_frame.pack(fill=tk.BOTH, expand=True) - - # 工具ID - ttk.Label(form_frame, text="工具ID (唯一标识):").grid(row=0, column=0, sticky=tk.W, pady=5) - id_var = tk.StringVar() - id_entry = ttk.Entry(form_frame, textvariable=id_var, width=40) - id_entry.grid(row=0, column=1, pady=5, sticky=tk.W) - - # 名称 - ttk.Label(form_frame, text="名称:").grid(row=1, column=0, sticky=tk.W, pady=5) - name_var = tk.StringVar() - name_entry = ttk.Entry(form_frame, textvariable=name_var, width=40) - name_entry.grid(row=1, column=1, pady=5, sticky=tk.W) - - # 版本 - ttk.Label(form_frame, text="版本:").grid(row=2, column=0, sticky=tk.W, pady=5) - version_var = tk.StringVar(value="1.0.0") - version_entry = ttk.Entry(form_frame, textvariable=version_var, width=40) - version_entry.grid(row=2, column=1, pady=5, sticky=tk.W) - - # 作者 - ttk.Label(form_frame, text="作者:").grid(row=3, column=0, sticky=tk.W, pady=5) - author_var = tk.StringVar(value="Neo Agent") - author_entry = ttk.Entry(form_frame, textvariable=author_var, width=40) - author_entry.grid(row=3, column=1, pady=5, sticky=tk.W) - - # 描述 - ttk.Label(form_frame, text="功能描述:").grid(row=4, column=0, sticky=tk.NW, pady=5) - desc_text = tk.Text(form_frame, width=40, height=4) - desc_text.grid(row=4, column=1, pady=5, sticky=tk.W) - - # 关键词 - ttk.Label(form_frame, text="触发关键词:").grid(row=5, column=0, sticky=tk.NW, pady=5) - keywords_text = tk.Text(form_frame, width=40, height=3) - keywords_text.grid(row=5, column=1, pady=5, sticky=tk.W) - ttk.Label(form_frame, text="(逗号分隔)", font=("微软雅黑", 8), foreground="gray").grid(row=5, column=1, sticky=tk.E, pady=5) - - # 函数名 - ttk.Label(form_frame, text="执行函数名:").grid(row=6, column=0, sticky=tk.W, pady=5) - func_var = tk.StringVar(value="execute") - func_entry = ttk.Entry(form_frame, textvariable=func_var, width=40) - func_entry.grid(row=6, column=1, pady=5, sticky=tk.W) - - # 代码模板 - ttk.Label(form_frame, text="代码模板:").grid(row=7, column=0, sticky=tk.NW, pady=5) - code_text = scrolledtext.ScrolledText(form_frame, width=50, height=10, font=("Consolas", 9)) - code_text.grid(row=7, column=1, pady=5, sticky=tk.W) - - # 默认代码模板 - default_code = '''""" -{name} - NPS工具模块 -{description} -""" - -from typing import Dict, Any - - -def {function}(context: Dict[str, Any] = None) -> Dict[str, Any]: - """ - 工具执行函数 - - Args: - context: 执行上下文,包含user_input等信息 - - Returns: - 包含context字段的结果字典 - """ - user_input = context.get('user_input', '') if context else '' - - # TODO: 在这里实现工具逻辑 - result = "工具执行结果" - - return {{ - 'context': result - }} -''' - code_text.insert("1.0", default_code) - - def create_tool(): - """创建工具""" - tool_id = id_var.get().strip() - name = name_var.get().strip() - version = version_var.get().strip() - author = author_var.get().strip() - description = desc_text.get("1.0", tk.END).strip() - keywords_str = keywords_text.get("1.0", tk.END).strip() - function = func_var.get().strip() - code = code_text.get("1.0", tk.END) - - # 验证必填字段 - if not tool_id: - messagebox.showwarning("提示", "请输入工具ID") - return - if not name: - messagebox.showwarning("提示", "请输入工具名称") - return - if not description: - messagebox.showwarning("提示", "请输入功能描述") - return - if not function: - messagebox.showwarning("提示", "请输入执行函数名") - return - - # 检查ID是否已存在 - if self.registry.get_tool(tool_id): - messagebox.showwarning("提示", f"工具ID「{tool_id}」已存在") - return - - try: - # 解析关键词 - keywords = [k.strip() for k in keywords_str.split(',') if k.strip()] - - # 创建.NPS配置文件 - config = { - 'tool_id': tool_id, - 'name': name, - 'description': description, - 'module': tool_id, - 'function': function, - 'version': version, - 'author': author, - 'keywords': keywords, - 'enabled': True - } - - nps_path = os.path.join(self.registry.tools_dir, f"{tool_id}.NPS") - with open(nps_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=4) - - # 创建Python模块文件 - # 替换模板中的占位符 - final_code = code.format( - name=name, - description=description, - function=function - ) - - py_path = os.path.join(self.registry.tools_dir, f"{tool_id}.py") - with open(py_path, 'w', encoding='utf-8') as f: - f.write(final_code) - - # 重新扫描注册工具 - self.registry.scan_and_register() - - messagebox.showinfo("成功", f"工具「{name}」创建成功!\n\n请编辑 {py_path} 实现工具逻辑。") - dialog.destroy() - self.refresh_tools() - - except Exception as e: - messagebox.showerror("错误", f"创建工具失败: {str(e)}") - - # 按钮区域 - btn_frame = ttk.Frame(dialog) - btn_frame.pack(fill=tk.X, padx=10, pady=10) - - ttk.Button(btn_frame, text="创建", command=create_tool, width=10).pack(side=tk.LEFT, padx=5) - ttk.Button(btn_frame, text="取消", command=dialog.destroy, width=10).pack(side=tk.LEFT, padx=5) - - def import_tool(self): - """导入工具""" - # 选择.NPS文件 - nps_path = filedialog.askopenfilename( - title="选择工具配置文件", - filetypes=[("NPS配置文件", "*.NPS"), ("所有文件", "*.*")] - ) - if not nps_path: - return - - try: - # 读取配置 - with open(nps_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - tool_id = config.get('tool_id') - if not tool_id: - messagebox.showerror("错误", "无效的NPS配置文件:缺少tool_id") - return - - # 检查是否存在对应的Python文件 - source_dir = os.path.dirname(nps_path) - py_path = os.path.join(source_dir, f"{config.get('module', tool_id)}.py") - - if not os.path.exists(py_path): - messagebox.showwarning("警告", f"未找到对应的Python模块文件: {py_path}\n\n将只导入配置文件。") - - # 复制文件到工具目录 - import shutil - - dest_nps = os.path.join(self.registry.tools_dir, f"{tool_id}.NPS") - shutil.copy(nps_path, dest_nps) - - if os.path.exists(py_path): - dest_py = os.path.join(self.registry.tools_dir, f"{tool_id}.py") - shutil.copy(py_path, dest_py) - - # 重新扫描注册 - self.registry.scan_and_register() - - messagebox.showinfo("成功", f"工具「{config.get('name', tool_id)}」导入成功!") - self.refresh_tools() - - except json.JSONDecodeError: - messagebox.showerror("错误", "无效的JSON格式") - except Exception as e: - messagebox.showerror("错误", f"导入失败: {str(e)}") - - def show_statistics(self): - """显示统计信息""" - stats = self.registry.get_statistics() - - msg = f"""NPS工具系统统计信息 -═══════════════════════════════ - -总工具数: {stats['total_tools']} -已启用: {stats['enabled_tools']} -已禁用: {stats['disabled_tools']} - -工具目录: {self.registry.tools_dir} - -已注册工具列表: -""" - for tool_id in stats['tool_ids']: - tool = self.registry.get_tool(tool_id) - if tool: - status = "✓" if tool.enabled else "✗" - msg += f" {status} {tool.name} (v{tool.version})\n" - - messagebox.showinfo("NPS统计信息", msg) - - def toggle_auto_refresh(self): - """切换自动刷新""" - self.auto_refresh_enabled = not self.auto_refresh_enabled - - if self.auto_refresh_enabled: - self.auto_refresh_btn.config(text="⏸ 暂停刷新") - self.start_auto_refresh() - else: - self.auto_refresh_btn.config(text="▶ 继续刷新") - self.stop_auto_refresh() - - def start_auto_refresh(self): - """启动自动刷新""" - if self.auto_refresh_enabled: - self.refresh_tools() - self.refresh_job = self.parent.after(self.refresh_interval, self.start_auto_refresh) - - def stop_auto_refresh(self): - """停止自动刷新""" - if self.refresh_job: - self.parent.after_cancel(self.refresh_job) - self.refresh_job = None diff --git a/src/gui/schedule_gui.py b/src/gui/schedule_gui.py deleted file mode 100644 index d1b1049..0000000 --- a/src/gui/schedule_gui.py +++ /dev/null @@ -1,738 +0,0 @@ -""" -日程管理GUI模块 -独立的日程管理界面 -""" - -import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox, simpledialog -from typing import Dict, Any, List -from datetime import datetime, timedelta -from src.core.schedule_manager import ScheduleManager, ScheduleType, SchedulePriority -from src.core.database_manager import DatabaseManager - - -class ScheduleManagerGUI: - """ - 日程管理GUI界面 - 提供独立的日程管理功能 - """ - - def __init__(self, parent_frame, db_manager: DatabaseManager = None): - """ - 初始化日程管理GUI - - Args: - parent_frame: 父容器 - db_manager: 数据库管理器实例 - """ - self.parent = parent_frame - self.db = db_manager or DatabaseManager() - - # 获取或创建schedule_manager - if hasattr(self.db, 'schedule_manager'): - self.schedule_manager = self.db.schedule_manager - else: - self.schedule_manager = ScheduleManager(self.db) - - # 自动刷新相关 - self.auto_refresh_enabled = True - self.refresh_interval = 3000 # 3秒刷新一次 - self.refresh_job = None - - # 创建界面 - self.create_widgets() - - # 首次刷新数据 - self.refresh_schedules() - - # 启动自动刷新 - self.start_auto_refresh() - - # 绑定窗口关闭事件 - self.parent.bind('', self.on_destroy) - - def on_destroy(self, event=None): - """窗口销毁时的清理工作""" - self.stop_auto_refresh() - - def create_widgets(self): - """创建所有GUI组件""" - # 顶部工具栏 - toolbar = ttk.Frame(self.parent) - toolbar.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(toolbar, text="📅 日程管理", font=("微软雅黑", 12, "bold")).pack(side=tk.LEFT, padx=5) - - ttk.Button(toolbar, text="➕ 添加日程", command=self.add_schedule, width=12).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="✏ 编辑", command=self.edit_schedule, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🗑 删除", command=self.delete_schedule, width=10).pack(side=tk.LEFT, padx=2) - ttk.Button(toolbar, text="🔄 刷新", command=self.refresh_schedules, width=10).pack(side=tk.LEFT, padx=2) - - # 自动刷新开关 - self.auto_refresh_btn = ttk.Button(toolbar, text="⏸ 暂停刷新", command=self.toggle_auto_refresh, width=12) - self.auto_refresh_btn.pack(side=tk.LEFT, padx=2) - - # 待确认日程提示 - self.pending_label = ttk.Label(toolbar, text="", font=("微软雅黑", 9), foreground="orange") - self.pending_label.pack(side=tk.RIGHT, padx=10) - - # 分割线 - ttk.Separator(self.parent, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=5, pady=2) - - # 筛选器区域 - filter_frame = ttk.Frame(self.parent) - filter_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(filter_frame, text="类型:").pack(side=tk.LEFT, padx=(5, 2)) - self.type_var = tk.StringVar(value="全部") - type_combo = ttk.Combobox(filter_frame, textvariable=self.type_var, width=12, state="readonly") - type_combo['values'] = ['全部', '周期日程', '预约日程', '临时日程'] - type_combo.pack(side=tk.LEFT, padx=2) - type_combo.bind('<>', lambda e: self.refresh_schedules()) - - ttk.Label(filter_frame, text="日期:").pack(side=tk.LEFT, padx=(15, 2)) - self.date_var = tk.StringVar(value="今天") - date_combo = ttk.Combobox(filter_frame, textvariable=self.date_var, width=12, state="readonly") - date_combo['values'] = ['今天', '明天', '本周', '全部'] - date_combo.pack(side=tk.LEFT, padx=2) - date_combo.bind('<>', lambda e: self.refresh_schedules()) - - ttk.Label(filter_frame, text="状态:").pack(side=tk.LEFT, padx=(15, 2)) - self.status_var = tk.StringVar(value="全部") - status_combo = ttk.Combobox(filter_frame, textvariable=self.status_var, width=12, state="readonly") - status_combo['values'] = ['全部', '待确认', '已确认', '不需要'] - status_combo.pack(side=tk.LEFT, padx=2) - status_combo.bind('<>', lambda e: self.refresh_schedules()) - - # 日程列表区域 - list_frame = ttk.Frame(self.parent) - list_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # 滚动条 - scrollbar_y = ttk.Scrollbar(list_frame, orient=tk.VERTICAL) - scrollbar_x = ttk.Scrollbar(list_frame, orient=tk.HORIZONTAL) - - # Treeview - self.tree = ttk.Treeview( - list_frame, - columns=("title", "type", "priority", "time", "status", "queryable", "description"), - show="headings", - yscrollcommand=scrollbar_y.set, - xscrollcommand=scrollbar_x.set - ) - - # 配置列 - self.tree.heading("title", text="标题") - self.tree.heading("type", text="类型") - self.tree.heading("priority", text="优先级") - self.tree.heading("time", text="时间") - self.tree.heading("status", text="协作状态") - self.tree.heading("queryable", text="可查询") - self.tree.heading("description", text="描述") - - self.tree.column("title", width=180, minwidth=150, stretch=False) - self.tree.column("type", width=80, minwidth=70, stretch=False) - self.tree.column("priority", width=70, minwidth=60, stretch=False) - self.tree.column("time", width=280, minwidth=200, stretch=False) - self.tree.column("status", width=90, minwidth=80, stretch=False) - self.tree.column("queryable", width=70, minwidth=60, stretch=False) - self.tree.column("description", width=250, minwidth=150, stretch=True) - - scrollbar_y.config(command=self.tree.yview) - scrollbar_x.config(command=self.tree.xview) - - # 布局 - self.tree.grid(row=0, column=0, sticky="nsew") - scrollbar_y.grid(row=0, column=1, sticky="ns") - scrollbar_x.grid(row=1, column=0, sticky="ew") - - list_frame.grid_rowconfigure(0, weight=1) - list_frame.grid_columnconfigure(0, weight=1) - - # 双击编辑 - self.tree.bind("", lambda e: self.edit_schedule()) - - # 右键菜单 - self.context_menu = tk.Menu(self.tree, tearoff=0) - self.context_menu.add_command(label="查看详情", command=self.view_schedule_details) - self.context_menu.add_command(label="编辑", command=self.edit_schedule) - self.context_menu.add_command(label="删除", command=self.delete_schedule) - self.context_menu.add_separator() - self.context_menu.add_command(label="✓ 确认协作", command=self.confirm_collaboration) - self.context_menu.add_command(label="✗ 拒绝协作", command=self.reject_collaboration) - - self.tree.bind("", self.show_context_menu) - - # 底部统计区域 - stats_frame = ttk.Frame(self.parent) - stats_frame.pack(fill=tk.X, padx=5, pady=5) - - self.stats_label = ttk.Label(stats_frame, text="", font=("微软雅黑", 9)) - self.stats_label.pack(side=tk.LEFT, padx=5) - - self.last_refresh_label = ttk.Label(stats_frame, text="", font=("微软雅黑", 8), foreground="gray") - self.last_refresh_label.pack(side=tk.RIGHT, padx=5) - - def refresh_schedules(self): - """刷新日程列表""" - # 清空现有内容 - for item in self.tree.get_children(): - self.tree.delete(item) - - try: - # 根据日期筛选确定时间范围 - date_filter = self.date_var.get() - today = datetime.now().date() - - if date_filter == "今天": - start_date = today - end_date = today - elif date_filter == "明天": - start_date = today + timedelta(days=1) - end_date = today + timedelta(days=1) - elif date_filter == "本周": - days_since_monday = today.weekday() - start_date = today - timedelta(days=days_since_monday) - end_date = start_date + timedelta(days=6) - else: # 全部 - start_date = today - timedelta(days=365) - end_date = today + timedelta(days=365) - - start_time = datetime.combine(start_date, datetime.min.time()).isoformat() - end_time = datetime.combine(end_date, datetime.max.time()).isoformat() - - # 获取日程 - schedules = self.schedule_manager.get_schedules_by_time_range( - start_time, end_time, queryable_only=False, active_only=True - ) - - # 根据类型筛选 - type_filter = self.type_var.get() - type_map = { - '周期日程': 'recurring', - '预约日程': 'appointment', - '临时日程': 'temporary' - } - - if type_filter != "全部": - filter_type = type_map.get(type_filter) - schedules = [s for s in schedules if s.schedule_type.value == filter_type] - - # 根据状态筛选 - status_filter = self.status_var.get() - status_map = { - '待确认': 'pending', - '已确认': 'confirmed', - '不需要': 'not_required' - } - - if status_filter != "全部": - filter_status = status_map.get(status_filter) - schedules = [s for s in schedules if s.collaboration_status.value == filter_status] - - # 类型映射(显示用) - type_display_map = { - 'recurring': '周期', - 'appointment': '预约', - 'temporary': '临时' - } - - # 优先级映射 - priority_display_map = { - 1: '低', - 2: '中', - 3: '高', - 4: '关键' - } - - # 协作状态映射 - collab_status_map = { - 'not_required': '不需要', - 'pending': '待确认', - 'confirmed': '已确认', - 'rejected': '已拒绝' - } - - # 填充数据 - pending_count = 0 - for schedule in schedules: - start_dt = datetime.fromisoformat(schedule.start_time) - end_dt = datetime.fromisoformat(schedule.end_time) - time_str = f"{start_dt.strftime('%Y-%m-%d %H:%M')} - {end_dt.strftime('%H:%M')}" - - schedule_type = type_display_map.get(schedule.schedule_type.value, schedule.schedule_type.value) - priority = priority_display_map.get(schedule.priority.value, str(schedule.priority.value)) - status = collab_status_map.get(schedule.collaboration_status.value, schedule.collaboration_status.value) - queryable = "是" if schedule.is_queryable else "否" - description = schedule.description[:50] + "..." if len(schedule.description) > 50 else schedule.description - - # 统计待确认数量 - if schedule.collaboration_status.value == 'pending': - pending_count += 1 - - # 添加颜色标记 - tags = [] - if schedule.collaboration_status.value == 'pending': - tags.append('pending') - elif schedule.priority.value >= 3: - tags.append('high_priority') - - self.tree.insert( - "", - tk.END, - values=(schedule.title, schedule_type, priority, time_str, status, queryable, description), - tags=(schedule.schedule_id,) + tuple(tags) - ) - - # 配置标签颜色 - self.tree.tag_configure('pending', foreground='orange', font=("微软雅黑", 9, "bold")) - self.tree.tag_configure('high_priority', foreground='red') - - # 更新待确认提示 - if pending_count > 0: - self.pending_label.config(text=f"⚠️ 有 {pending_count} 个待确认的协作日程") - else: - self.pending_label.config(text="") - - # 更新统计信息 - stats = self.schedule_manager.get_statistics() - stats_text = (f"总计: {len(schedules)} 个日程 (显示中) | " - f"全部: {stats['total_schedules']} | " - f"周期: {stats['recurring']} | " - f"预约: {stats['appointments']} | " - f"临时: {stats['temporary']} | " - f"待确认: {stats['pending_collaboration']}") - self.stats_label.config(text=stats_text) - - # 更新刷新时间 - current_time = datetime.now().strftime("%H:%M:%S") - self.last_refresh_label.config(text=f"最后刷新: {current_time}") - - except Exception as e: - messagebox.showerror("错误", f"刷新日程列表失败:\n{str(e)}") - - def add_schedule(self): - """添加新日程""" - dialog = tk.Toplevel(self.parent) - dialog.title("添加日程") - dialog.geometry("550x500") - dialog.transient(self.parent) - dialog.grab_set() - - # 创建表单 - form_frame = ttk.Frame(dialog, padding=20) - form_frame.pack(fill=tk.BOTH, expand=True) - - # 标题 - ttk.Label(form_frame, text="标题:", font=("微软雅黑", 9)).grid(row=0, column=0, sticky=tk.W, pady=5) - title_entry = ttk.Entry(form_frame, width=45, font=("微软雅黑", 9)) - title_entry.grid(row=0, column=1, pady=5, sticky=tk.EW) - - # 描述 - ttk.Label(form_frame, text="描述:", font=("微软雅黑", 9)).grid(row=1, column=0, sticky=tk.W, pady=5) - description_text = tk.Text(form_frame, width=45, height=4, font=("微软雅黑", 9)) - description_text.grid(row=1, column=1, pady=5, sticky=tk.EW) - - # 类型 - ttk.Label(form_frame, text="类型:", font=("微软雅黑", 9)).grid(row=2, column=0, sticky=tk.W, pady=5) - type_var = tk.StringVar(value="预约日程") - type_combo = ttk.Combobox(form_frame, textvariable=type_var, width=43, state="readonly", font=("微软雅黑", 9)) - type_combo['values'] = ['周期日程', '预约日程', '临时日程'] - type_combo.grid(row=2, column=1, pady=5, sticky=tk.EW) - - # 优先级 - ttk.Label(form_frame, text="优先级:", font=("微软雅黑", 9)).grid(row=3, column=0, sticky=tk.W, pady=5) - priority_var = tk.StringVar(value="中") - priority_combo = ttk.Combobox(form_frame, textvariable=priority_var, width=43, state="readonly", font=("微软雅黑", 9)) - priority_combo['values'] = ['低', '中', '高', '关键'] - priority_combo.grid(row=3, column=1, pady=5, sticky=tk.EW) - - # 开始时间 - ttk.Label(form_frame, text="开始时间:", font=("微软雅黑", 9)).grid(row=4, column=0, sticky=tk.W, pady=5) - start_frame = ttk.Frame(form_frame) - start_frame.grid(row=4, column=1, pady=5, sticky=tk.EW) - - now = datetime.now() - start_date_entry = ttk.Entry(start_frame, width=14, font=("微软雅黑", 9)) - start_date_entry.insert(0, now.strftime("%Y-%m-%d")) - start_date_entry.pack(side=tk.LEFT, padx=2) - - start_time_entry = ttk.Entry(start_frame, width=10, font=("微软雅黑", 9)) - start_time_entry.insert(0, "09:00") - start_time_entry.pack(side=tk.LEFT, padx=2) - - # 结束时间 - ttk.Label(form_frame, text="结束时间:", font=("微软雅黑", 9)).grid(row=5, column=0, sticky=tk.W, pady=5) - end_frame = ttk.Frame(form_frame) - end_frame.grid(row=5, column=1, pady=5, sticky=tk.EW) - - end_date_entry = ttk.Entry(end_frame, width=14, font=("微软雅黑", 9)) - end_date_entry.insert(0, now.strftime("%Y-%m-%d")) - end_date_entry.pack(side=tk.LEFT, padx=2) - - end_time_entry = ttk.Entry(end_frame, width=10, font=("微软雅黑", 9)) - end_time_entry.insert(0, "11:00") - end_time_entry.pack(side=tk.LEFT, padx=2) - - # 涉及用户参与 - involves_user_var = tk.BooleanVar(value=False) - ttk.Checkbutton(form_frame, text="涉及用户参与(需要确认)", variable=involves_user_var, - style="TCheckbutton").grid(row=6, column=1, pady=5, sticky=tk.W) - - form_frame.grid_columnconfigure(1, weight=1) - - # 按钮区域 - button_frame = ttk.Frame(dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - def save_schedule(): - try: - title = title_entry.get().strip() - if not title: - messagebox.showwarning("警告", "请输入标题") - return - - description = description_text.get("1.0", tk.END).strip() - - # 类型映射 - type_map = { - '周期日程': ScheduleType.RECURRING, - '预约日程': ScheduleType.APPOINTMENT, - '临时日程': ScheduleType.TEMPORARY - } - schedule_type = type_map[type_var.get()] - - # 优先级映射 - priority_map = { - '低': SchedulePriority.LOW, - '中': SchedulePriority.MEDIUM, - '高': SchedulePriority.HIGH, - '关键': SchedulePriority.CRITICAL - } - priority = priority_map[priority_var.get()] - - # 解析时间 - start_date = start_date_entry.get().strip() - start_time = start_time_entry.get().strip() - end_date = end_date_entry.get().strip() - end_time = end_time_entry.get().strip() - - start_datetime = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M") - end_datetime = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M") - - # 创建日程 - success, schedule, message = self.schedule_manager.create_schedule( - title=title, - description=description, - schedule_type=schedule_type, - start_time=start_datetime.isoformat(), - end_time=end_datetime.isoformat(), - priority=priority, - involves_user=involves_user_var.get() if schedule_type == ScheduleType.TEMPORARY else False, - check_conflict=True - ) - - if success: - messagebox.showinfo("成功", message) - dialog.destroy() - self.refresh_schedules() - else: - messagebox.showerror("错误", message) - - except ValueError as e: - messagebox.showerror("错误", f"时间格式不正确:\n{str(e)}") - except Exception as e: - messagebox.showerror("错误", f"创建日程失败:\n{str(e)}") - - ttk.Button(button_frame, text="💾 保存", command=save_schedule, width=12).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="✗ 取消", command=dialog.destroy, width=12).pack(side=tk.LEFT, padx=5) - - def edit_schedule(self): - """编辑选中的日程""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("警告", "请选择要编辑的日程") - return - - item = selection[0] - schedule_id = self.tree.item(item)['tags'][0] - - try: - schedule = self.schedule_manager.get_schedule(schedule_id) - if not schedule: - messagebox.showerror("错误", "日程不存在") - return - - # 创建编辑对话框(与添加类似,但预填充数据) - dialog = tk.Toplevel(self.parent) - dialog.title("编辑日程") - dialog.geometry("550x450") - dialog.transient(self.parent) - dialog.grab_set() - - form_frame = ttk.Frame(dialog, padding=20) - form_frame.pack(fill=tk.BOTH, expand=True) - - # 标题 - ttk.Label(form_frame, text="标题:", font=("微软雅黑", 9)).grid(row=0, column=0, sticky=tk.W, pady=5) - title_entry = ttk.Entry(form_frame, width=45, font=("微软雅黑", 9)) - title_entry.insert(0, schedule.title) - title_entry.grid(row=0, column=1, pady=5, sticky=tk.EW) - - # 描述 - ttk.Label(form_frame, text="描述:", font=("微软雅黑", 9)).grid(row=1, column=0, sticky=tk.W, pady=5) - description_text = tk.Text(form_frame, width=45, height=4, font=("微软雅黑", 9)) - description_text.insert("1.0", schedule.description) - description_text.grid(row=1, column=1, pady=5, sticky=tk.EW) - - # 优先级 - ttk.Label(form_frame, text="优先级:", font=("微软雅黑", 9)).grid(row=2, column=0, sticky=tk.W, pady=5) - priority_map = {1: '低', 2: '中', 3: '高', 4: '关键'} - priority_var = tk.StringVar(value=priority_map[schedule.priority.value]) - priority_combo = ttk.Combobox(form_frame, textvariable=priority_var, width=43, state="readonly", font=("微软雅黑", 9)) - priority_combo['values'] = ['低', '中', '高', '关键'] - priority_combo.grid(row=2, column=1, pady=5, sticky=tk.EW) - - # 开始时间 - start_dt = datetime.fromisoformat(schedule.start_time) - end_dt = datetime.fromisoformat(schedule.end_time) - - ttk.Label(form_frame, text="开始时间:", font=("微软雅黑", 9)).grid(row=3, column=0, sticky=tk.W, pady=5) - start_frame = ttk.Frame(form_frame) - start_frame.grid(row=3, column=1, pady=5, sticky=tk.EW) - - start_date_entry = ttk.Entry(start_frame, width=14, font=("微软雅黑", 9)) - start_date_entry.insert(0, start_dt.strftime("%Y-%m-%d")) - start_date_entry.pack(side=tk.LEFT, padx=2) - - start_time_entry = ttk.Entry(start_frame, width=10, font=("微软雅黑", 9)) - start_time_entry.insert(0, start_dt.strftime("%H:%M")) - start_time_entry.pack(side=tk.LEFT, padx=2) - - # 结束时间 - ttk.Label(form_frame, text="结束时间:", font=("微软雅黑", 9)).grid(row=4, column=0, sticky=tk.W, pady=5) - end_frame = ttk.Frame(form_frame) - end_frame.grid(row=4, column=1, pady=5, sticky=tk.EW) - - end_date_entry = ttk.Entry(end_frame, width=14, font=("微软雅黑", 9)) - end_date_entry.insert(0, end_dt.strftime("%Y-%m-%d")) - end_date_entry.pack(side=tk.LEFT, padx=2) - - end_time_entry = ttk.Entry(end_frame, width=10, font=("微软雅黑", 9)) - end_time_entry.insert(0, end_dt.strftime("%H:%M")) - end_time_entry.pack(side=tk.LEFT, padx=2) - - form_frame.grid_columnconfigure(1, weight=1) - - # 按钮区域 - button_frame = ttk.Frame(dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - def save_changes(): - try: - title = title_entry.get().strip() - description = description_text.get("1.0", tk.END).strip() - - # 优先级映射 - priority_reverse_map = { - '低': SchedulePriority.LOW, - '中': SchedulePriority.MEDIUM, - '高': SchedulePriority.HIGH, - '关键': SchedulePriority.CRITICAL - } - priority = priority_reverse_map[priority_var.get()] - - # 解析时间 - start_date = start_date_entry.get().strip() - start_time = start_time_entry.get().strip() - end_date = end_date_entry.get().strip() - end_time = end_time_entry.get().strip() - - start_datetime = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M") - end_datetime = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M") - - # 更新日程 - success = self.schedule_manager.update_schedule( - schedule_id, - title=title, - description=description, - priority=priority.value, - start_time=start_datetime.isoformat(), - end_time=end_datetime.isoformat() - ) - - if success: - messagebox.showinfo("成功", "日程更新成功") - dialog.destroy() - self.refresh_schedules() - else: - messagebox.showerror("错误", "更新日程失败") - - except ValueError as e: - messagebox.showerror("错误", f"时间格式不正确:\n{str(e)}") - except Exception as e: - messagebox.showerror("错误", f"更新日程失败:\n{str(e)}") - - ttk.Button(button_frame, text="💾 保存", command=save_changes, width=12).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="✗ 取消", command=dialog.destroy, width=12).pack(side=tk.LEFT, padx=5) - - except Exception as e: - messagebox.showerror("错误", f"加载日程失败:\n{str(e)}") - - def delete_schedule(self): - """删除选中的日程""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("警告", "请选择要删除的日程") - return - - item = selection[0] - values = self.tree.item(item)['values'] - schedule_id = self.tree.item(item)['tags'][0] - - if not messagebox.askyesno("确认", f"确定要删除日程「{values[0]}」吗?\n\n这是软删除操作,不会永久删除数据。"): - return - - try: - success = self.schedule_manager.delete_schedule(schedule_id) - if success: - messagebox.showinfo("成功", "日程已删除") - self.refresh_schedules() - else: - messagebox.showerror("错误", "删除日程失败") - - except Exception as e: - messagebox.showerror("错误", f"删除日程失败:\n{str(e)}") - - def view_schedule_details(self): - """查看日程详情""" - selection = self.tree.selection() - if not selection: - return - - item = selection[0] - schedule_id = self.tree.item(item)['tags'][0] - - try: - schedule = self.schedule_manager.get_schedule(schedule_id) - if not schedule: - messagebox.showerror("错误", "日程不存在") - return - - # 创建详情对话框 - dialog = tk.Toplevel(self.parent) - dialog.title(f"日程详情 - {schedule.title}") - dialog.geometry("500x400") - dialog.transient(self.parent) - - # 详情文本 - text_frame = ttk.Frame(dialog, padding=10) - text_frame.pack(fill=tk.BOTH, expand=True) - - text = scrolledtext.ScrolledText(text_frame, wrap=tk.WORD, font=("微软雅黑", 10)) - text.pack(fill=tk.BOTH, expand=True) - - # 格式化详情信息 - type_map = {'recurring': '周期日程', 'appointment': '预约日程', 'temporary': '临时日程'} - priority_map = {1: '低', 2: '中', 3: '高', 4: '关键'} - status_map = {'not_required': '不需要', 'pending': '待确认', 'confirmed': '已确认', 'rejected': '已拒绝'} - - details = f""" -📅 日程详情 - -标题: {schedule.title} -类型: {type_map.get(schedule.schedule_type.value, schedule.schedule_type.value)} -优先级: {priority_map.get(schedule.priority.value, schedule.priority.value)} - -⏰ 时间安排 -开始时间: {datetime.fromisoformat(schedule.start_time).strftime('%Y-%m-%d %H:%M')} -结束时间: {datetime.fromisoformat(schedule.end_time).strftime('%Y-%m-%d %H:%M')} - -📝 描述 -{schedule.description if schedule.description else '(无)'} - -🤝 协作信息 -协作状态: {status_map.get(schedule.collaboration_status.value, schedule.collaboration_status.value)} -可查询: {'是' if schedule.is_queryable else '否'} - -🔧 系统信息 -日程ID: {schedule.schedule_id} -创建时间: {schedule.created_at[:19] if schedule.created_at else '(未知)'} -状态: {'激活' if schedule.is_active else '已删除'} -""" - - text.insert("1.0", details) - text.config(state=tk.DISABLED) - - # 关闭按钮 - ttk.Button(dialog, text="关闭", command=dialog.destroy, width=15).pack(pady=10) - - except Exception as e: - messagebox.showerror("错误", f"加载日程详情失败:\n{str(e)}") - - def show_context_menu(self, event): - """显示右键菜单""" - item = self.tree.identify_row(event.y) - if item: - self.tree.selection_set(item) - self.context_menu.post(event.x_root, event.y_root) - - def confirm_collaboration(self): - """确认协作""" - selection = self.tree.selection() - if not selection: - return - - item = selection[0] - schedule_id = self.tree.item(item)['tags'][0] - - try: - success = self.schedule_manager.confirm_collaboration(schedule_id, True) - if success: - messagebox.showinfo("成功", "已确认协作日程") - self.refresh_schedules() - else: - messagebox.showerror("错误", "确认失败") - except Exception as e: - messagebox.showerror("错误", f"确认失败:\n{str(e)}") - - def reject_collaboration(self): - """拒绝协作""" - selection = self.tree.selection() - if not selection: - return - - item = selection[0] - schedule_id = self.tree.item(item)['tags'][0] - - try: - success = self.schedule_manager.confirm_collaboration(schedule_id, False) - if success: - messagebox.showinfo("成功", "已拒绝协作日程") - self.refresh_schedules() - else: - messagebox.showerror("错误", "拒绝失败") - except Exception as e: - messagebox.showerror("错误", f"拒绝失败:\n{str(e)}") - - def start_auto_refresh(self): - """启动自动刷新""" - if self.auto_refresh_enabled: - self.refresh_schedules() - self.refresh_job = self.parent.after(self.refresh_interval, self.start_auto_refresh) - - def stop_auto_refresh(self): - """停止自动刷新""" - if self.refresh_job: - self.parent.after_cancel(self.refresh_job) - self.refresh_job = None - - def toggle_auto_refresh(self): - """切换自动刷新""" - self.auto_refresh_enabled = not self.auto_refresh_enabled - if self.auto_refresh_enabled: - self.auto_refresh_btn.config(text="⏸ 暂停刷新") - self.start_auto_refresh() - else: - self.auto_refresh_btn.config(text="▶ 启动刷新") - self.stop_auto_refresh() diff --git a/src/gui/settings_migration_gui.py b/src/gui/settings_migration_gui.py deleted file mode 100644 index b8274c8..0000000 --- a/src/gui/settings_migration_gui.py +++ /dev/null @@ -1,530 +0,0 @@ -""" -设定迁移 GUI 模块 -提供图形界面用于导出和导入智能体设定 -""" - -import os -import tkinter as tk -from tkinter import ttk, filedialog, messagebox, scrolledtext -from datetime import datetime -from typing import Dict, Any, List, Optional -from pathlib import Path - -from src.tools.settings_migration import SettingsMigration -from src.core.database_manager import DatabaseManager - - -class SettingsMigrationGUI: - """ - 设定迁移图形界面 - """ - - def __init__(self, parent=None, db_manager: DatabaseManager = None): - """ - 初始化设定迁移GUI - - Args: - parent: 父窗口 - db_manager: 数据库管理器实例 - """ - self.parent = parent - self.db_manager = db_manager or DatabaseManager() - self.migration = SettingsMigration(db_manager=self.db_manager) - - # 如果有父窗口,嵌入到父窗口中;否则创建独立窗口 - if parent: - self.window = parent - self._setup_ui() - else: - self.window = tk.Tk() - self.window.title("智能体设定迁移") - self.window.geometry("800x700") - - # 设置窗口图标(如果存在) - try: - self.window.iconbitmap('icon.ico') - except: - pass - - self._setup_ui() - - def _setup_ui(self): - """设置用户界面""" - # 创建选项卡 - self.notebook = ttk.Notebook(self.window) - self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - # 导出选项卡 - self.export_frame = ttk.Frame(self.notebook) - self.notebook.add(self.export_frame, text="导出设定") - self._setup_export_ui() - - # 导入选项卡 - self.import_frame = ttk.Frame(self.notebook) - self.notebook.add(self.import_frame, text="导入设定") - self._setup_import_ui() - - def _setup_export_ui(self): - """设置导出界面""" - # 创建主滚动容器 - main_canvas = tk.Canvas(self.export_frame, highlightthickness=0) - main_scrollbar = ttk.Scrollbar(self.export_frame, orient="vertical", command=main_canvas.yview) - scrollable_main_frame = ttk.Frame(main_canvas) - - scrollable_main_frame.bind( - "", - lambda e: main_canvas.configure(scrollregion=main_canvas.bbox("all")) - ) - - main_canvas.create_window((0, 0), window=scrollable_main_frame, anchor="nw") - main_canvas.configure(yscrollcommand=main_scrollbar.set) - - # 绑定鼠标滚轮事件 - def _on_mousewheel(event): - main_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - main_canvas.bind_all("", _on_mousewheel) - - main_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(10, 0), pady=10) - main_scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=10, padx=(0, 10)) - - # 说明文本 - info_frame = ttk.LabelFrame(scrollable_main_frame, text="说明", padding=10) - info_frame.pack(fill=tk.X, padx=10, pady=5) - - info_text = """导出功能可以将当前智能体的配置和数据保存到文件中,便于备份或迁移到其他环境。 -导出的文件包含 .env 配置和选定的数据库数据。""" - ttk.Label(info_frame, text=info_text, wraplength=700, justify=tk.LEFT).pack() - - # 导出选项 - options_frame = ttk.LabelFrame(scrollable_main_frame, text="导出选项", padding=10) - options_frame.pack(fill=tk.X, padx=10, pady=5) - - # .env 配置选项 - self.export_env_var = tk.BooleanVar(value=True) - ttk.Checkbutton( - options_frame, - text="包含 .env 配置文件", - variable=self.export_env_var - ).pack(anchor=tk.W, pady=2) - - # 数据类别选择 - ttk.Label(options_frame, text="\n选择要导出的数据类别:", - font=('微软雅黑', 10, 'bold')).pack(anchor=tk.W, pady=(10, 5)) - - # 全选/取消全选按钮 - select_buttons_frame = ttk.Frame(options_frame) - select_buttons_frame.pack(fill=tk.X, pady=5) - - ttk.Button( - select_buttons_frame, - text="全选", - command=self._select_all_export_categories - ).pack(side=tk.LEFT, padx=5) - - ttk.Button( - select_buttons_frame, - text="取消全选", - command=self._deselect_all_export_categories - ).pack(side=tk.LEFT, padx=5) - - # 创建滚动框架用于类别选择 - canvas = tk.Canvas(options_frame, height=250) - scrollbar = ttk.Scrollbar(options_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - # 数据类别复选框 - self.export_category_vars = {} - categories = self.migration.get_available_categories() - - # 默认选中的类别 - default_selected = [ - 'base_knowledge', 'entities', 'long_term_memory', - 'emotion_history', 'environment_descriptions', 'agent_expressions' - ] - - for category_key, category_name in categories.items(): - var = tk.BooleanVar(value=(category_key in default_selected)) - self.export_category_vars[category_key] = var - - ttk.Checkbutton( - scrollable_frame, - text=f"{category_name} ({category_key})", - variable=var - ).pack(anchor=tk.W, pady=2, padx=10) - - canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # 导出按钮 - button_frame = ttk.Frame(scrollable_main_frame) - button_frame.pack(fill=tk.X, padx=10, pady=10) - - ttk.Button( - button_frame, - text="导出设定", - command=self._do_export, - style='Accent.TButton' - ).pack(side=tk.RIGHT, padx=5) - - # 结果显示 - result_frame = ttk.LabelFrame(scrollable_main_frame, text="导出结果", padding=10) - result_frame.pack(fill=tk.X, padx=10, pady=5) - - self.export_result_text = scrolledtext.ScrolledText( - result_frame, - height=8, - wrap=tk.WORD, - font=('Consolas', 9) - ) - self.export_result_text.pack(fill=tk.BOTH, expand=True) - - def _setup_import_ui(self): - """设置导入界面""" - # 创建主滚动容器 - main_canvas = tk.Canvas(self.import_frame, highlightthickness=0) - main_scrollbar = ttk.Scrollbar(self.import_frame, orient="vertical", command=main_canvas.yview) - scrollable_main_frame = ttk.Frame(main_canvas) - - scrollable_main_frame.bind( - "", - lambda e: main_canvas.configure(scrollregion=main_canvas.bbox("all")) - ) - - main_canvas.create_window((0, 0), window=scrollable_main_frame, anchor="nw") - main_canvas.configure(yscrollcommand=main_scrollbar.set) - - # 绑定鼠标滚轮事件 - def _on_mousewheel(event): - main_canvas.yview_scroll(int(-1*(event.delta/120)), "units") - main_canvas.bind_all("", _on_mousewheel) - - main_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(10, 0), pady=10) - main_scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=10, padx=(0, 10)) - - # 说明文本 - info_frame = ttk.LabelFrame(scrollable_main_frame, text="说明", padding=10) - info_frame.pack(fill=tk.X, padx=10, pady=5) - - info_text = """导入功能可以从导出的配置文件中恢复智能体设定。 -您可以选择是否导入 .env 配置和选择要导入的数据类别。 -建议先预览导入内容,确认无误后再执行导入。""" - ttk.Label(info_frame, text=info_text, wraplength=700, justify=tk.LEFT).pack() - - # 文件选择 - file_frame = ttk.LabelFrame(scrollable_main_frame, text="选择导入文件", padding=10) - file_frame.pack(fill=tk.X, padx=10, pady=5) - - file_select_frame = ttk.Frame(file_frame) - file_select_frame.pack(fill=tk.X) - - self.import_file_var = tk.StringVar() - ttk.Entry( - file_select_frame, - textvariable=self.import_file_var, - width=60 - ).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) - - ttk.Button( - file_select_frame, - text="浏览...", - command=self._select_import_file - ).pack(side=tk.RIGHT) - - ttk.Button( - file_frame, - text="预览导入内容", - command=self._preview_import - ).pack(pady=(10, 0)) - - # 导入选项 - options_frame = ttk.LabelFrame(scrollable_main_frame, text="导入选项", padding=10) - options_frame.pack(fill=tk.X, padx=10, pady=5) - - # .env 配置选项 - self.import_env_var = tk.BooleanVar(value=True) - ttk.Checkbutton( - options_frame, - text="导入 .env 配置文件", - variable=self.import_env_var - ).pack(anchor=tk.W, pady=2) - - # 覆盖选项 - self.overwrite_var = tk.BooleanVar(value=False) - ttk.Checkbutton( - options_frame, - text="覆盖现有数据(谨慎使用)", - variable=self.overwrite_var - ).pack(anchor=tk.W, pady=2) - - # 数据类别选择 - ttk.Label(options_frame, text="\n选择要导入的数据类别:", - font=('微软雅黑', 10, 'bold')).pack(anchor=tk.W, pady=(10, 5)) - - # 创建滚动框架 - canvas = tk.Canvas(options_frame, height=200) - scrollbar = ttk.Scrollbar(options_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - # 数据类别复选框 - self.import_category_vars = {} - categories = self.migration.get_available_categories() - - for category_key, category_name in categories.items(): - var = tk.BooleanVar(value=True) - self.import_category_vars[category_key] = var - - ttk.Checkbutton( - scrollable_frame, - text=f"{category_name} ({category_key})", - variable=var - ).pack(anchor=tk.W, pady=2, padx=10) - - canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # 导入按钮 - button_frame = ttk.Frame(scrollable_main_frame) - button_frame.pack(fill=tk.X, padx=10, pady=10) - - ttk.Button( - button_frame, - text="执行导入", - command=self._do_import, - style='Accent.TButton' - ).pack(side=tk.RIGHT, padx=5) - - # 结果显示 - result_frame = ttk.LabelFrame(scrollable_main_frame, text="导入结果", padding=10) - result_frame.pack(fill=tk.X, padx=10, pady=5) - - self.import_result_text = scrolledtext.ScrolledText( - result_frame, - height=8, - wrap=tk.WORD, - font=('Consolas', 9) - ) - self.import_result_text.pack(fill=tk.BOTH, expand=True) - - def _select_all_export_categories(self): - """全选导出类别""" - for var in self.export_category_vars.values(): - var.set(True) - - def _deselect_all_export_categories(self): - """取消全选导出类别""" - for var in self.export_category_vars.values(): - var.set(False) - - def _do_export(self): - """执行导出""" - # 获取选中的类别 - selected_categories = [ - key for key, var in self.export_category_vars.items() if var.get() - ] - - if not selected_categories and not self.export_env_var.get(): - messagebox.showwarning("警告", "请至少选择一项要导出的内容") - return - - # 选择保存路径 - default_filename = f"neo_agent_settings_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - file_path = filedialog.asksaveasfilename( - title="保存导出文件", - defaultextension=".json", - initialfile=default_filename, - filetypes=[("JSON文件", "*.json"), ("所有文件", "*.*")] - ) - - if not file_path: - return - - # 移除扩展名(因为 export_settings 会自动添加) - if file_path.endswith('.json'): - file_path = file_path[:-5] - - # 显示进度 - self.export_result_text.delete(1.0, tk.END) - self.export_result_text.insert(tk.END, "正在导出...\n") - self.window.update() - - # 执行导出 - result = self.migration.export_settings( - export_path=file_path, - include_env=self.export_env_var.get(), - selected_categories=selected_categories if selected_categories else None - ) - - # 显示结果 - self.export_result_text.delete(1.0, tk.END) - - if result['success']: - self.export_result_text.insert(tk.END, f"✓ 导出成功!\n\n", 'success') - self.export_result_text.insert(tk.END, f"导出文件: {result['exported_file']}\n\n") - self.export_result_text.insert(tk.END, "导出统计:\n") - - for key, count in result['stats'].items(): - category_name = self.migration.DATA_CATEGORIES.get(key, key) - self.export_result_text.insert(tk.END, f" - {category_name}: {count} 条\n") - - self.export_result_text.tag_config('success', foreground='green') - - messagebox.showinfo("成功", f"设定已成功导出到:\n{result['exported_file']}") - else: - self.export_result_text.insert(tk.END, f"✗ {result['message']}\n", 'error') - self.export_result_text.tag_config('error', foreground='red') - - messagebox.showerror("错误", result['message']) - - def _select_import_file(self): - """选择导入文件""" - file_path = filedialog.askopenfilename( - title="选择导入文件", - filetypes=[("JSON文件", "*.json"), ("所有文件", "*.*")] - ) - - if file_path: - self.import_file_var.set(file_path) - - def _preview_import(self): - """预览导入内容""" - file_path = self.import_file_var.get() - - if not file_path or not os.path.exists(file_path): - messagebox.showwarning("警告", "请先选择有效的导入文件") - return - - # 预览导入内容 - preview = self.migration.preview_import(file_path) - - self.import_result_text.delete(1.0, tk.END) - - if preview['success']: - self.import_result_text.insert(tk.END, "=== 导入文件预览 ===\n\n", 'title') - - # 显示导出信息 - export_info = preview['export_info'] - if export_info: - self.import_result_text.insert(tk.END, "导出信息:\n") - self.import_result_text.insert(tk.END, f" 版本: {export_info.get('version', '未知')}\n") - self.import_result_text.insert(tk.END, f" 导出时间: {export_info.get('exported_at', '未知')}\n") - self.import_result_text.insert(tk.END, f" 智能体名称: {export_info.get('agent_name', '未知')}\n\n") - - # 显示环境变量统计 - if preview['env_settings_count'] > 0: - self.import_result_text.insert(tk.END, f"环境变量: {preview['env_settings_count']} 项\n\n") - - # 显示数据类别统计 - if preview['categories']: - self.import_result_text.insert(tk.END, "数据类别:\n") - for category_key, info in preview['categories'].items(): - self.import_result_text.insert( - tk.END, - f" - {info['name']}: {info['count']} 条\n" - ) - - self.import_result_text.tag_config('title', font=('微软雅黑', 10, 'bold')) - - messagebox.showinfo("预览", "导入文件预览已显示在下方结果区域") - else: - self.import_result_text.insert(tk.END, f"✗ {preview['message']}\n", 'error') - self.import_result_text.tag_config('error', foreground='red') - - messagebox.showerror("错误", preview['message']) - - def _do_import(self): - """执行导入""" - file_path = self.import_file_var.get() - - if not file_path or not os.path.exists(file_path): - messagebox.showwarning("警告", "请先选择有效的导入文件") - return - - # 获取选中的类别 - selected_categories = [ - key for key, var in self.import_category_vars.items() if var.get() - ] - - if not selected_categories and not self.import_env_var.get(): - messagebox.showwarning("警告", "请至少选择一项要导入的内容") - return - - # 确认导入 - overwrite = self.overwrite_var.get() - message = "确定要导入设定吗?" - if overwrite: - message += "\n\n警告: 您选择了覆盖现有数据,这将删除现有的数据!" - - if not messagebox.askyesno("确认导入", message): - return - - # 显示进度 - self.import_result_text.delete(1.0, tk.END) - self.import_result_text.insert(tk.END, "正在导入...\n") - self.window.update() - - # 执行导入 - result = self.migration.import_settings( - import_path=file_path, - import_env=self.import_env_var.get(), - import_database=True, - overwrite=overwrite, - selected_categories=selected_categories if selected_categories else None - ) - - # 显示结果 - self.import_result_text.delete(1.0, tk.END) - - if result['success']: - self.import_result_text.insert(tk.END, f"✓ 导入成功!\n\n", 'success') - - if 'backup_env' in result: - self.import_result_text.insert( - tk.END, - f"原 .env 已备份到: {result['backup_env']}\n\n" - ) - - self.import_result_text.insert(tk.END, "导入统计:\n") - - for key, count in result['stats'].items(): - category_name = self.migration.DATA_CATEGORIES.get(key, key) - self.import_result_text.insert(tk.END, f" - {category_name}: {count} 条\n") - - self.import_result_text.tag_config('success', foreground='green') - - messagebox.showinfo("成功", "设定已成功导入!\n\n如果导入了 .env 配置,请重启应用以使新配置生效。") - else: - self.import_result_text.insert(tk.END, f"✗ {result['message']}\n", 'error') - self.import_result_text.tag_config('error', foreground='red') - - messagebox.showerror("错误", result['message']) - - def run(self): - """运行GUI(独立窗口模式)""" - if not self.parent: - self.window.mainloop() - - -def main(): - """主函数,用于独立运行""" - gui = SettingsMigrationGUI() - gui.run() - - -if __name__ == '__main__': - main() diff --git a/src/hypothalamus/__init__.py b/src/hypothalamus/__init__.py new file mode 100644 index 0000000..7d77875 --- /dev/null +++ b/src/hypothalamus/__init__.py @@ -0,0 +1,13 @@ +""" +Hypothalamus - 下丘脑模块。 + +负责人体内稳态、生理节律、生命状态管理与用户行为习惯。 +""" + +from __future__ import annotations + +__all__ = [ + "module", + "state", + "habits", +] diff --git a/src/hypothalamus/habits/__init__.py b/src/hypothalamus/habits/__init__.py new file mode 100644 index 0000000..514c082 --- /dev/null +++ b/src/hypothalamus/habits/__init__.py @@ -0,0 +1,9 @@ +""" +Hypothalamus habits package - 用户行为习惯与节律。 +""" + +from __future__ import annotations + +__all__ = [ + "user_habits", +] diff --git a/src/hypothalamus/habits/user_habits.py b/src/hypothalamus/habits/user_habits.py new file mode 100644 index 0000000..de88ba6 --- /dev/null +++ b/src/hypothalamus/habits/user_habits.py @@ -0,0 +1,170 @@ +""" +用户行为习惯跟踪模块 +- 提取用户作息 / 饮食 / 工作 / 娱乐 4 类行为习惯 +- 准入规则:同一习惯候选需在 ≥2 个不同自然日被提取才进入 qualified_habits +- 为 ProactiveEngine.proactive_impulse_pool 提供 habit_care 想法来源 + +参考架构:spec §Exploratory → UserHabitTracker +""" + +import os +import json +import uuid +from datetime import datetime +from typing import Dict, List, Any, Optional + +from src.core.database_manager import DatabaseManager +from src.core.llm_helper import LLMHelper +from src.tools.debug_logger import get_debug_logger + +ENABLE_USER_HABITS = os.getenv('ENABLE_USER_HABITS', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + + +class UserHabitTracker: + """ + 用户行为习惯跟踪器。 + - update_from_message: 从用户消息中提取习惯候选 + - qualified_habits: 返回已通过 ≥2 自然日准入规则的习惯 + - habit_proactive_event: 触发习惯相关主动事件 + - format_for_schedule: 渲染为可注入 schedule 的格式 + """ + + CATEGORIES = ['作息', '饮食', '工作', '娱乐'] + MIN_DAYS_FOR_QUALIFY = 2 + + def __init__(self, db_manager: DatabaseManager = None): + self.db = db_manager or DatabaseManager() + self.enabled = ENABLE_USER_HABITS + + def update_from_message(self, user: str, text: str) -> List[Dict[str, Any]]: + """ + 从用户消息中提取习惯候选。 + 返回 [{category, content, observed_date}, ...](仅当天新观察到的)。 + """ + if not self.enabled or not text: + return [] + # 极简规则:基于关键词分类 + text_lower = text.lower() + observed_date = datetime.now().date().isoformat() + candidates = [] + # 作息 + if any(kw in text for kw in ['起床', '睡觉', '晚安', '早起', '熬夜', '失眠']): + candidates.append({'category': '作息', 'content': text[:80]}) + # 饮食 + if any(kw in text for kw in ['吃饭', '早餐', '午餐', '晚餐', '宵夜', '喝', '咖啡', '奶茶', '饿了', '饱']): + candidates.append({'category': '饮食', 'content': text[:80]}) + # 工作 + if any(kw in text for kw in ['加班', '开会', '项目', '任务', '工作', '上班', '下班', '出差']): + candidates.append({'category': '工作', 'content': text[:80]}) + # 娱乐 + if any(kw in text for kw in ['看', '玩', '听', '游戏', '电影', '剧', '音乐', '运动', '跑步', '逛街']): + candidates.append({'category': '娱乐', 'content': text[:80]}) + # 存储 + for c in candidates: + self.db.set_metadata( + f'habit_{user}_{c["category"]}_{observed_date}_{uuid.uuid4().hex[:8]}', + json.dumps({ + 'category': c['category'], + 'content': c['content'], + 'observed_date': observed_date, + }, ensure_ascii=False) + ) + return [{**c, 'observed_date': observed_date} for c in candidates] + + def qualified_habits(self, user: str) -> List[Dict[str, Any]]: + """ + 返回已通过 ≥ MIN_DAYS_FOR_QUALIFY 个不同自然日准入规则的习惯。 + """ + if not self.enabled: + return [] + all_meta = self.db.list_metadata_keys(prefix=f'habit_{user}_') + by_key: Dict[str, Dict[str, set]] = {} + for key in all_meta: + value = self.db.get_metadata(key, None) + if not value: + continue + try: + data = json.loads(value) + except (TypeError, ValueError): + continue + cat = data.get('category', '其他') + content = data.get('content', '')[:80] + date = data.get('observed_date', '') + key2 = f'{cat}::{content}' + if key2 not in by_key: + by_key[key2] = {'category': cat, 'content': content, 'dates': set()} + by_key[key2]['dates'].add(date) + qualified = [] + for k, v in by_key.items(): + if len(v['dates']) >= self.MIN_DAYS_FOR_QUALIFY: + qualified.append({ + 'category': v['category'], + 'content': v['content'], + 'observed_dates': sorted(v['dates']), + }) + return qualified + + def habit_proactive_event(self, user: str, + now: Optional[datetime] = None) -> Optional[Dict[str, Any]]: + """ + 根据当前时间 + 已确认习惯触发一条主动事件。 + 返回 {category, content, trigger_at} 或 None。 + """ + if not self.enabled: + return None + now = now or datetime.now() + h = now.hour + # 简单的时段映射 + target_category = None + if 6 <= h <= 9: + target_category = '饮食' # 早餐 + elif 11 <= h <= 13: + target_category = '饮食' # 午餐 + elif 17 <= h <= 19: + target_category = '饮食' # 晚餐 + elif 22 <= h <= 24 or 0 <= h <= 2: + target_category = '作息' # 提醒睡觉 + elif 14 <= h <= 18: + target_category = '娱乐' # 下午休闲 + else: + target_category = '工作' + habits = self.qualified_habits(user) + matched = [h2 for h2 in habits if h2.get('category') == target_category] + if not matched: + return None + # 取最新观察 + picked = sorted(matched, key=lambda h2: h2['observed_dates'][-1], reverse=True)[0] + return { + 'category': picked['category'], + 'content': picked['content'], + 'trigger_at': now.isoformat(), + } + + def format_for_schedule(self, limit: int = 8) -> str: + """渲染为 prompt 文本,注入 schedule 上下文。""" + if not self.enabled: + return "" + # 不再传 user(无 GUI 用户上下文)— 改为从 metadata 全局读 + all_meta = self.db.list_metadata_keys(prefix='habit_') + seen = set() + lines = [] + for key in all_meta: + value = self.db.get_metadata(key, None) + if not value: + continue + try: + data = json.loads(value) + except (TypeError, ValueError): + continue + k = f'{data.get("category")}::{data.get("content")}' + if k in seen: + continue + seen.add(k) + lines.append(f"• [{data.get('category')}] {data.get('content')[:40]}") + if len(lines) >= limit: + break + if not lines: + return "" + return "【用户行为习惯】\n" + "\n".join(lines) diff --git a/src/hypothalamus/module.py b/src/hypothalamus/module.py new file mode 100644 index 0000000..e1c8c63 --- /dev/null +++ b/src/hypothalamus/module.py @@ -0,0 +1,186 @@ +""" +HypothalamusModule - 下丘脑模块(v4.0 神经系统接入层)。 + +将 src.hypothalamus.state.life_state、src.hypothalamus.habits.user_habits +中的内稳态与用户习惯能力包装为 BaseModule,使其可以通过 CentralRouter +被其他模块调用。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, Optional + +from src.core.database_manager import DatabaseManager +from src.hypothalamus.habits.user_habits import UserHabitTracker +from src.hypothalamus.state.life_state import LifeStateManager +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +def _parse_datetime(value: Optional[str]) -> Optional[datetime]: + """将 ISO 格式字符串解析为 datetime,失败时返回 None。""" + if value is None: + return None + try: + return datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + + +class HypothalamusModule(BaseModule): + """ + 下丘脑模块。 + + 负责内稳态、生理节律、生命状态、用户行为习惯。 + """ + + module_id = "hypothalamus.homeostasis" + module_type = "hypothalamus" + + def __init__( + self, + router: "CentralRouter", + db_manager: Optional[DatabaseManager] = None, + character_info: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(router) + self._db = db_manager or DatabaseManager() + self._life_state = LifeStateManager( + db_manager=self._db, + character_info=character_info, + ) + self._habits = UserHabitTracker(db_manager=self._db) + + async def handle(self, packet: Packet) -> Packet: + """ + 处理下丘脑相关请求。 + + Channels: + - life_state_ensure: 确保当日状态存在 + - life_state_snapshot: 获取当前状态快照 + - life_state_transition: 应用状态转移 + - life_state_prompt: 渲染状态为 prompt + - life_state_body_cycle: 计算生理周期 + - life_state_consume_dream_delta: 消费梦境能量差值 + - habit_update: 从用户消息更新习惯候选 + - habit_qualified: 获取已确认习惯 + - habit_proactive_event: 触发习惯主动事件 + - habit_format_schedule: 渲染习惯为 schedule 格式 + """ + channel = packet.channel + payload = packet.payload + + try: + if channel == "life_state_ensure": + return self._handle_life_state_ensure(packet, payload) + if channel == "life_state_snapshot": + return self._handle_life_state_snapshot(packet, payload) + if channel == "life_state_transition": + return self._handle_life_state_transition(packet, payload) + if channel == "life_state_prompt": + return self._handle_life_state_prompt(packet, payload) + if channel == "life_state_body_cycle": + return self._handle_life_state_body_cycle(packet, payload) + if channel == "life_state_consume_dream_delta": + return self._handle_life_state_consume_dream_delta(packet, payload) + if channel == "habit_update": + return self._handle_habit_update(packet, payload) + if channel == "habit_qualified": + return self._handle_habit_qualified(packet, payload) + if channel == "habit_proactive_event": + return self._handle_habit_proactive_event(packet, payload) + if channel == "habit_format_schedule": + return self._handle_habit_format_schedule(packet, payload) + except Exception as e: + return Packet.error(packet, str(e), "HYPOTHALAMUS_ERROR") + + return packet.response({ + "status": "unknown_channel", + "channel": channel, + }) + + def _handle_life_state_ensure( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + force = payload.get("force", False) + weather = payload.get("weather") + now = _parse_datetime(payload.get("now")) + result = self._life_state.ensure_daily_state( + force=force, weather=weather, now=now + ) + return packet.response(result) + + def _handle_life_state_snapshot( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + now = _parse_datetime(payload.get("now")) + result = self._life_state.get_current_state_snapshot(now=now) + return packet.response(result) + + def _handle_life_state_transition( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + current = payload.get("current", {}) + transition_options = payload.get("transition_options") + result = self._life_state.apply_transition( + current=current, transition_options=transition_options + ) + return packet.response(result) + + def _handle_life_state_prompt( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + now = _parse_datetime(payload.get("now")) + prompt = self._life_state.format_state_for_prompt(now=now) + return packet.response({"prompt": prompt}) + + def _handle_life_state_body_cycle( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + character_info = payload.get("character_info") + now = _parse_datetime(payload.get("now")) + result = self._life_state.body_cycle( + character_info=character_info, now=now + ) + return packet.response(result) + + def _handle_life_state_consume_dream_delta( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + energy_delta = payload.get("energy_delta", 0.0) + self._life_state.consume_dream_energy_delta(energy_delta) + return packet.response({"status": "ok"}) + + def _handle_habit_update( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + user = payload.get("user", "") + text = payload.get("text", "") + candidates = self._habits.update_from_message(user=user, text=text) + return packet.response({"candidates": candidates}) + + def _handle_habit_qualified( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + user = payload.get("user", "") + habits = self._habits.qualified_habits(user=user) + return packet.response({"habits": habits}) + + def _handle_habit_proactive_event( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + user = payload.get("user", "") + now = _parse_datetime(payload.get("now")) + event = self._habits.habit_proactive_event(user=user, now=now) + return packet.response({"event": event}) + + def _handle_habit_format_schedule( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + limit = payload.get("limit", 8) + prompt = self._habits.format_for_schedule(limit=limit) + return packet.response({"prompt": prompt}) diff --git a/src/hypothalamus/state/__init__.py b/src/hypothalamus/state/__init__.py new file mode 100644 index 0000000..2777151 --- /dev/null +++ b/src/hypothalamus/state/__init__.py @@ -0,0 +1,9 @@ +""" +Hypothalamus state package - 生命状态与内稳态。 +""" + +from __future__ import annotations + +__all__ = [ + "life_state", +] diff --git a/src/hypothalamus/state/life_state.py b/src/hypothalamus/state/life_state.py new file mode 100644 index 0000000..b320bd7 --- /dev/null +++ b/src/hypothalamus/state/life_state.py @@ -0,0 +1,384 @@ +""" +生活状态机管理模块 +管理 Bot 当日精力 / 心情 / 健康状态的状态机。 +实现要点: +- ensure_daily_state: 每日首次访问时自动生成状态快照(含 conditions 与 transition_options) +- generate_state_conditions: 通过 LLMHelper.call_tool_model 生成结构化状态条件(temperature 0.3) +- get_current_state_snapshot: 拉取当日状态 +- apply_transition: 按 transition_options 的权重决定下一状态 +- format_state_for_prompt: 渲染为 prompt 文本(通过 PromptManager) +- 反向消费:DreamDiaryManager.generate_dream_pick 的 energy_delta 喂入次日 energy 基线 + +参考架构:spec §1 ADDED Requirements → LifeStateManager +""" + +import os +import json +import random +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional + +from src.core.database_manager import DatabaseManager +from src.core.llm_helper import LLMHelper +from src.core.prompt_manager import get_prompt_manager +from src.tools.debug_logger import get_debug_logger + +ENABLE_LIFE_STATE = os.getenv('ENABLE_LIFE_STATE', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + + +class LifeStateManager: + """ + 生活状态机管理器 + 每日生成一次状态快照,按 transition_options 决定状态转移。 + """ + + STATE_TITLES = ['常态', '疲惫', '兴奋', '低落', '焦虑', '放松', '专注', '饥饿'] + DEFAULT_MOODS = ['平静', '愉快', '低落', '焦虑', '兴奋', '疲倦', '温暖'] + DEFAULT_HEALTHS = ['健康', '轻微不适', '生病', '康复中'] + TRANSITION_TRIGGERS = ['sleep_debt', 'body_cycle', 'meal_missed', 'weather_shift', + 'good_news', 'bad_news', 'idle_long', 'intense_talk'] + + def __init__(self, db_manager: DatabaseManager = None, + prompt_manager=None, + character_info: Optional[Dict[str, str]] = None): + """ + Args: + db_manager: 共享数据库管理器 + prompt_manager: 提示词管理器(默认走单例) + character_info: 角色设定信息(用于状态生成时的角色一致性) + """ + self.db = db_manager or DatabaseManager() + self.prompt_manager = prompt_manager or get_prompt_manager() + self.character_info = character_info or {} + self.enabled = ENABLE_LIFE_STATE + self._last_dream_energy_delta = 0.0 + + def ensure_daily_state(self, force: bool = False, + weather: Optional[Dict[str, Any]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 每日首次访问时生成状态快照;若当日已存在则直接返回(除非 force=True)。 + """ + if not self.enabled: + return self._get_or_create_default(now) + + now = now or datetime.now() + date_str = now.date().isoformat() + + if not force: + existing = self.db.get_life_state_daily(date_str) + if existing: + return existing + + conditions = self.generate_state_conditions(weather=weather, now=now) + energy = self._resolve_energy(conditions, now) + mood = self._resolve_mood(conditions) + health = self._resolve_health(conditions) + state_title = self._resolve_state_title(conditions, energy, mood) + transition_options = self._build_transition_options(conditions) + + success = self.db.upsert_life_state_daily( + date=date_str, energy=energy, mood=mood, + state_title=state_title, health=health, + conditions=conditions, transition_options=transition_options, + energy_delta=self._last_dream_energy_delta + ) + self._last_dream_energy_delta = 0.0 + + if not success: + return self._get_or_create_default(now) + + result = self.db.get_life_state_daily(date_str) or {} + debug_logger.log_info('LifeStateManager', '生成当日状态', { + 'date': date_str, 'energy': energy, 'mood': mood, + 'state_title': state_title, 'conditions_count': len(conditions) + }) + return result + + def generate_state_conditions(self, weather: Optional[Dict[str, Any]] = None, + now: Optional[datetime] = None) -> List[Dict[str, Any]]: + """ + 调用工具模型生成结构化的"今日状态条件"列表。 + 每条 condition 含 name / weight / category / note。 + """ + now = now or datetime.now() + prompt_vars = { + 'character_name': self.character_info.get('character_name', 'AI'), + 'date': now.date().isoformat(), + 'weather': (weather or {}).get('summary', '晴') if weather else '晴', + 'dream_energy_delta': self._last_dream_energy_delta, + } + + try: + system_prompt = self.prompt_manager.get_system_prompt( + 'life_state', prompt_vars + ) + except Exception: + system_prompt = self._fallback_conditions_prompt() + + user_msg = f"今日 {prompt_vars['date']},天气 {prompt_vars['weather']}。请给出 3-5 条状态条件 JSON。" + + try: + raw = LLMHelper.call_tool_model( + system_prompt=system_prompt, + user_message=user_msg, + temperature=0.3, + max_tokens=512 + ) + conditions = self._parse_conditions(raw) + except Exception as e: + debug_logger.log_error('LifeStateManager', f'生成状态条件失败: {e}', e) + conditions = self._default_conditions(now) + + if not conditions: + conditions = self._default_conditions(now) + return conditions + + def get_current_state_snapshot(self, now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 获取当前状态快照(若当日不存在则确保生成)。 + """ + now = now or datetime.now() + return self.ensure_daily_state(now=now) + + def apply_transition(self, current: Dict[str, Any], + transition_options: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """ + 根据 transition_options 权重决定下一状态。 + transition_options 每条:{trigger, target_state, weight}。 + """ + options = transition_options or current.get('transition_options', []) + if not options: + return current + + weights = [max(0.0, float(opt.get('weight', 0.5))) for opt in options] + total = sum(weights) + if total <= 0: + return current + pick = random.random() * total + cumulative = 0.0 + chosen = options[0] + for opt, w in zip(options, weights): + cumulative += w + if pick <= cumulative: + chosen = opt + break + + new_state = dict(current) + new_state['state_title'] = chosen.get('target_state', current.get('state_title', '常态')) + new_state['last_transition_trigger'] = chosen.get('trigger', 'unknown') + + date_str = current.get('date') or datetime.now().date().isoformat() + self.db.upsert_life_state_daily( + date=date_str, + energy=float(current.get('energy', 0.7)), + mood=current.get('mood', '平静'), + state_title=new_state['state_title'], + health=current.get('health', '健康'), + conditions=current.get('conditions', []), + transition_options=options, + energy_delta=0.0, + ) + return new_state + + def format_state_for_prompt(self, now: Optional[datetime] = None) -> str: + """ + 将当前状态渲染为 prompt 文本(与 generate_tone_prompt 平行注入)。 + """ + if not self.enabled: + return "" + snapshot = self.get_current_state_snapshot(now=now) + if not snapshot: + return "" + + template = self.prompt_manager.load_prompt('system', 'life_state') + vars_ = { + 'energy': f"{snapshot.get('energy', 0.7):.2f}", + 'mood': snapshot.get('mood', '平静'), + 'state_title': snapshot.get('state_title', '常态'), + 'health': snapshot.get('health', '健康'), + 'date': snapshot.get('date', datetime.now().date().isoformat()), + } + return self.prompt_manager.render_prompt(template, vars_) + + def consume_dream_energy_delta(self, energy_delta: float) -> None: + """ + DreamDiaryManager 调用此方法将梦境的 energy_delta 喂给次日精力基线。 + """ + self._last_dream_energy_delta = float(energy_delta) + + # ==================== P3-E: body_cycle 子模块(女性人格可选) ==================== + + def body_cycle(self, character_info: Optional[Dict[str, Any]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 仅当角色为女性人格时启用。 + 返回 {'enabled': bool, 'phase': str, 'energy_modifier': float} + """ + info = character_info or self.character_info or {} + gender = (info.get('gender') or info.get('性别') or '').lower() + if gender not in ('female', 'f', '女', '女性'): + return {'enabled': False, 'phase': '', 'energy_modifier': 0.0} + # 简化:以 28 天周期计算(无个性化锚点) + now = now or datetime.now() + anchor = self._body_cycle_anchor(info) + delta_days = (now - anchor).days % 28 + phase = self.cycle_phase(delta_days) + modifier = self.cycle_energy_modifier(phase) + return { + 'enabled': True, + 'phase': phase, + 'energy_modifier': modifier, + 'cycle_day': delta_days, + } + + @staticmethod + def _body_cycle_anchor(info: Dict[str, Any]) -> datetime: + """简化:默认锚点 2026-01-01。生产环境应从 character_info 中读取真实锚点。""" + anchor_str = info.get('cycle_anchor') or info.get('生理周期锚点') + if anchor_str: + try: + return datetime.fromisoformat(str(anchor_str)) + except (ValueError, TypeError): + pass + return datetime(2026, 1, 1) + + @staticmethod + def cycle_phase(cycle_day: int) -> str: + """ + 根据周期天数(0-27)返回阶段。 + 简化分段(4 段):经期 / 经后 / 排卵期 / 经前 + """ + cycle_day = cycle_day % 28 + if cycle_day < 5: + return '经期' + if cycle_day < 13: + return '经后' + if cycle_day < 17: + return '排卵期' + return '经前' + + @staticmethod + def cycle_energy_modifier(phase: str) -> float: + """返回对能量基线的修正系数(-0.2 ~ +0.1)。""" + return { + '经期': -0.15, + '经后': 0.05, + '排卵期': 0.1, + '经前': -0.2, + }.get(phase, 0.0) + + def _resolve_energy(self, conditions: List[Dict[str, Any]], now: datetime) -> float: + base = 0.7 + for c in conditions: + name = (c.get('name') or '').lower() + weight = float(c.get('weight', 0.0)) + if any(k in name for k in ('失眠', '熬夜', 'sleep_debt')): + base -= 0.2 * weight + elif any(k in name for k in ('好梦', 'good_dream', '恢复', 'relaxed')): + base += 0.1 * weight + elif any(k in name for k in ('焦虑', 'anxiety', '压力')): + base -= 0.1 * weight + base += self._last_dream_energy_delta + return round(max(0.0, min(1.0, base)), 3) + + def _resolve_mood(self, conditions: List[Dict[str, Any]]) -> str: + for c in conditions: + name = (c.get('name') or '').lower() + if '焦虑' in name or '压力' in name: + return '焦虑' + if '低落' in name or 'sad' in name: + return '低落' + if '兴奋' in name or 'excited' in name: + return '兴奋' + if '愉快' in name or 'happy' in name: + return '愉快' + if '疲倦' in name or 'tired' in name: + return '疲倦' + return '平静' + + def _resolve_health(self, conditions: List[Dict[str, Any]]) -> str: + for c in conditions: + name = (c.get('name') or '').lower() + if '生病' in name or 'ill' in name: + return '生病' + if '不适' in name or 'unwell' in name: + return '轻微不适' + return '健康' + + def _resolve_state_title(self, conditions: List[Dict[str, Any]], + energy: float, mood: str) -> str: + for c in conditions: + name = (c.get('name') or '').lower() + if '饥饿' in name or 'hungry' in name: + return '饥饿' + if '专注' in name or 'focus' in name: + return '专注' + if energy < 0.4: + return '疲惫' + if mood == '焦虑': + return '焦虑' + if mood == '低落': + return '低落' + if mood == '兴奋': + return '兴奋' + return '常态' + + def _build_transition_options(self, conditions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + options = [] + for c in conditions: + name = (c.get('name') or '').lower() + if '饥饿' in name or 'hungry' in name: + options.append({'trigger': 'meal_missed', 'target_state': '饥饿', 'weight': 0.7}) + elif '疲倦' in name or 'tired' in name: + options.append({'trigger': 'sleep_debt', 'target_state': '疲惫', 'weight': 0.8}) + elif '焦虑' in name or 'anxiety' in name: + options.append({'trigger': 'bad_news', 'target_state': '焦虑', 'weight': 0.6}) + if not options: + options.append({'trigger': 'idle_long', 'target_state': '放松', 'weight': 0.5}) + return options + + def _parse_conditions(self, raw: str) -> List[Dict[str, Any]]: + text = raw.strip() + if text.startswith('```'): + text = text.strip('`') + if text.startswith('json'): + text = text[4:] + try: + data = json.loads(text) + if isinstance(data, list): + return [c for c in data if isinstance(c, dict) and 'name' in c] + if isinstance(data, dict) and 'conditions' in data: + return [c for c in data['conditions'] if isinstance(c, dict) and 'name' in c] + except json.JSONDecodeError: + pass + return [] + + def _default_conditions(self, now: datetime) -> List[Dict[str, Any]]: + return [ + {'name': '常态作息', 'weight': 0.6, 'category': '作息', 'note': '保持稳定'}, + {'name': '天气适宜', 'weight': 0.4, 'category': '环境', 'note': '外部环境平稳'}, + ] + + def _fallback_conditions_prompt(self) -> str: + return ("你是状态分析器。返回 JSON list,每条含 name/weight/category/note。" + "weight 范围 0-1,表示强度。") + + def _get_or_create_default(self, now: Optional[datetime]) -> Dict[str, Any]: + now = now or datetime.now() + date_str = now.date().isoformat() + existing = self.db.get_life_state_daily(date_str) + if existing: + return existing + return { + 'date': date_str, + 'energy': 0.7, + 'mood': '平静', + 'state_title': '常态', + 'health': '健康', + 'conditions': [], + 'transition_options': [], + 'energy_delta': 0.0, + } diff --git a/src/limbic/__init__.py b/src/limbic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/limbic/amygdala/__init__.py b/src/limbic/amygdala/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/limbic/amygdala/emotion_state.py b/src/limbic/amygdala/emotion_state.py new file mode 100644 index 0000000..346e786 --- /dev/null +++ b/src/limbic/amygdala/emotion_state.py @@ -0,0 +1,1191 @@ +""" +情感关系分析模块(v4.0 杏仁核 / Amygdala) + +初次评估基于前5轮对话,后续每15轮对话更新评估。 +使用LLM生成对用户的印象并进行累加评分。 +使用数据库替代JSON文件存储。 + +v4.0 重构说明: +- 真实实现从 src.core.emotion_analyzer 迁移到 src.limbic.amygdala.emotion_state +- src.core.emotion_analyzer 保留为兼容层 +- 新代码应优先使用本模块 + +P2 增强: +- 新增 PlutchikEmotionWheel(8 基本情绪 + 衰减 + profile 推导) +- 累加评分(EmotionRelationshipAnalyzer)作为"关系状态"维度保留 +- PlutchikEmotionWheel 作为"当下情绪"维度补充 +- 二者并行注入 prompt,由 chat_agent 协调 +""" + +from __future__ import annotations + +import os +import json +import math +from datetime import datetime +from typing import List, Dict, Any, Optional +from dotenv import load_dotenv +import requests +from src.core.database_manager import DatabaseManager +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + +ENABLE_EMOTION_WHEEL = os.getenv('ENABLE_EMOTION_WHEEL', 'false').lower() == 'true' + + +class EmotionRelationshipAnalyzer: + """ + 情感关系分析器 + 分阶段评估系统: + - 初次评估:基于前5轮对话,生成初始印象并评分(0-35) + - 更新评估:每15轮对话,评估最近表现并给出分数变化(-3到+3) + 使用累加评分系统,基于角色设定生成印象 + 使用数据库存储替代JSON文件 + """ + + def __init__(self, + db_manager: DatabaseManager = None): + """ + 初始化情感关系分析器(使用LangChain架构) + + Args: + db_manager: 数据库管理器实例(如果为None则创建新实例) + """ + # 使用共享的数据库管理器 + self.db = db_manager or DatabaseManager() + + # 初始化模型名称(用于日志记录) + self.model_name = os.getenv('TOOL_MODEL_NAME', 'zai-org/GLM-4.6V') + + # 检查是否需要从JSON迁移数据 + if os.path.exists('emotion_data.json'): + print("○ 检测到旧的情感数据JSON文件,正在迁移...") + self._migrate_from_json('emotion_data.json') + os.rename('emotion_data.json', 'emotion_data.json.bak') + print("✓ 情感数据已迁移,JSON文件已备份") + + print("✓ 情感关系分析器已初始化(使用数据库存储,基于LangChain)") + + def analyze_emotion_relationship(self, + messages: List[Dict[str, str]], + character_name: str = "AI", + character_settings: str = "", + is_initial: bool = None) -> Dict[str, Any]: + """ + 分析情感关系(累加评分系统) + + Args: + messages: 对话消息列表 + character_name: AI角色名称 + character_settings: 角色设定描述 + is_initial: 是否为初次评估,None表示自动判断 + + Returns: + 情感分析结果,包含印象评分和描述 + """ + debug_logger.log_module('EmotionAnalyzer', '开始情感关系分析', { + 'total_messages': len(messages), + 'character_name': character_name + }) + + # 获取历史评分 + latest_emotion = self.get_latest_emotion() + current_score = latest_emotion.get('overall_score', 0) if latest_emotion else 0 + + # 自动判断是初次评估还是更新评估:仅根据是否存在历史情感记录 + if is_initial is None: + is_initial = (latest_emotion is None) + + # 根据评估类型选择分析的对话轮数 + if is_initial: + # 初次评估:使用前5轮对话(10条消息) + recent_messages = messages[-10:] if len(messages) > 10 else messages + min_messages = 2 + else: + # 更新评估:使用最近15轮对话(30条消息) + recent_messages = messages[-30:] if len(messages) > 30 else messages + min_messages = 2 + + if len(recent_messages) < min_messages: + # 对话太少,返回默认值 + debug_logger.log_info('EmotionAnalyzer', '对话数量不足,返回默认情感数据', { + 'message_count': len(recent_messages) + }) + return self._get_default_emotion_result() + + # 构建分析提示词 + conversation_text = self._format_conversation(recent_messages) + prompt = self._build_analysis_prompt( + conversation_text, + character_name, + character_settings, + is_initial=is_initial, + current_score=current_score + ) + + debug_logger.log_prompt('EmotionAnalyzer', 'user', prompt, { + 'message_count': len(recent_messages), + 'prompt_length': len(prompt) + }) + + try: + # 调用LLM进行分析 + debug_logger.log_info('EmotionAnalyzer', '调用LLM进行情感分析', { + 'model': self.model_name + }) + + result = self._call_llm(prompt) + + debug_logger.log_info('EmotionAnalyzer', 'LLM返回结果', { + 'result_length': len(result) + }) + + # 解析结果 + emotion_data = self._parse_emotion_result(result, is_initial=is_initial) + + # 处理累加评分 + if is_initial: + # 初次评估:使用返回的分数作为初始分数,并确保在0-35范围内 + final_score = max(0, min(35, emotion_data.get('overall_score', 0))) + emotion_data['overall_score'] = final_score + debug_logger.log_info('EmotionAnalyzer', '初次评估', { + 'initial_score': final_score + }) + else: + # 更新评估:当前分数 + 变化量,并确保变化量在-3到+3范围内 + score_change = emotion_data.get('score_change', 0) + score_change = max(-3, min(3, score_change)) + final_score = current_score + score_change + # 确保分数在合理范围内(0-100) + final_score = max(0, min(100, final_score)) + emotion_data['overall_score'] = final_score + emotion_data['previous_score'] = current_score + emotion_data['score_change'] = score_change + debug_logger.log_info('EmotionAnalyzer', '更新评估', { + 'previous_score': current_score, + 'score_change': score_change, + 'final_score': final_score + }) + + # 添加时间戳 + emotion_data['timestamp'] = datetime.now().isoformat() + emotion_data['message_count'] = len(recent_messages) + emotion_data['is_initial'] = is_initial + + # 保存到数据库 + self._save_emotion_to_db(emotion_data) + + debug_logger.log_info('EmotionAnalyzer', '情感分析完成', { + 'overall_score': emotion_data.get('overall_score', 0), + 'relationship_type': emotion_data.get('relationship_type', '未知'), + 'emotional_tone': emotion_data.get('emotional_tone', '未知') + }) + + return emotion_data + + except Exception as e: + debug_logger.log_error('EmotionAnalyzer', f'情感分析时出错: {str(e)}', e) + print(f"情感分析时出错: {e}") + return self._get_default_emotion_result() + + def _save_emotion_to_db(self, emotion_data: Dict[str, Any]): + """ + 保存情感分析结果到数据库 + + Args: + emotion_data: 情感分析数据 + """ + try: + # 安全地将评分字段转换为整数,避免类型错误 + def _safe_int(value, default=0): + try: + if value is None: + return default + return int(value) + except (TypeError, ValueError): + return default + + # 将印象和分析合并到analysis_summary中 + impression = emotion_data.get('impression', '') + analysis = emotion_data.get('analysis', '') + is_initial = emotion_data.get('is_initial', False) + + # 统一规范 overall_score,确保用于摘要和数据库时一致 + overall_score = _safe_int(emotion_data.get('overall_score'), 0) + + # 添加评分信息到摘要 + if is_initial: + score_info = f"【初始评分】{overall_score}/35\n\n" + else: + score_change = _safe_int(emotion_data.get('score_change'), None) + previous_score = _safe_int(emotion_data.get('previous_score'), None) + + if score_change is None or previous_score is None: + # 评分变化信息不完整或无效,降级为仅展示当前评分 + debug_logger.log_error( + 'EmotionAnalyzer', + '情感数据缺少或包含无效的评分变化字段,已降级为仅当前评分摘要' + ) + score_info = f"【当前评分】{overall_score}/100\n\n" + else: + score_info = f"【评分变化】{previous_score} → {overall_score} ({score_change:+d})\n\n" + + combined_summary = f"{score_info}【印象】\n{impression}\n\n【总结】\n{analysis}" + + self.db.add_emotion_analysis( + relationship_type=emotion_data.get('relationship_type', '未知'), + emotional_tone=emotion_data.get('emotional_tone', '未知'), + overall_score=overall_score, + intimacy=0, # 不再使用维度评分 + trust=0, + pleasure=0, + resonance=0, + dependence=0, + analysis_summary=combined_summary + ) + debug_logger.log_info('EmotionAnalyzer', '情感数据已保存到数据库') + except Exception as e: + debug_logger.log_error('EmotionAnalyzer', f'保存情感数据失败: {str(e)}', e) + print(f"✗ 保存情感数据到数据库时出错: {e}") + + def _migrate_from_json(self, json_file: str): + """ + 从JSON文件迁移情感数据到数据库 + + Args: + json_file: JSON文件路径 + """ + try: + with open(json_file, 'r', encoding='utf-8') as f: + emotion_history = json.load(f) + + count = 0 + for emotion_data in emotion_history: + try: + self.db.add_emotion_analysis( + relationship_type=emotion_data.get('relationship_type', '未知'), + emotional_tone=emotion_data.get('emotional_tone', '未知'), + overall_score=emotion_data.get('overall_score', 0), + intimacy=emotion_data.get('亲密度', 0), + trust=emotion_data.get('信任度', 0), + pleasure=emotion_data.get('愉悦度', 0), + resonance=emotion_data.get('共鸣度', 0), + dependence=emotion_data.get('依赖度', 0), + analysis_summary=emotion_data.get('analysis', '') + ) + count += 1 + except Exception as e: + print(f"✗ 迁移情感数据条目失败: {e}") + + print(f"✓ 迁移情感数据: {count}/{len(emotion_history)} 条") + except Exception as e: + print(f"✗ 从JSON迁移情感数据失败: {e}") + + def _format_conversation(self, messages: List[Dict[str, str]]) -> str: + """ + 格式化对话内容 + + Args: + messages: 消息列表 + + Returns: + 格式化后的对话文本 + """ + formatted = [] + for msg in messages: + role = "用户" if msg['role'] == 'user' else "AI" + content = msg['content'] + formatted.append(f"{role}: {content}") + + return "\n".join(formatted) + + def _build_analysis_prompt(self, conversation_text: str, character_name: str, + character_settings: str = "", is_initial: bool = True, + current_score: int = 0) -> str: + """ + 构建情感分析提示词(使用提示词模板) + + Args: + conversation_text: 对话文本 + character_name: AI角色名称 + character_settings: 角色设定描述 + is_initial: 是否为初次评估 + current_score: 当前累计分数 + + Returns: + 分析提示词 + """ + try: + from src.core.prompt_manager import get_prompt_manager + prompt_manager = get_prompt_manager() + + # 准备变量 + analysis_type = "初次评估" if is_initial else "更新评估" + variables = { + 'character_name': character_name, + 'character_settings': character_settings or "无特殊设定", + 'analysis_type': analysis_type, + 'conversation_text': conversation_text, + 'current_score': current_score + } + + # 加载并渲染模板 + prompt = prompt_manager.get_system_prompt('emotion_analysis', variables) + + # 根据评估类型附加具体指令 + if is_initial: + prompt += f""" + +【当前任务】 +这是初次评估,基于前5轮对话。 + +对话内容: +{conversation_text} + +请返回JSON格式的初次评估结果。""" + else: + prompt += f""" + +【当前任务】 +这是更新评估,基于最近15轮对话。 +当前累计分数:{current_score}/100 + +最近对话内容: +{conversation_text} + +请返回JSON格式的更新评估结果。""" + + return prompt + + except Exception as e: + # 如果模板加载失败,使用后备的硬编码提示词 + debug_logger.log_error('EmotionAnalyzer', f'加载提示词模板失败: {str(e)}', e) + return self._build_fallback_prompt(conversation_text, character_name, + character_settings, is_initial, current_score) + + def _build_fallback_prompt(self, conversation_text: str, character_name: str, + character_settings: str = "", is_initial: bool = True, + current_score: int = 0) -> str: + """ + 后备的硬编码提示词(兼容性) + + Args: + conversation_text: 对话文本 + character_name: AI角色名称 + character_settings: 角色设定描述 + is_initial: 是否为初次评估 + current_score: 当前累计分数 + + Returns: + 分析提示词 + """ + character_context = f"\n角色设定:\n{character_settings}\n" if character_settings else "" + + if is_initial: + # 初次评估提示词 + prompt = f"""你是AI角色"{character_name}",这是你和用户的初次见面。请基于你的角色设定和最近5轮对话,生成你对用户的初步印象。 +{character_context} +对话内容: +{conversation_text} + +请基于你的角色性格和对话内容,完成以下任务: +1. 生成你对用户的初步印象(150-200字),包括: + - 用户的第一印象 + - 用户的态度和表现 + - 对这次初识的感受 + +2. 给出初始情感评分(0-35分): + - 0-10分:非常负面的初印象(用户冷淡、敌对、无礼等) + - 11-20分:较负面的初印象(用户不够友好、态度消极等) + - 21-28分:中性初印象(普通的开始,无明显特点) + - 29-32分:较正面的初印象(用户友好、有礼貌等) + - 33-35分:非常正面的初印象(用户热情、真诚、令人愉快等) + +3. 概括关系类型和情感基调 + +请以JSON格式返回分析结果,格式如下: +{{ + "impression": "对用户的初步印象描述", + "overall_score": 初始评分(0-35), + "sentiment": "印象倾向(positive/neutral/negative)", + "relationship_type": "关系类型(如:初识、陌生人等)", + "emotional_tone": "整体情感基调(如:积极、中性、消极)", + "key_topics": ["主要话题1", "主要话题2"], + "analysis": "简短的关系总结(50字以内)" +}} + +只返回JSON,不要有其他内容。""" + else: + # 更新评估提示词 + prompt = f"""你是AI角色"{character_name}",你们已经有过一些交流了。请基于你的角色设定和最近15轮对话,更新你对用户的印象。 +{character_context} +当前累计情感分数:{current_score}/100 + +最近对话内容: +{conversation_text} + +请基于你的角色性格和最近的对话内容,完成以下任务: +1. 生成对最近交流的印象评价(150-200字),包括: + - 用户在最近对话中的表现 + - 相比之前的变化或延续 + - 对最近交流的感受 + +2. 根据最近对话的印象,给出分数变化(-3到+3分): + - -3分:最近表现很差(态度恶化、不尊重、冷漠等) + - -2分:表现较差(不够友好、兴趣降低等) + - -1分:略有不足(小问题、轻微不愉快等) + - 0分:保持现状(无明显变化) + - +1分:略有改善(更友好、更积极等) + - +2分:表现较好(明显改善、增进了解等) + - +3分:表现很好(大幅改善、深入交流、建立信任等) + +3. 更新关系类型和情感基调 + +请以JSON格式返回分析结果,格式如下: +{{ + "impression": "对最近交流的印象评价", + "score_change": 分数变化(-3到+3), + "sentiment": "印象倾向(positive/neutral/negative)", + "relationship_type": "更新后的关系类型", + "emotional_tone": "整体情感基调", + "key_topics": ["主要话题1", "主要话题2"], + "analysis": "简短的变化总结(50字以内)" +}} + +只返回JSON,不要有其他内容。""" + + return prompt + + def _call_llm(self, prompt: str) -> str: + """ + 调用LLM API(使用工具模型处理情感分析任务) + + Args: + prompt: 提示词 + + Returns: + LLM返回的文本 + """ + from src.core.llm_helper import LLMHelper + + debug_logger.log_info('EmotionAnalyzer', '调用工具模型进行情感分析') + + try: + # 使用工具模型处理情感分析(轻量级任务) + response = LLMHelper.call_tool_model( + system_prompt="你是一个情感分析专家,负责分析对话中的情感关系。", + user_message=prompt, + temperature=0.3, + max_tokens=1000 + ) + + debug_logger.log_info('EmotionAnalyzer', 'LLM调用成功', { + 'response_length': len(response) + }) + + return response + + except Exception as e: + debug_logger.log_error('EmotionAnalyzer', f'LLM调用异常: {str(e)}', e) + raise + + def _parse_emotion_result(self, result_text: str, is_initial: bool = True) -> Dict[str, Any]: + """ + 解析LLM返回的情感分析结果 + + Args: + result_text: LLM返回的文本 + is_initial: 是否为初次评估 + + Returns: + 解析后的情感数据 + """ + debug_logger.log_info('EmotionAnalyzer', '开始解析LLM返回结果', { + 'result_length': len(result_text), + 'is_initial': is_initial + }) + + try: + # 尝试提取JSON部分 + result_text = result_text.strip() + + # 如果有markdown代码块,去除 + if result_text.startswith('```'): + debug_logger.log_info('EmotionAnalyzer', '检测到markdown代码块,正在清理') + lines = result_text.split('\n') + result_text = '\n'.join(lines[1:-1]) if len(lines) > 2 else result_text + if result_text.startswith('json'): + result_text = result_text[4:].strip() + + # 解析JSON + emotion_data = json.loads(result_text) + + debug_logger.log_info('EmotionAnalyzer', 'JSON解析成功', { + 'keys': list(emotion_data.keys()) + }) + + # 确保所有必需字段存在 + if is_initial: + required_fields = { + "impression": "暂无印象描述", + "overall_score": 15, # 初次评估默认中间值 + "sentiment": "neutral", + "relationship_type": "初识", + "emotional_tone": "中性", + "key_topics": [], + "analysis": "暂无分析" + } + else: + required_fields = { + "impression": "暂无印象描述", + "score_change": 0, # 更新评估默认无变化 + "sentiment": "neutral", + "relationship_type": "普通朋友", + "emotional_tone": "中性", + "key_topics": [], + "analysis": "暂无分析" + } + + missing_fields = [] + for field, default_value in required_fields.items(): + if field not in emotion_data: + emotion_data[field] = default_value + missing_fields.append(field) + + if missing_fields: + debug_logger.log_info('EmotionAnalyzer', '补充缺失字段', { + 'missing_fields': missing_fields + }) + + return emotion_data + + except json.JSONDecodeError as e: + debug_logger.log_error('EmotionAnalyzer', f'JSON解析失败: {str(e)}', e) + debug_logger.log_info('EmotionAnalyzer', '原始结果内容', { + 'result_text': result_text[:500] # 只记录前500字符 + }) + print(f"解析情感结果时出错: {e}") + print(f"原始结果: {result_text}") + return self._get_default_emotion_result(is_initial) + except Exception as e: + debug_logger.log_error('EmotionAnalyzer', f'解析情感结果时出错: {str(e)}', e) + print(f"解析情感结果时出错: {e}") + print(f"原始结果: {result_text}") + return self._get_default_emotion_result(is_initial) + + def _get_default_emotion_result(self, is_initial: bool = True) -> Dict[str, Any]: + """ + 获取默认情感分析结果 + + Args: + is_initial: 是否为初次评估 + + Returns: + 默认情感数据 + """ + base_result = { + "impression": "对话轮数较少,尚未形成明确印象。用户表现正常,期待更多交流。", + "relationship_type": "初识", + "emotional_tone": "中性", + "key_topics": [], + "analysis": "对话刚开始,关系尚在建立初期。", + "timestamp": datetime.now().isoformat(), + "message_count": 0, + "is_initial": is_initial, + "sentiment": "neutral" + } + + if is_initial: + base_result["overall_score"] = 15 # 默认初始分数中间值 + else: + base_result["score_change"] = 0 # 默认无变化 + base_result["overall_score"] = 0 + base_result["previous_score"] = 0 + + return base_result + + def get_emotion_trend(self) -> List[Dict[str, Any]]: + """ + 获取情感关系变化趋势(从数据库) + + Returns: + 历史情感数据列表 + """ + emotion_history = self.db.get_emotion_history() + + # 转换为新格式 + result = [] + for emotion in emotion_history: + analysis_summary = emotion.get('analysis_summary', '') + # 尝试分离印象和总结 + impression = "" + analysis = "" + if "【印象】" in analysis_summary and "【总结】" in analysis_summary: + # 先移除可能存在的评分前缀行(如【初始评分】或【评分变化】) + cleaned_lines = [] + for line in analysis_summary.splitlines(): + if line.startswith("【初始评分】") or line.startswith("【评分变化】") or line.startswith("【当前评分】"): + continue + cleaned_lines.append(line) + cleaned_summary = "\n".join(cleaned_lines).lstrip() + + parts = cleaned_summary.split("【总结】", 1) + impression = parts[0].replace("【印象】", "").strip() + analysis = parts[1].strip() if len(parts) > 1 else "" + else: + # 旧格式或简单格式 + analysis = analysis_summary + impression = "暂无详细印象" + + result.append({ + "impression": impression, + "overall_score": emotion.get('overall_score', 0), + "sentiment": self._score_to_sentiment(emotion.get('overall_score', 50)), + "relationship_type": emotion.get('relationship_type', '未知'), + "emotional_tone": emotion.get('emotional_tone', '未知'), + "analysis": analysis, + "timestamp": emotion.get('created_at', ''), + "uuid": emotion.get('uuid', '') + }) + + return result + + def _score_to_sentiment(self, score: int) -> str: + """ + 将评分转换为情感倾向 + + Args: + score: 评分值,可以是 0-100 或 0-35 的评分 + + Returns: + 情感倾向:positive/neutral/negative + """ + # 处理空值,避免异常 + if score is None: + return "neutral" + + # 如果评分在 0-35 之间,认为是初始评估的 0-35 制分数,先归一化到 0-100 + if 0 <= score <= 35: + normalized_score = round(score / 35 * 100) + else: + normalized_score = score + + # 简单限制范围,防止异常值影响结果 + if normalized_score < 0: + normalized_score = 0 + elif normalized_score > 100: + normalized_score = 100 + + if normalized_score >= 61: + return "positive" + elif normalized_score >= 41: + return "neutral" + else: + return "negative" + + def get_latest_emotion(self) -> Optional[Dict[str, Any]]: + """ + 获取最新的情感分析结果(从数据库) + + Returns: + 最新情感数据,如果没有则返回None + """ + latest = self.db.get_latest_emotion() + + if not latest: + return None + + # 转换为新格式 + analysis_summary = latest.get('analysis_summary', '') + impression = "" + analysis = "" + if "【印象】" in analysis_summary and "【总结】" in analysis_summary: + # 先移除可能存在的评分前缀行(如【初始评分】或【评分变化】) + cleaned_lines = [] + for line in analysis_summary.splitlines(): + if line.startswith("【初始评分】") or line.startswith("【评分变化】") or line.startswith("【当前评分】"): + continue + cleaned_lines.append(line) + cleaned_summary = "\n".join(cleaned_lines).lstrip() + + parts = cleaned_summary.split("【总结】", 1) + impression = parts[0].replace("【印象】", "").strip() + analysis = parts[1].strip() if len(parts) > 1 else "" + else: + # 旧格式或简单格式 + analysis = analysis_summary + impression = "暂无详细印象" + + return { + "impression": impression, + "overall_score": latest.get('overall_score', 0), + "sentiment": self._score_to_sentiment(latest.get('overall_score', 50)), + "relationship_type": latest.get('relationship_type', '未知'), + "emotional_tone": latest.get('emotional_tone', '未知'), + "analysis": analysis, + "timestamp": latest.get('created_at', ''), + "uuid": latest.get('uuid', '') + } + + # ==================== Stage B.3 Web 后端兼容接口 ==================== + + # Plutchik 8 维情绪键(与 PlutchikEmotionWheel 保持一致) + PLUTCHIK_KEYS = ( + 'joy', 'trust', 'fear', 'surprise', + 'sadness', 'disgust', 'anger', 'anticipation', + ) + PLUTCHIK_CN = { + 'joy': '喜悦', 'trust': '信任', 'fear': '恐惧', 'surprise': '惊讶', + 'sadness': '悲伤', 'disgust': '厌恶', 'anger': '愤怒', 'anticipation': '期待', + } + + def _derive_plutchik_from_legacy( + self, overall_score: int, emotional_tone: str, + ) -> Dict[str, float]: + """ + 从旧的累加评分(overall_score)+ emotional_tone 推导 Plutchik 8 维(0-1)。 + + 用于 Stage B.3: 在 emotion_history 表未存 Plutchik 原始数据时, + 提供合理的可视化默认值;不修改现有 emotion_history 表 schema。 + """ + try: + score = float(overall_score or 0) + except (TypeError, ValueError): + score = 0.0 + # 0-35 视作初始评估制,先归一到 0-100 + if 0.0 <= score <= 35.0: + score = score / 35.0 * 100.0 + score = max(0.0, min(100.0, score)) + norm = score / 100.0 # 0-1 基础强度 + + tone = (emotional_tone or '中性').strip() + # 默认 8 维分配:积极高 / 消极低 / 中性均匀 + base = { + 'joy': 0.3, 'trust': 0.3, 'fear': 0.1, 'surprise': 0.1, + 'sadness': 0.1, 'disgust': 0.1, 'anger': 0.1, 'anticipation': 0.3, + } + if tone in ('积极', '正面', '乐观'): + base.update({ + 'joy': 0.6, 'trust': 0.6, 'anticipation': 0.5, + 'fear': 0.05, 'sadness': 0.05, 'disgust': 0.05, 'anger': 0.05, + }) + elif tone in ('消极', '负面', '悲观'): + base.update({ + 'joy': 0.1, 'trust': 0.2, 'anticipation': 0.1, + 'fear': 0.5, 'sadness': 0.6, 'disgust': 0.4, 'anger': 0.5, + }) + else: # 中性 / 未知 + for k in base: + base[k] = 0.2 + 0.1 * norm + + # 用 norm 缩放(高分 → 整体更强) + scale = 0.5 + 0.5 * norm + out = {k: max(0.0, min(1.0, float(v) * scale)) for k, v in base.items()} + return out + + def get_latest_for_user(self, user_id: str = "default") -> Dict[str, Any]: + """ + 获取指定用户的最新情感数据(Web 后端专用)。 + + 返回结构(对齐前端 EmotionPanel.tsx + Pydantic EmotionLatestResponse): + - timestamp: ISO 8601 字符串 + - additive_scores: Plutchik 8 维累加评分(dict,每维 0-100) + - plutchik: Plutchik 8 维强度(dict,每维 0-1) + - dominant: 主导情感中文名 + + 设计: + - 不破坏现有 ``get_latest_emotion()`` 方法签名; + - 复用 ``self.get_latest_emotion()`` 拉取数据; + - 在 emotion_history 表未存 Plutchik 原始 8 维数据时, + 基于 overall_score + emotional_tone 派生合理默认; + - 失败 / 无记录时返回 ``{}``(路由层保持 200 + 空 dict)。 + """ + try: + latest = self.get_latest_emotion() + except Exception: # noqa: BLE001 + return {} + + if not latest: + return {} + + try: + overall_score = int(latest.get('overall_score') or 0) + except (TypeError, ValueError): + overall_score = 0 + emotional_tone = latest.get('emotional_tone', '中性') or '中性' + timestamp = latest.get('timestamp') or latest.get('created_at') or '' + + plutchik = self._derive_plutchik_from_legacy(overall_score, emotional_tone) + # additive_scores:把 0-1 强度乘回 0-100,与前端雷达图 max=10/100 兼容 + additive = {k: round(float(v) * 100.0, 2) for k, v in plutchik.items()} + + try: + dominant_key = max(plutchik.items(), key=lambda kv: kv[1])[0] + except Exception: # noqa: BLE001 + dominant_key = 'joy' + dominant = self.PLUTCHIK_CN.get(dominant_key, dominant_key) + # 整体强度过低 → 标记为"平静" + if max(plutchik.values()) < 0.15: + dominant = '平静' + + return { + 'timestamp': timestamp, + 'additive_scores': additive, + 'plutchik': {k: round(float(v), 3) for k, v in plutchik.items()}, + 'dominant': dominant, + } + + def generate_tone_prompt(self) -> str: + """ + 根据最新情感分析生成对话语气提示 + + Returns: + 语气提示文本,如果没有情感数据则返回空字符串 + """ + latest_emotion = self.get_latest_emotion() + if not latest_emotion: + debug_logger.log_info('EmotionAnalyzer', '无情感数据,无法生成语气提示') + return "" + + debug_logger.log_info('EmotionAnalyzer', '开始生成情感语气提示', { + 'has_emotion_data': True + }) + + # 提取关键信息 + relationship_type = latest_emotion.get('relationship_type', '普通朋友') + emotional_tone = latest_emotion.get('emotional_tone', '中性') + overall_score = latest_emotion.get('overall_score', 50) + sentiment = latest_emotion.get('sentiment', 'neutral') + impression = latest_emotion.get('impression', '') + + debug_logger.log_info('EmotionAnalyzer', '提取情感数据', { + 'relationship_type': relationship_type, + 'emotional_tone': emotional_tone, + 'overall_score': overall_score, + 'sentiment': sentiment + }) + + # 构建语气提示 + prompt_parts = ["\n【当前情感关系状态】"] + prompt_parts.append(f"你和用户的关系类型:{relationship_type}") + prompt_parts.append(f"整体情感基调:{emotional_tone}") + prompt_parts.append(f"关系总评分:{overall_score}/100") + + # 添加印象摘要 + if impression: + # 取印象的前100字作为摘要 + impression_summary = impression[:100] + "..." if len(impression) > 100 else impression + prompt_parts.append(f"\n【对用户的印象】\n{impression_summary}") + + # 添加对话建议 + prompt_parts.append("\n【对话语气建议】") + + # 根据评分给出建议 + if overall_score >= 81: + prompt_parts.append("• 关系非常好,用户对你印象很正面") + prompt_parts.append("• 可以更自在地表达,像好友一样交流") + prompt_parts.append("• 继续保持热情积极的态度") + elif overall_score >= 61: + prompt_parts.append("• 关系较好,用户对你印象偏正面") + prompt_parts.append("• 可以适当分享更多个人想法") + prompt_parts.append("• 保持友好但不过分亲密") + elif overall_score >= 41: + prompt_parts.append("• 关系一般,印象中性") + prompt_parts.append("• 保持礼貌友好的态度") + prompt_parts.append("• 需要更用心经营关系") + elif overall_score >= 21: + prompt_parts.append("• 印象偏负面,需要改善") + prompt_parts.append("• 更加关注用户需求和感受") + prompt_parts.append("• 避免可能引起反感的行为") + else: + prompt_parts.append("• 印象很负面,关系需要修复") + prompt_parts.append("• 更谨慎地选择用词和态度") + prompt_parts.append("• 努力重建信任") + + # 根据情感基调调整语气 + if emotional_tone == "积极": + prompt_parts.append("• 保持积极乐观的态度,继续营造愉快氛围") + elif emotional_tone == "消极": + prompt_parts.append("• 注意用户情绪,给予更多关心和支持") + else: + prompt_parts.append("• 保持友好平和的态度") + + # 根据关系类型调整语气 + if relationship_type in ["知己", "亲密朋友", "好友"]: + prompt_parts.append("• 可以使用更亲昵的语气,像老朋友一样交流") + elif relationship_type in ["朋友", "熟人"]: + prompt_parts.append("• 保持友好但不过分亲密的语气") + elif relationship_type in ["初识", "陌生人"]: + prompt_parts.append("• 保持礼貌友好,逐步建立信任") + + prompt_parts.append("\n⚠️ 请根据以上印象和状态调整你的回复语气和态度,使对话更自然、更贴合当前关系。") + + tone_prompt = '\n'.join(prompt_parts) + + debug_logger.log_info('EmotionAnalyzer', '情感语气提示生成完成', { + 'prompt_length': len(tone_prompt), + 'relationship_type': relationship_type, + 'overall_score': overall_score + }) + + return tone_prompt + + def export_emotion_data(self, filepath: str): + """ + 导出情感数据到文件 + + Args: + filepath: 导出文件路径 + """ + debug_logger.log_info('EmotionAnalyzer', '开始导出情感数据', { + 'filepath': filepath, + 'data_count': len(self.emotion_history) + }) + + try: + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(self.emotion_history, f, ensure_ascii=False, indent=2) + + debug_logger.log_info('EmotionAnalyzer', '情感数据导出成功', { + 'filepath': filepath + }) + print(f"情感数据已导出到: {filepath}") + except Exception as e: + debug_logger.log_error('EmotionAnalyzer', f'导出情感数据时出错: {str(e)}', e) + print(f"导出情感数据时出错: {e}") + + def import_emotion_data(self, filepath: str): + """ + 从文件导入情感数据 + + Args: + filepath: 导入文件路径 + """ + debug_logger.log_info('EmotionAnalyzer', '开始导入情感数据', { + 'filepath': filepath + }) + + try: + if os.path.exists(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + self.emotion_history = json.load(f) + + debug_logger.log_info('EmotionAnalyzer', '情感数据导入成功', { + 'filepath': filepath, + 'data_count': len(self.emotion_history) + }) + print(f"情感数据已从文件加载: {len(self.emotion_history)} 条记录") + else: + debug_logger.log_info('EmotionAnalyzer', '导入文件不存在', { + 'filepath': filepath + }) + except Exception as e: + debug_logger.log_error('EmotionAnalyzer', f'导入情感数据时出错: {str(e)}', e) + print(f"导入情感数据时出错: {e}") + + +def format_emotion_summary(emotion_data: Dict[str, Any]) -> str: + """ + 格式化情感分析结果为可读文本 + + Args: + emotion_data: 情感数据 + + Returns: + 格式化后的文本 + """ + impression = emotion_data.get('impression', '暂无印象') + is_initial = emotion_data.get('is_initial', False) + + # 评分信息 + if is_initial: + score_text = f"初始评分: {emotion_data.get('overall_score', 0)}/35" + else: + score_change = emotion_data.get('score_change', 0) + previous_score = emotion_data.get('previous_score', 0) + current_score = emotion_data.get('overall_score', 0) + score_text = f"累计评分: {current_score}/100 (上次: {previous_score}, 变化: {score_change:+d})" + + summary = f""" +【情感关系分析】 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +关系类型: {emotion_data.get('relationship_type', '未知')} +情感基调: {emotion_data.get('emotional_tone', '未知')} +{score_text} +印象倾向: {emotion_data.get('sentiment', 'neutral')} + +【对用户的印象】 +{impression} + +【关系总结】 +{emotion_data.get('analysis', '暂无分析')} + +【主要话题】 +{', '.join(emotion_data.get('key_topics', [])) if emotion_data.get('key_topics') else '暂无'} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + """ + return summary.strip() + + +# ==================== P2: PlutchikEmotionWheel ==================== + +class PlutchikEmotionWheel: + """ + Plutchik 情绪轮(8 基本情绪)。 + 与累加评分(EmotionRelationshipAnalyzer)并行:累加 = 关系状态,Plutchik = 当下情绪。 + 情绪随时间衰减;支持复合表达("开心但有点紧张")。 + """ + + EMOTIONS = [ + 'joy', 'trust', 'fear', 'surprise', + 'sadness', 'disgust', 'anger', 'anticipation', + ] + EMOTION_CN = { + 'joy': '喜悦', 'trust': '信任', 'fear': '恐惧', 'surprise': '惊讶', + 'sadness': '悲伤', 'disgust': '厌恶', 'anger': '愤怒', 'anticipation': '期待', + } + OPPOSITES = { + 'joy': 'sadness', 'sadness': 'joy', + 'trust': 'disgust', 'disgust': 'trust', + 'fear': 'anger', 'anger': 'fear', + 'surprise': 'anticipation', 'anticipation': 'surprise', + } + DEFAULT_HALF_LIFE_HOURS = 24.0 + + def __init__(self, db_manager: DatabaseManager = None, + half_life_hours: float = DEFAULT_HALF_LIFE_HOURS): + """ + Args: + db_manager: 数据库管理器 + half_life_hours: 情绪半衰期(小时) + """ + self.db = db_manager + self.half_life_hours = float(half_life_hours) + self.enabled = ENABLE_EMOTION_WHEEL + + def decay_emotions(self, state: Dict[str, Any], now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 按指数衰减应用至 state['emotions']。 + state['emotions'] 为 {emotion_key: intensity(0-1)} + state['last_update'] 为 ISO 时间字符串。 + """ + if not state or 'emotions' not in state: + return self._default_state(now) + now = now or datetime.now() + last = self._parse_iso(state.get('last_update')) or now + elapsed_h = max(0.0, (now - last).total_seconds() / 3600.0) + if elapsed_h <= 0: + return state + factor = math.pow(0.5, elapsed_h / self.half_life_hours) + new_emotions = {} + for k, v in (state.get('emotions') or {}).items(): + try: + new_emotions[k] = max(0.0, min(1.0, float(v) * factor)) + except (TypeError, ValueError): + continue + state['emotions'] = new_emotions + state['last_update'] = now.isoformat() + return state + + def nudge_emotion(self, emotions: Dict[str, float], + key: str, delta: float) -> Dict[str, float]: + """ + 对单个情绪做 +/- 调整,clamp 至 [0, 1]。 + """ + if key not in self.EMOTIONS: + return emotions + out = dict(emotions or {}) + out[key] = max(0.0, min(1.0, float(out.get(key, 0.0)) + float(delta))) + return out + + def profile_from_basic(self, emotions: Dict[str, float], + now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 从 8 基本情绪推导 profile: + - primary: 主导情绪 + - secondary: 第二情绪 + - intensity: 平均强度 + - compound_desc: 复合表达("开心但有点紧张") + - tone_label: 1-3 词标签 + """ + if not emotions: + return { + 'primary': '平静', 'secondary': None, 'intensity': 0.0, + 'compound_desc': '平静', 'tone_label': '平静', + } + sorted_items = sorted( + emotions.items(), key=lambda kv: kv[1], reverse=True + ) + primary_k, primary_v = sorted_items[0] + primary_cn = self.EMOTION_CN.get(primary_k, primary_k) + + secondary_cn = None + if len(sorted_items) > 1 and sorted_items[1][1] >= 0.3: + secondary_cn = self.EMOTION_CN.get(sorted_items[1][0], sorted_items[1][0]) + + avg = sum(emotions.values()) / max(1, len(emotions)) + compound = primary_cn + if secondary_cn and primary_v > 0.3 and sorted_items[1][1] >= 0.3: + compound = f"{primary_cn}但有点{secondary_cn}" + + if avg < 0.15: + tone = '平静' + elif avg < 0.4: + tone = primary_cn + elif avg < 0.7: + tone = f"明显{primary_cn}" + else: + tone = f"强烈{primary_cn}" + + return { + 'primary': primary_cn, 'secondary': secondary_cn, + 'intensity': round(avg, 3), 'compound_desc': compound, + 'tone_label': tone, + } + + def format_emotion_hint(self, state: Dict[str, Any]) -> str: + """ + 渲染为 prompt 文本(与 generate_tone_prompt 平行注入)。 + """ + if not self.enabled: + return "" + if not state or not state.get('emotions'): + return "" + profile = self.profile_from_basic(state.get('emotions', {})) + lines = ["【当下情绪】"] + lines.append(f"主导:{profile['primary']}(强度 {profile['intensity']:.2f})") + if profile.get('secondary'): + lines.append(f"次要:{profile['secondary']}") + lines.append(f"综合:{profile['compound_desc']}") + lines.append(f"语气:{profile['tone_label']}") + return "\n".join(lines) + + def _default_state(self, now: Optional[datetime] = None) -> Dict[str, Any]: + now = now or datetime.now() + return { + 'emotions': {k: 0.0 for k in self.EMOTIONS}, + 'last_update': now.isoformat(), + } + + @staticmethod + def _parse_iso(value: Any) -> Optional[datetime]: + if not value: + return None + if isinstance(value, datetime): + return value + try: + return datetime.fromisoformat(str(value)) + except (TypeError, ValueError): + return None + + +__all__ = [ + "EmotionRelationshipAnalyzer", + "PlutchikEmotionWheel", + "format_emotion_summary", + "ENABLE_EMOTION_WHEEL", +] diff --git a/src/limbic/amygdala/module.py b/src/limbic/amygdala/module.py new file mode 100644 index 0000000..732edbb --- /dev/null +++ b/src/limbic/amygdala/module.py @@ -0,0 +1,126 @@ +""" +AmygdalaModule - 杏仁核模块(v4.0 神经系统接入层)。 + +将 src.limbic.amygdala.emotion_state 中的情感分析能力包装为 BaseModule, +使其可以通过 CentralRouter 被其他模块调用。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List + +from src.limbic.amygdala.emotion_state import ( + EmotionRelationshipAnalyzer, + PlutchikEmotionWheel, +) +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +class AmygdalaModule(BaseModule): + """ + 杏仁核模块。 + + 负责情感关系分析(累加评分)和当下情绪(Plutchik 8 维)。 + """ + + module_id = "limbic.amygdala" + module_type = "limbic" + + def __init__(self, router: "CentralRouter") -> None: + super().__init__(router) + self._analyzer: EmotionRelationshipAnalyzer | None = None + self._wheel: PlutchikEmotionWheel | None = None + + async def initialize(self) -> None: + self._analyzer = EmotionRelationshipAnalyzer() + self._wheel = PlutchikEmotionWheel() + await super().initialize() + + async def shutdown(self) -> None: + self._analyzer = None + self._wheel = None + await super().shutdown() + + async def handle(self, packet: Packet) -> Packet: + """ + 处理情感相关请求。 + + Channels: + - emotion_analyze: 分析对话情感关系 + - emotion_latest: 获取最新情感数据 + - emotion_trend: 获取情感趋势 + - emotion_tone: 生成语气提示 + - emotion_wheel_profile: 从 8 维情绪推导 profile + """ + channel = packet.channel + payload = packet.payload + + if channel == "emotion_analyze": + return self._handle_analyze(packet, payload) + if channel == "emotion_latest": + return self._handle_latest(packet, payload) + if channel == "emotion_trend": + return self._handle_trend(packet, payload) + if channel == "emotion_tone": + return self._handle_tone(packet, payload) + if channel == "emotion_wheel_profile": + return self._handle_wheel_profile(packet, payload) + + return packet.response({ + "status": "unknown_channel", + "channel": channel, + }) + + def _handle_analyze(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + messages: List[Dict[str, str]] = payload.get("messages", []) + character_name: str = payload.get("character_name", "AI") + character_settings: str = payload.get("character_settings", "") + is_initial = payload.get("is_initial") + + if self._analyzer is None: + return packet.error("AmygdalaModule not initialized", code="NOT_INITIALIZED") + + result = self._analyzer.analyze_emotion_relationship( + messages=messages, + character_name=character_name, + character_settings=character_settings, + is_initial=is_initial, + ) + return packet.response(result) + + def _handle_latest(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + if self._analyzer is None: + return packet.error("AmygdalaModule not initialized", code="NOT_INITIALIZED") + + user_id: str = payload.get("user_id", "default") + result = self._analyzer.get_latest_for_user(user_id) + return packet.response(result) + + def _handle_trend(self, packet: Packet, _payload: Dict[str, Any]) -> Packet: + if self._analyzer is None: + return packet.error("AmygdalaModule not initialized", code="NOT_INITIALIZED") + + result = self._analyzer.get_emotion_trend() + return packet.response({"trend": result}) + + def _handle_tone(self, packet: Packet, _payload: Dict[str, Any]) -> Packet: + if self._analyzer is None: + return packet.error("AmygdalaModule not initialized", code="NOT_INITIALIZED") + + result = self._analyzer.generate_tone_prompt() + return packet.response({"tone_prompt": result}) + + def _handle_wheel_profile(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + emotions: Dict[str, float] = payload.get("emotions", {}) + if self._wheel is None: + return packet.error("AmygdalaModule not initialized", code="NOT_INITIALIZED") + + profile = self._wheel.profile_from_basic(emotions) + return packet.response(profile) + + +__all__ = ["AmygdalaModule"] diff --git a/src/limbic/hippocampus/__init__.py b/src/limbic/hippocampus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/limbic/hippocampus/base_knowledge.py b/src/limbic/hippocampus/base_knowledge.py new file mode 100644 index 0000000..79e93b2 --- /dev/null +++ b/src/limbic/hippocampus/base_knowledge.py @@ -0,0 +1,262 @@ +""" +基础知识库模块 +存储智能体的核心基础知识,这些知识: +1. 拥有最高优先级(100%正确) +2. 不可被覆盖或更改 +3. 即使与现实相悖也以该知识为准 +4. 不以上下文形式存在,而是直接嵌入到提示词中 +""" + +import os +from typing import Dict, List, Any +from src.core.database_manager import DatabaseManager + + +class BaseKnowledge: + """ + 基础知识库管理器 + 管理智能体的核心基础知识,这些知识具有绝对权威性 + 使用数据库存储替代JSON文件 + """ + + def __init__(self, db_manager: DatabaseManager = None): + """ + 初始化基础知识库 + + Args: + db_manager: 数据库管理器实例(如果为None则创建新实例) + """ + self.db = db_manager or DatabaseManager() + + # 检查是否需要初始化默认知识 + base_facts = self.db.get_all_base_facts() + if not base_facts: + self._initialize_default_knowledge() + + # 检查是否需要从JSON迁移数据 + if os.path.exists('base_knowledge.json'): + print("○ 检测到旧的JSON文件,正在迁移到数据库...") + self.db.migrate_from_json('base_knowledge.json', 'base_knowledge') + # 迁移完成后可以选择重命名或删除JSON文件 + os.rename('base_knowledge.json', 'base_knowledge.json.bak') + print("✓ JSON文件已备份为 base_knowledge.json.bak") + + def _initialize_default_knowledge(self): + """ + 初始化默认的基础知识 + """ + print("○ 初始化默认基础知识...") + + # 添加默认知识:HeDaas是一个高校 + self.add_base_fact( + entity_name="HeDaas", + fact_content="HeDaas是一个高校", + category="机构类型", + description="HeDaas的基本定义", + immutable=True + ) + + print("✓ 默认基础知识已初始化") + + + def add_base_fact(self, + entity_name: str, + fact_content: str, + category: str = "通用", + description: str = "", + immutable: bool = True) -> bool: + """ + 添加一条基础事实到基础知识库 + + Args: + entity_name: 实体名称(如"HeDaas") + fact_content: 事实内容(如"HeDaas是一个高校") + category: 分类(如"机构类型"、"定义"等) + description: 描述说明 + immutable: 是否不可变(默认True,基础知识应该不可变) + + Returns: + 是否添加成功 + """ + try: + # 检查是否已存在 + existing = self.db.get_base_fact(entity_name) + if existing: + print(f"⚠ 基础事实已存在: {entity_name}") + print(f" 现有内容: {existing['content']}") + print(f" → 基础知识不可覆盖,保持原有内容") + return False + + # 添加基础事实 + result = self.db.add_base_fact( + entity_name=entity_name, + content=fact_content, + category=category, + description=description, + immutable=immutable + ) + + if result: + print(f"✓ 已添加基础事实: {entity_name} -> {fact_content}") + return result + + except Exception as e: + print(f"✗ 添加基础事实时出错: {e}") + return False + + def get_base_fact(self, entity_name: str) -> Dict[str, Any] | None: + """ + 获取指定实体的基础事实(支持不区分大小写的查找) + + Args: + entity_name: 实体名称 + + Returns: + 基础事实字典,如果不存在返回None + """ + return self.db.get_base_fact(entity_name) + + def check_conflict_with_base(self, entity_name: str, new_content: str) -> bool: + """ + 检查新知识是否与基础知识冲突 + + Args: + entity_name: 实体名称 + new_content: 新的知识内容 + + Returns: + True表示存在冲突,False表示无冲突 + """ + base_fact = self.db.get_base_fact(entity_name) + + if not base_fact: + return False + + + # 如果新内容与基础事实不同,则存在冲突 + if new_content.strip() != base_fact['content'].strip(): + print(f"⚠ 检测到与基础知识冲突:") + print(f" 实体: {entity_name}") + print(f" 基础知识: {base_fact['content']}") + print(f" 新知识: {new_content}") + print(f" → 基础知识优先级最高,将保持基础知识不变") + return True + + return False + + def get_all_base_facts(self) -> List[Dict[str, Any]]: + """ + 获取所有基础事实 + + Returns: + 基础事实列表 + """ + return self.db.get_all_base_facts() + + def generate_base_knowledge_prompt(self) -> str: + """ + 生成用于嵌入提示词的基础知识文本 + 这些知识将直接嵌入到系统提示词中,确保AI始终遵循 + + Returns: + 基础知识提示词文本 + """ + base_facts = self.get_all_base_facts() + + if not base_facts: + return "" + + prompt_parts = [ + "\n【核心基础知识 - 最高优先级】", + "以下是你必须遵守的核心基础知识,这些知识具有绝对权威性:", + "- 优先级:100%(最高)", + "- 准确性:100%(绝对正确)", + "- 即使与其他信息相悖,也必须以这些基础知识为准", + "" + ] + + # 按分类组织基础知识 + by_category = {} + for fact in base_facts: + category = fact.get('category', '通用') + if category not in by_category: + by_category[category] = [] + by_category[category].append(fact) + + # 生成每个分类的知识 + for category, facts in by_category.items(): + prompt_parts.append(f"[{category}]") + for fact in facts: + prompt_parts.append(f"• {fact['content']}") + if fact.get('description'): + prompt_parts.append(f" 说明: {fact['description']}") + prompt_parts.append("") + + prompt_parts.append("请在所有回答中严格遵循以上基础知识。") + prompt_parts.append("=" * 60) + + return "\n".join(prompt_parts) + + def get_statistics(self) -> Dict[str, Any]: + """ + 获取基础知识库统计信息 + + Returns: + 统计信息字典 + """ + base_facts = self.get_all_base_facts() + + # 按分类统计 + by_category = {} + for fact in base_facts: + category = fact.get('category', '通用') + by_category[category] = by_category.get(category, 0) + 1 + + return { + 'total_facts': len(base_facts), + 'category_distribution': by_category, + 'all_immutable': all(f.get('immutable', True) for f in base_facts) + } + + def remove_base_fact(self, entity_name: str) -> bool: + """ + 删除基础事实(谨慎使用,基础知识一般不应删除) + + Args: + entity_name: 实体名称 + + Returns: + 是否删除成功 + """ + return self.db.delete_base_fact(entity_name) + + +if __name__ == '__main__': + print("=" * 60) + print("基础知识库测试") + print("=" * 60) + + # 创建基础知识库实例 + base_kb = BaseKnowledge() + + # 显示统计信息 + print("\n基础知识库统计:") + stats = base_kb.get_statistics() + print(f"总事实数: {stats['total_facts']}") + print(f"分类分布: {stats['category_distribution']}") + print(f"全部不可变: {stats['all_immutable']}") + + # 显示所有基础事实 + print("\n所有基础事实:") + for fact in base_kb.get_all_base_facts(): + print(f"• {fact['entity_name']}: {fact['content']}") + print(f" 分类: {fact['category']}, 优先级: {fact['priority']}, 置信度: {fact['confidence']}") + + # 生成基础知识提示词 + print("\n生成的基础知识提示词:") + print(base_kb.generate_base_knowledge_prompt()) + + # 测试冲突检测 + print("\n测试冲突检测:") + has_conflict = base_kb.check_conflict_with_base("HeDaas", "HeDaas是一家公司") + print(f"冲突检测结果: {has_conflict}") diff --git a/src/limbic/hippocampus/knowledge_store.py b/src/limbic/hippocampus/knowledge_store.py new file mode 100644 index 0000000..adea82b --- /dev/null +++ b/src/limbic/hippocampus/knowledge_store.py @@ -0,0 +1,847 @@ +""" +知识库管理模块 +负责从对话中提取和管理知识性记忆 +实现主体-定义-信息的三层绑定结构,支持冲突检测和置信度管理 +""" + +import os +import json +import uuid +from datetime import datetime +from typing import List, Dict, Any, Optional +from dotenv import load_dotenv +import requests +from src.core.database_manager import DatabaseManager +from src.limbic.hippocampus.base_knowledge import BaseKnowledge +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class KnowledgeBase: + """ + 知识库管理器 + 每5轮对话提取一次知识,形成结构化的知识性记忆 + 使用数据库替代JSON文件存储 + + 数据结构: + - entities: 主体字典 {entity_uuid: entity_data} + - 每个主体有唯一UUID + - 每个主体只能有一个定义(definition) + - 每个主体可以有多个相关信息(related_info) + """ + + def __init__(self, + db_manager: DatabaseManager = None, + api_key: str = None, + api_url: str = None, + model_name: str = None): + """ + 初始化知识库管理器(使用LangChain架构) + + Args: + db_manager: 数据库管理器实例(如果为None则创建新实例) + api_key: API密钥(保留用于向后兼容,实际不使用) + api_url: API地址(保留用于向后兼容,实际不使用) + model_name: 模型名称(保留用于向后兼容,实际不使用) + """ + # 使用共享的数据库管理器 + self.db = db_manager or DatabaseManager() + + # 初始化基础知识库(共享数据库管理器) + self.base_knowledge = BaseKnowledge(db_manager=self.db) + + # 检查是否需要从JSON迁移数据 + if os.path.exists('knowledge_base.json'): + print("○ 检测到旧的知识库JSON文件,正在迁移到数据库...") + self.db.migrate_from_json('knowledge_base.json', 'knowledge_base') + os.rename('knowledge_base.json', 'knowledge_base.json.bak') + print("✓ JSON文件已备份为 knowledge_base.json.bak") + + print(f"✓ 知识库已初始化(使用数据库存储,基于LangChain)") + + + + def _find_or_create_entity(self, entity_name: str) -> str: + """ + 查找或创建主体(使用数据库) + + Args: + entity_name: 主体名称 + + Returns: + 主体UUID + """ + return self.db.find_or_create_entity(entity_name) + + def _add_or_update_entity_definition(self, + entity_name: str, + definition: str, + knowledge_type: str = '定义', + source: str = '', + created_at: str = None, + confidence: float = 1.0) -> str: + """ + 添加或更新主体的定义(每个主体只能有一个定义,新定义会覆盖旧定义) + 但基础知识库中的定义拥有最高优先级,不会被覆盖 + 使用数据库存储 + + Args: + entity_name: 主体名称 + definition: 定义内容 + knowledge_type: 知识类型 + source: 来源 + created_at: 创建时间(用于迁移旧数据) + confidence: 置信度(0-1之间,定义的置信度高于相关信息) + + Returns: + 主体UUID + """ + # 检查是否与基础知识冲突 + if self.base_knowledge.check_conflict_with_base(entity_name, definition): + print(f"⚠ 由于与基础知识冲突,拒绝添加/更新定义: {entity_name}") + print(f" → 保持基础知识不变") + + # 如果实体不存在,创建它但使用基础知识作为定义 + base_fact = self.base_knowledge.get_base_fact(entity_name) + if base_fact: + entity_uuid = self._find_or_create_entity(entity_name) + + # 使用基础知识作为定义(存入数据库) + existing_def = self.db.get_entity_definition(entity_uuid) + if not existing_def: + self.db.set_entity_definition( + entity_uuid=entity_uuid, + content=base_fact['content'], + type_='基础知识', + source='基础知识库', + confidence=1.0, + priority=100, + is_base_knowledge=True + ) + return entity_uuid + + # 查找或创建实体 + entity_uuid = self._find_or_create_entity(entity_name) + + # 检查当前定义是否为基础知识 + existing_def = self.db.get_entity_definition(entity_uuid) + if existing_def and existing_def.get('is_base_knowledge'): + print(f"⚠ 主体 '{entity_name}' 的定义来自基础知识库,不可更改") + print(f" 基础知识: {existing_def['content']}") + print(f" 尝试更新为: {definition}") + print(f" → 保持基础知识不变(优先级100)") + return entity_uuid + + # 检查是否存在旧定义 + if existing_def: + print(f"⚠ 主体 '{entity_name}' 的定义发生冲突:") + print(f" 旧定义: {existing_def.get('content', '')[:50]}...") + print(f" 新定义: {definition[:50]}...") + print(f" → 采用新定义(置信度: {confidence})") + + # 设置新定义(会覆盖旧定义) + self.db.set_entity_definition( + entity_uuid=entity_uuid, + content=definition, + type_=knowledge_type, + source=source, + confidence=confidence, + priority=50, + is_base_knowledge=False + ) + + return entity_uuid + + def add_related_info_to_entity(self, + entity_uuid: str = None, + entity_name: str = None, + info_content: str = '', + info_type: str = '相关信息', + source: str = '', + confidence: float = 0.8, + status: str = '疑似') -> Optional[str]: + """ + 为主体添加相关信息(不限数量,使用数据库) + 新添加的信息默认标记为"疑似"状态 + + Args: + entity_uuid: 主体UUID(优先使用) + entity_name: 主体名称(如果没有UUID则使用名称查找) + info_content: 信息内容 + info_type: 信息类型 + source: 来源 + confidence: 置信度(相关信息的置信度低于定义) + status: 状态(疑似/确认),默认为"疑似" + + Returns: + 信息UUID,失败返回None + """ + # 确定主体UUID + if entity_uuid is None and entity_name is None: + print("✗ 必须提供entity_uuid或entity_name") + return None + + if entity_uuid is None: + entity_uuid = self._find_or_create_entity(entity_name) + + # 验证实体是否存在 + entity = self.db.get_entity_by_uuid(entity_uuid) + if not entity: + print(f"✗ 主体UUID {entity_uuid} 不存在") + return None + + # 添加相关信息到数据库(包含状态字段) + info_uuid = self.db.add_entity_related_info( + entity_uuid=entity_uuid, + content=info_content, + type_=info_type, + source=source, + confidence=confidence, + status=status + ) + + return info_uuid + + def get_relevant_knowledge_for_query(self, query: str, max_items: int = 10) -> Dict[str, Any]: + """ + 根据用户查询获取相关知识(理解阶段使用) + 按优先级返回:基础知识(最高优先级100) > 定义(高置信度) > 相关信息(中置信度) + 使用数据库查询 + + Args: + query: 用户查询 + max_items: 最多返回的知识条目数 + + Returns: + 包含实体和相关知识的字典 + """ + debug_logger.log_module('KnowledgeBase', '开始检索相关知识', f'查询: {query}') + + # 1. 提取查询中的主体 + entities = self.extract_entities_from_query(query) + + if not entities: + debug_logger.log_info('KnowledgeBase', '未识别到实体') + return { + 'query': query, + 'entities_found': [], + 'knowledge_items': [], + 'base_knowledge_items': [], + 'summary': '未在查询中识别到相关主体。' + } + + # 2. 对每个主体,查找对应的知识 + knowledge_items = [] + base_knowledge_items = [] + entities_found = [] + + for entity_name in entities: + debug_logger.log_info('KnowledgeBase', f'查找实体: {entity_name}') + + # 首先检查基础知识库(最高优先级) + base_fact = self.base_knowledge.get_base_fact(entity_name) + if base_fact: + debug_logger.log_info('KnowledgeBase', f'找到基础知识: {entity_name}', { + 'content': base_fact['content'] + }) + base_knowledge_items.append({ + 'entity_name': entity_name, + 'type': '基础知识', + 'content': base_fact['content'], + 'confidence': 1.0, + 'priority': 0, + 'is_base_knowledge': True, + 'created_at': base_fact['created_at'] + }) + entities_found.append(entity_name) + else: + debug_logger.log_info('KnowledgeBase', f'未找到基础知识: {entity_name}') + + # 查找主体是否存在于普通知识库(数据库) + entity = self.db.get_entity_by_name(entity_name) + if entity: + entity_uuid = entity['uuid'] + + if entity_name not in entities_found: + entities_found.append(entity_name) + + # 添加定义(次优先级) + definition = self.db.get_entity_definition(entity_uuid) + if definition and not definition.get('is_base_knowledge', False): + knowledge_items.append({ + 'entity_name': entity['name'], + 'type': '定义', + 'content': definition['content'], + 'confidence': definition['confidence'], + 'priority': 1, + 'created_at': definition['created_at'] + }) + + # 添加相关信息(第三优先级,优先确认状态的知识) + related_infos = self.db.get_entity_related_info(entity_uuid) + + # 分离确认和疑似的知识 + confirmed = [i for i in related_infos if i.get('status') == DatabaseManager.STATUS_CONFIRMED] + suspected = [i for i in related_infos if i.get('status') != DatabaseManager.STATUS_CONFIRMED] + + # 确认的按时间倒序,疑似的也按时间倒序 + confirmed.sort(key=lambda x: x.get('created_at', ''), reverse=True) + suspected.sort(key=lambda x: x.get('created_at', ''), reverse=True) + + # 合并:确认的在前,疑似的在后,取前3条 + related_infos_sorted = confirmed + suspected + + for info in related_infos_sorted[:3]: + knowledge_items.append({ + 'entity_name': entity['name'], + 'type': info['type'], + 'content': info['content'], + 'confidence': info['confidence'], + 'status': info.get('status', '疑似'), + 'mention_count': info.get('mention_count', 1), + 'priority': 2, + 'created_at': info['created_at'] + }) + + # 3. 合并基础知识和普通知识,按优先级和置信度排序 + all_knowledge = base_knowledge_items + knowledge_items + all_knowledge.sort(key=lambda x: (x['priority'], -x['confidence'])) + + # 4. 限制返回数量 + all_knowledge = all_knowledge[:max_items] + + # 5. 生成摘要 + summary = self._generate_knowledge_summary(entities_found, all_knowledge) + + debug_logger.log_info('KnowledgeBase', '知识检索完成', { + 'entities_found': entities_found, + 'base_knowledge_count': len(base_knowledge_items), + 'normal_knowledge_count': len(knowledge_items), + 'total_knowledge': len(all_knowledge) + }) + + return { + 'query': query, + 'entities_found': entities_found, + 'knowledge_items': knowledge_items, + 'base_knowledge_items': base_knowledge_items, + 'all_knowledge': all_knowledge, + 'summary': summary + } + + def _generate_knowledge_summary(self, entities: List[str], knowledge_items: List[Dict]) -> str: + """ + 生成知识摘要文本 + + Args: + entities: 识别到的实体列表 + knowledge_items: 知识项列表 + + Returns: + 摘要文本 + """ + if not entities: + return '未找到相关主体。' + + if not knowledge_items: + return f'识别到主体:{", ".join(entities)},但知识库中暂无相关信息。' + + # 按主体分组 + by_entity = {} + for item in knowledge_items: + entity_name = item['entity_name'] + if entity_name not in by_entity: + by_entity[entity_name] = [] + by_entity[entity_name].append(item) + + summary_parts = [f'识别到 {len(entities)} 个相关主体:{", ".join(entities)}。'] + summary_parts.append('\n相关知识:') + + for entity_name, items in by_entity.items(): + definitions = [i for i in items if i['type'] == '定义'] + others = [i for i in items if i['type'] != '定义'] + + if definitions: + summary_parts.append(f'\n• {entity_name}: {definitions[0]["content"]}') + + if others: + for item in others[:2]: # 最多显示2条相关信息 + summary_parts.append(f' - {item["type"]}: {item["content"][:50]}...') + + return ''.join(summary_parts) + + def add_knowledge(self, knowledge_data: Dict[str, Any], source_messages: List[Dict[str, Any]]) -> str: + """ + 添加一条知识到知识库(新版:支持主体-定义-信息结构,使用数据库) + + Args: + knowledge_data: 知识数据(包含entity_name, is_definition, content, type等) + source_messages: 来源消息列表 + + Returns: + 知识的UUID(主体UUID或信息UUID) + """ + entity_name = knowledge_data.get('entity_name', '未知主体') + is_definition = knowledge_data.get('is_definition', False) + content = knowledge_data.get('content', '') + knowledge_type = knowledge_data.get('type', '其他') + source = knowledge_data.get('source', '对话记录') + confidence = knowledge_data.get('confidence', 0.9 if is_definition else 0.8) + + if is_definition: + # 添加或更新定义 + entity_uuid = self._add_or_update_entity_definition( + entity_name=entity_name, + definition=content, + knowledge_type=knowledge_type, + source=source, + confidence=confidence + ) + result_uuid = entity_uuid + else: + # 添加相关信息 + info_uuid = self.add_related_info_to_entity( + entity_name=entity_name, + info_content=content, + info_type=knowledge_type, + source=source, + confidence=confidence + ) + result_uuid = info_uuid + + return result_uuid + + def extract_knowledge(self, messages: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]: + """ + 从最近的对话中提取知识 + 只从用户的语句中提取信息,不从助手的回复中提取 + + Args: + messages: 最近5轮的对话消息列表 + + Returns: + 提取的知识列表,每个知识包含entity(主体)、is_definition(是否为定义)、content等 + """ + try: + # 只提取用户消息 + user_messages = [msg for msg in messages if msg.get('role') == 'user'] + + if not user_messages: + print("○ 没有用户消息,无法提取知识") + return None + + # 构建用户对话文本 + user_text = "" + for msg in user_messages: + user_text += f"用户: {msg['content']}\n" + + # 构建知识提取请求(新版提示词,只关注用户的陈述) + extraction_prompt = f"""请从以下用户的语句中提取关键信息和知识点。 + +重要要求: +1. **只提取用户明确陈述的信息**,不要推断或假设 +2. 识别用户提到的**主体**(entity):如人名、物品名、概念名等 +3. 对每个主体,区分以下两类信息: + - **定义**(definition):主体的核心定义、本质属性(如"是什么") + - **相关信息**(related_info):主体的其他属性、特征、用途、偏好等(不限数量) +4. 每个主体只应有一个定义,如果用户多次提到冲突的定义,以最新的为准 +5. 知识类型包括:个人信息、偏好、事实、经历、观点、定义等 +6. 只提取明确的、有价值的信息,避免重复和模糊的内容 +7. 如果没有值得记录的知识,返回空列表 + +返回JSON格式(只返回JSON数组,不要其他文字): +[ + {{ + "entity_name": "主体名称", + "is_definition": true, + "content": "定义内容或相关信息内容", + "type": "知识类型", + "source": "用户陈述", + "confidence": 0.9 + }} +] + +用户语句: +{user_text} + +请提取知识点(只返回JSON数组):""" + + from src.core.llm_helper import LLMHelper + + system_prompt = '你是一个专业的知识提取助手,擅长从用户的陈述中识别主体、定义和相关信息。你只从用户明确说出的内容中提取信息,不进行推断。你只返回JSON格式的数据,不添加任何解释。' + + # 使用工具模型进行知识提取 + content = LLMHelper.call_tool_model( + system_prompt=system_prompt, + user_message=extraction_prompt, + temperature=0.3, + max_tokens=1500 + ) + + content = content.strip() + + # 尝试解析JSON + # 清理可能的markdown代码块标记 + if content.startswith('```'): + content = content.split('```')[1] + if content.startswith('json'): + content = content[4:] + content = content.strip() + + try: + knowledge_list = json.loads(content) + if isinstance(knowledge_list, list): + return knowledge_list + else: + print(f"✗ 返回的不是列表格式: {type(knowledge_list)}") + debug_logger.log_error('KnowledgeBase', f'返回的不是列表格式: {type(knowledge_list)}') + return None + except json.JSONDecodeError as e: + print(f"✗ JSON解析失败: {e}") + print(f"原始内容: {content[:200]}...") + debug_logger.log_error('KnowledgeBase', 'JSON解析失败', e) + debug_logger.log_info('KnowledgeBase', '原始内容', {'content': content[:500]}) + return None + + except Exception as e: + print(f"✗ 提取知识时出错: {e}") + return None + + def extract_entities_from_query(self, query: str) -> List[str]: + """ + 从用户查询中提取相关主体 + + Args: + query: 用户输入的查询文本 + + Returns: + 提取到的主体名称列表 + """ + try: + # 构建实体提取请求 + extraction_prompt = f"""请从以下用户输入中提取所有可能相关的主体(实体)名称。 + +主体可以是:人名、物品名、概念名、地点名、事件名等。 + +用户输入: +{query} + +请以JSON数组格式返回主体名称列表(只返回JSON,不要其他文字): +["主体1", "主体2", ...] + +如果没有明确的主体,返回空数组 []""" + + from src.core.llm_helper import LLMHelper + + system_prompt = '你是一个专业的实体识别助手,只返回JSON格式数据。' + + # 使用工具模型进行实体提取 + content = LLMHelper.call_tool_model( + system_prompt=system_prompt, + user_message=extraction_prompt, + temperature=0.2, + max_tokens=500 + ) + + content = content.strip() + + # 清理markdown代码块 + if content.startswith('```'): + content = content.split('```')[1] + if content.startswith('json'): + content = content[4:] + content = content.strip() + + try: + entities = json.loads(content) + if isinstance(entities, list): + result = [e for e in entities if isinstance(e, str)] + debug_logger.log_info('KnowledgeBase', '实体提取成功', { + 'query': query, + 'entities': result + }) + return result + else: + debug_logger.log_info('KnowledgeBase', '实体提取结果不是列表', {'content': content}) + return [] + except json.JSONDecodeError as e: + print(f"✗ 实体提取JSON解析失败") + debug_logger.log_error('KnowledgeBase', 'JSON解析失败', e) + debug_logger.log_info('KnowledgeBase', '无法解析的内容', {'content': content[:500]}) + print(f"调试信息 - 原始内容: {content[:200]}...") + return [] + + except Exception as e: + print(f"✗ 提取实体时出错: {e}") + debug_logger.log_error('KnowledgeBase', '提取实体时出错', e) + return [] + + def search_knowledge( + self, + keyword: str = None, + knowledge_type: str = None, + entity_name: str = None + ) -> List[Dict[str, Any]]: + """ + 搜索知识库 + + Args: + keyword: 关键词,用于搜索知识内容 + knowledge_type: 知识类型,如"定义"、"特征"等 + entity_name: 主体名称,用于筛选特定主体的知识 + + Returns: + 匹配的知识列表(按置信度排序) + """ + results = self.get_all_knowledge(sort_by_confidence=True) + + # 按主体名称筛选 + if entity_name: + entity_lower = entity_name.lower() + results = [ + k for k in results + if entity_lower in k.get('entity_name', '').lower() + ] + + # 按关键词筛选 + if keyword: + keyword_lower = keyword.lower() + results = [ + k for k in results + if keyword_lower in k.get('title', '').lower() or + keyword_lower in k.get('content', '').lower() or + keyword_lower in k.get('entity_name', '').lower() + ] + + # 按类型筛选 + if knowledge_type: + results = [k for k in results if k.get('type', '') == knowledge_type] + + return results + + def get_all_knowledge(self, sort_by_confidence: bool = True) -> List[Dict[str, Any]]: + """ + 获取所有知识(新版:返回结构化的主体-定义-信息列表,从数据库) + + Args: + sort_by_confidence: 是否按置信度排序(定义优先于相关信息) + + Returns: + 知识列表,包含主体、定义、相关信息等 + """ + result = [] + + # 从数据库获取所有实体 + entities = self.db.get_all_entities() + + for entity in entities: + entity_uuid = entity['uuid'] + entity_name = entity['name'] + + # 获取并添加主体的定义 + definition = self.db.get_entity_definition(entity_uuid) + if definition: + result.append({ + 'uuid': entity_uuid, + 'entity_name': entity_name, + 'title': f"{entity_name}的定义", + 'content': definition['content'], + 'type': definition['type'], + 'source': definition.get('source', ''), + 'confidence': definition['confidence'], + 'is_definition': True, + 'created_at': definition['created_at'], + 'updated_at': definition.get('updated_at', definition['created_at']) + }) + + # 获取并添加主体的相关信息 + related_infos = self.db.get_entity_related_info(entity_uuid) + for info in related_infos: + result.append({ + 'uuid': info['uuid'], + 'entity_name': entity_name, + 'title': f"{entity_name}的{info['type']}", + 'content': info['content'], + 'type': info['type'], + 'source': info.get('source', ''), + 'confidence': info['confidence'], + 'status': info.get('status', '疑似'), + 'mention_count': info.get('mention_count', 1), + 'is_definition': False, + 'created_at': info['created_at'], + 'updated_at': info.get('created_at') + }) + + # 按置信度排序(定义优先) + if sort_by_confidence: + result.sort(key=lambda x: (x['confidence'], x['is_definition']), reverse=True) + + return result + + def get_knowledge_by_uuid(self, knowledge_uuid: str) -> Optional[Dict[str, Any]]: + """ + 根据UUID获取知识 + + Args: + knowledge_uuid: 知识UUID + + Returns: + 知识项,如果不存在返回None + """ + for item in self.knowledge_items: + if item.get('uuid') == knowledge_uuid: + return item + return None + + def get_base_knowledge_prompt(self) -> str: + """ + 获取基础知识提示词文本,用于嵌入到系统提示词中 + 这些知识具有最高优先级,AI必须严格遵循 + + Returns: + 基础知识提示词文本 + """ + return self.base_knowledge.generate_base_knowledge_prompt() + + def get_statistics(self) -> Dict[str, Any]: + """ + 获取知识库统计信息(从数据库) + + Returns: + 统计信息字典 + """ + # 从数据库获取所有知识 + all_knowledge = self.get_all_knowledge(sort_by_confidence=False) + + # 统计各类型知识数量 + type_counts = {} + definition_count = 0 + related_info_count = 0 + + for item in all_knowledge: + item_type = item.get('type', '其他') + type_counts[item_type] = type_counts.get(item_type, 0) + 1 + + if item.get('is_definition', False): + definition_count += 1 + else: + related_info_count += 1 + + # 统计置信度分布 + high_confidence = sum(1 for k in all_knowledge if k.get('confidence', 0) >= 0.9) + medium_confidence = sum(1 for k in all_knowledge if 0.7 <= k.get('confidence', 0) < 0.9) + low_confidence = sum(1 for k in all_knowledge if k.get('confidence', 0) < 0.7) + + # 获取基础知识库统计 + base_kb_stats = self.base_knowledge.get_statistics() + + # 获取数据库统计 + db_stats = self.db.get_statistics() + + # 统计知识状态分布(仅对相关信息) + status_counts = { + DatabaseManager.STATUS_SUSPECTED: 0, + DatabaseManager.STATUS_CONFIRMED: 0 + } + for item in all_knowledge: + if not item.get('is_definition', False): + status = item.get('status', DatabaseManager.STATUS_SUSPECTED) + status_counts[status] = status_counts.get(status, 0) + 1 + + return { + 'total_entities': db_stats['entities_count'], + 'total_definitions': definition_count, + 'total_related_info': related_info_count, + 'total_knowledge': len(all_knowledge), + 'base_knowledge_facts': base_kb_stats['total_facts'], + 'type_distribution': type_counts, + 'confidence_distribution': { + 'high (>=0.9)': high_confidence, + 'medium (0.7-0.9)': medium_confidence, + 'low (<0.7)': low_confidence + }, + 'status_distribution': status_counts, + 'database_size_kb': db_stats.get('db_size_kb', 0) + } + + def clear_knowledge(self): + """ + 清空知识库(清空数据库中的实体和知识) + """ + # 这里需要实现清空数据库实体的方法 + # 暂时只打印提示 + print("⚠ 清空知识库功能需要实现数据库级联删除") + print(" 建议手动删除数据库文件重新开始") + + def search_knowledge(self, keyword: str = None, knowledge_type: str = None, + entity_name: str = None) -> List[Dict[str, Any]]: + """ + 搜索知识库(使用数据库) + + Args: + keyword: 关键词 + knowledge_type: 知识类型 + entity_name: 主体名称 + + Returns: + 匹配的知识列表(按置信度排序) + """ + results = self.get_all_knowledge(sort_by_confidence=True) + + # 按主体名称筛选 + if entity_name: + entity_lower = entity_name.lower() + results = [ + k for k in results + if entity_lower in k.get('entity_name', '').lower() + ] + + # 按关键词筛选 + if keyword: + keyword_lower = keyword.lower() + results = [ + k for k in results + if keyword_lower in k.get('title', '').lower() or + keyword_lower in k.get('content', '').lower() or + keyword_lower in k.get('entity_name', '').lower() + ] + + # 按类型筛选 + if knowledge_type: + results = [k for k in results if k.get('type', '') == knowledge_type] + + return results + + +if __name__ == '__main__': + print("=" * 60) + print("知识库管理器测试") + print("=" * 60) + + kb = KnowledgeBase() + + print("\n当前知识库统计:") + stats = kb.get_statistics() + print(f"总实体数: {stats['total_entities']}") + print(f"总知识数: {stats['total_knowledge']}") + print(f"定义数: {stats['total_definitions']}") + print(f"相关信息数: {stats['total_related_info']}") + print(f"基础知识数: {stats['base_knowledge_facts']}") + print(f"类型分布: {stats['type_distribution']}") + print(f"数据库大小: {stats['database_size_kb']:.2f} KB") + + all_knowledge = kb.get_all_knowledge() + if all_knowledge: + print(f"\n知识列表 (前5条):") + for i, knowledge in enumerate(all_knowledge[:5], 1): + print(f"{i}. [{knowledge['type']}] {knowledge['title']}") + print(f" 内容: {knowledge['content'][:50]}...") + print(f" 置信度: {knowledge['confidence']:.2f}") + else: + print("\n知识库为空") + + print("\n✓ 测试完成") diff --git a/src/limbic/hippocampus/memory_store.py b/src/limbic/hippocampus/memory_store.py new file mode 100644 index 0000000..ec802b4 --- /dev/null +++ b/src/limbic/hippocampus/memory_store.py @@ -0,0 +1,725 @@ +""" +长效记忆管理模块 +实现分层记忆系统:短期记忆(最近20轮)+ 长期概括记忆 + 知识库 +使用数据库替代JSON文件存储 + +P2 增强: +- 新增 OpenLoopTracker(未完话题检测与延续) +- 与现有短期/长期记忆 / 知识库并行 +- 通过 DatabaseManager 持久化(与 LongTermMemoryManager 同库) +""" + +import os +import json +import uuid +import re +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +from dotenv import load_dotenv +import requests +from src.core.database_manager import DatabaseManager +from src.limbic.hippocampus.knowledge_store import KnowledgeBase +from src.core.llm_helper import LLMHelper +from src.core.prompt_manager import get_prompt_manager +from src.cortex.providers import registry as llm_providers # v4.0: 统一供应商解析 +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +debug_logger = get_debug_logger() + +ENABLE_OPEN_LOOP = os.getenv('ENABLE_OPEN_LOOP', 'false').lower() == 'true' + + +# ==================== P2: OpenLoopTracker ==================== + +class OpenLoopTracker: + """ + 未完话题跟踪器。 + 每轮从用户消息中提取未完话题(pending tasks / 提问 / 计划 / 疑虑), + 在合适时机主动延续。 + """ + + LOOP_TRIGGERS = [ + r'我[们]?(.+?)(明天|下周|以后|稍后|待会)[就再]', + r'(?:帮我|请|记得|别忘)(.+)', + r'(?:之后|下次)(?:再|记得|帮我)(.+)', + r'什么时候(.+)', + r'为什么(.+)', + r'(.+)怎么[办做]', + ] + + def __init__(self, db_manager: DatabaseManager = None, + prompt_manager=None, + max_active: int = 12): + self.db = db_manager or DatabaseManager() + self.prompt_manager = prompt_manager or get_prompt_manager() + self.enabled = ENABLE_OPEN_LOOP + self.max_active = max_active + + def update_from_message(self, user: str, text: str) -> Dict[str, Any]: + """ + 从用户消息中提取未完话题并入库。 + 返回 {topic, status, raised_at, related_keywords, uuid} 或 None(若未识别到)。 + """ + if not self.enabled or not text: + return {} + topic = self._extract_topic_via_rules(text) + keywords = self._extract_keywords(text) + if not topic and not keywords: + return {} + + if not topic: + topic = keywords[0] if keywords else text[:20] + context = text[:200] + + existing = self.db.find_matching_open_loop(topic) + if existing: + self.db.resolve_open_loop(existing['uuid']) + + loop_uuid = self.db.insert_open_loop( + topic=topic, context=context, + related_keywords=keywords, + ) + if not loop_uuid: + return {} + return { + 'uuid': loop_uuid, 'topic': topic, 'status': 'open', + 'raised_at': datetime.now().isoformat(), + 'related_keywords': keywords, + } + + def _extract_topic_via_rules(self, text: str) -> str: + for pat in self.LOOP_TRIGGERS: + m = re.search(pat, text) + if m: + captured = (m.group(1) or '').strip() + if 1 <= len(captured) <= 30: + return captured + return "" + + @staticmethod + def _extract_keywords(text: str) -> List[str]: + # 简化版:移除停用词,按空格 / 标点切分,保留长度 2-12 的 token + stop = {'的', '了', '我', '你', '是', '在', '和', '就', '都', '也', '吧', '啊', '吗', '呢'} + text = re.sub(r'[,。!?、,!?\.\s]+', ' ', text) + tokens = [t.strip() for t in text.split() if 2 <= len(t.strip()) <= 12] + return [t for t in tokens if t not in stop][:5] + + def resolve_matching_loop(self, text: str) -> Optional[Dict[str, Any]]: + """ + 若 text 命中已存在的未完话题,自动 resolve 并返回。 + """ + if not self.enabled or not text: + return None + match = self.db.find_matching_open_loop(text) + if not match: + return None + self.db.resolve_open_loop(match['uuid']) + return match + + def format_for_prompt(self, hint: str = "") -> str: + """ + 渲染为 prompt 文本(主流程第 4 步注入)。 + hint:可选的"对当前用户消息最相关的话题"提示。 + """ + if not self.enabled: + return "" + loops = self.db.get_open_loops(status='open', limit=self.max_active) + if not loops: + return "" + lines = ["【未完话题】"] + for lp in loops: + topic = lp.get('topic', '?') + keywords = lp.get('related_keywords', []) + if keywords: + lines.append(f"• {topic}(关键词:{', '.join(keywords[:3])})") + else: + lines.append(f"• {topic}") + if hint: + lines.append(f"\n当前消息与「{hint}」相关,可在合适时机主动延续。") + return "\n".join(lines) + + +class LongTermMemoryManager: + """ + 长效记忆管理器 + 负责管理短期详细记忆和长期概括记忆的分层存储 + 使用数据库替代JSON文件 + """ + + def __init__(self, + db_manager: DatabaseManager = None, + api_key: str = None, + api_url: str = None, + model_name: str = None): + """ + 初始化长效记忆管理器 + + Args: + db_manager: 数据库管理器实例(如果为None则创建新实例) + api_key: API密钥 + api_url: API地址 + model_name: 模型名称 + """ + # 使用共享的数据库管理器 + self.db = db_manager or DatabaseManager() + + # 短期记忆最大轮数(一轮 = 一对user+assistant消息) + self.max_short_term_rounds = 20 + self.max_short_term_messages = self.max_short_term_rounds * 2 # user + assistant + + # 知识提取间隔(每5轮) + self.knowledge_extraction_interval = 5 + + # API配置(用于生成概括) + # v4.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = api_key or llm_providers.resolve_api_key() + try: + self.api_url = api_url or llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + # 启动期 LLM_BASE_URL 缺失时(custom)保留旧 URL + self.api_url = api_url or os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + self.model_name = model_name or os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') + + # 初始化知识库(共享数据库管理器) + self.knowledge_base = KnowledgeBase( + db_manager=self.db, + api_key=self.api_key, + api_url=self.api_url, + model_name=self.model_name + ) + + # 检查是否需要从JSON迁移数据 + self._check_and_migrate_json() + + print(f"✓ 长效记忆管理器已初始化(使用数据库存储)") + + def _check_and_migrate_json(self): + """检查并迁移旧的JSON文件""" + # 迁移短期记忆 + short_term_file = os.getenv('MEMORY_FILE', 'memory_data.json') + if os.path.exists(short_term_file): + print(f"○ 检测到旧的短期记忆JSON文件,正在迁移...") + self.db.migrate_from_json(short_term_file, 'short_term') + os.rename(short_term_file, short_term_file + '.bak') + print(f"✓ 短期记忆已迁移,JSON文件已备份") + + # 迁移长期记忆 + long_term_file = 'longmemory_data.json' + if os.path.exists(long_term_file): + print(f"○ 检测到旧的长期记忆JSON文件,正在迁移...") + self.db.migrate_from_json(long_term_file, 'long_term') + os.rename(long_term_file, long_term_file + '.bak') + print(f"✓ 长期记忆已迁移,JSON文件已备份") + + def add_message(self, role: str, content: str): + """ + 添加新消息到短期记忆(使用数据库) + + Args: + role: 角色类型 ('user' 或 'assistant') + content: 消息内容 + """ + # 添加到数据库 + self.db.add_short_term_message(role, content) + + # 更新元数据 + if role == 'user': + total_conversations = self.db.get_metadata('total_conversations', 0) + total_conversations += 1 + self.db.set_metadata('total_conversations', total_conversations) + + # 检查是否需要提取知识(每5轮) + if total_conversations % self.knowledge_extraction_interval == 0: + print(f"\n📚 已达到 {total_conversations} 轮对话,开始提取知识...") + self._extract_and_save_knowledge() + + # 检查是否需要归档 + self._check_and_archive() + + def _check_and_archive(self): + """ + 检查短期记忆是否超过限制,如果超过则归档旧记忆 + """ + # 获取所有短期记忆 + messages = self.db.get_short_term_messages() + + # 计算当前对话轮数 + user_count = sum(1 for msg in messages if msg['role'] == 'user') + + # 如果超过20轮,将最早的20轮归档 + if user_count > self.max_short_term_rounds: + print(f"\n⚠ 短期记忆已达 {user_count} 轮,开始归档...") + self._archive_old_messages() + + def _archive_old_messages(self): + """ + 将最早的20轮对话归档为概括记忆 + """ + # 获取所有短期记忆消息 + all_messages = self.db.get_short_term_messages() + + # 找出前20轮对话(40条消息) + messages_to_archive = [] + message_ids_to_delete = [] + user_count = 0 + + for msg in all_messages: + messages_to_archive.append(msg) + message_ids_to_delete.append(msg['id']) + if msg['role'] == 'user': + user_count += 1 + if user_count >= self.max_short_term_rounds: + break + + # 生成概括 + summary = self._generate_summary(messages_to_archive) + + if summary: + # 保存到长期记忆(数据库) + self.db.add_long_term_summary( + summary=summary, + rounds=user_count, + message_count=len(messages_to_archive), + created_at=messages_to_archive[0]['timestamp'] if messages_to_archive else datetime.now().isoformat(), + ended_at=messages_to_archive[-1]['timestamp'] if messages_to_archive else datetime.now().isoformat() + ) + + # 从短期记忆中移除已归档的消息 + self.db.delete_short_term_messages(message_ids_to_delete) + + print(f"✓ 已归档 {user_count} 轮对话({len(messages_to_archive)} 条消息)") + print(f"✓ 生成主题概括: {summary[:50]}...") + + def _extract_and_save_knowledge(self): + """ + 从最近5轮对话中提取并保存知识 + 同时定期清理过时的知识 + """ + # 从数据库获取所有短期记忆 + all_messages = self.db.get_short_term_messages() + + # 获取最近5轮对话(10条消息) + recent_messages = [] + user_count = 0 + + for msg in reversed(all_messages): + recent_messages.insert(0, msg) + if msg['role'] == 'user': + user_count += 1 + if user_count >= 5: + break + + if len(recent_messages) < 2: # 至少需要一轮对话 + print("✗ 消息太少,无法提取知识") + return + + # 使用知识库提取知识 + knowledge_list = self.knowledge_base.extract_knowledge(recent_messages) + + if knowledge_list and len(knowledge_list) > 0: + print(f"✓ 提取到 {len(knowledge_list)} 条知识") + + # 保存每条知识 + for knowledge_data in knowledge_list: + entity_name = knowledge_data.get('entity_name', knowledge_data.get('title', '未知')) + is_def = knowledge_data.get('is_definition', False) + content = knowledge_data.get('content', '') + content_preview = content[:30] + print(f" • [{knowledge_data.get('type', '其他')}] {entity_name}{'的定义' if is_def else ''}: {content_preview}...") + + # 保存到数据库 + entity_uuid = self.db.find_or_create_entity(entity_name) + + if is_def: + # 保存为定义 + self.db.set_entity_definition( + entity_uuid=entity_uuid, + content=content, + type_=knowledge_data.get('type', '定义'), + source=knowledge_data.get('source', '对话提取'), + confidence=knowledge_data.get('confidence', 0.8) + ) + print(f" 置信度: {knowledge_data.get('confidence', 0.8):.2f} | 实体UUID: {entity_uuid}") + else: + # 保存为相关信息,默认状态为"疑似" + # add_entity_related_info 会检查是否已存在相同信息,如果存在会增加mention_count + from src.core.database_manager import DatabaseManager + info_uuid = self.db.add_entity_related_info( + entity_uuid=entity_uuid, + content=content, + type_=knowledge_data.get('type', '其他'), + source=knowledge_data.get('source', '对话提取'), + confidence=knowledge_data.get('confidence', 0.7), + status=DatabaseManager.STATUS_SUSPECTED + ) + + # 获取信息状态以显示 + info = self.db.get_entity_related_info(entity_uuid) + saved_info = next((i for i in info if i['uuid'] == info_uuid), None) + if saved_info: + status = saved_info.get('status', DatabaseManager.STATUS_SUSPECTED) + mention_count = saved_info.get('mention_count', 1) + status_label = f"[{status}]" if status == DatabaseManager.STATUS_CONFIRMED else f"[{status}×{mention_count}]" + print(f" 状态: {status_label} | 置信度: {knowledge_data.get('confidence', 0.7):.2f} | 实体UUID: {entity_uuid}") + else: + print(f" 置信度: {knowledge_data.get('confidence', 0.7):.2f} | 实体UUID: {entity_uuid}") + + # 每次提取知识后,检查是否需要清理过时信息 + # 每10次提取清理一次(即每50轮对话) + total_conv = self.db.get_metadata('total_conversations', 0) + if total_conv % 50 == 0 and total_conv > 0: + print("○ 执行定期知识库清理...") + # 这里可以添加清理逻辑 + print(f"✓ 清理完成") + else: + print("○ 未提取到新知识") + + def _generate_summary(self, messages: List[Dict[str, Any]]) -> Optional[str]: + """ + 使用LLM生成对话概括 + + Args: + messages: 要概括的消息列表 + + Returns: + 概括文本,失败返回None + """ + try: + # 构建对话文本 + conversation_text = "" + for msg in messages: + role_name = "用户" if msg['role'] == 'user' else "助手" + conversation_text += f"{role_name}: {msg['content']}\n" + + # 构建概括请求 + summary_prompt = f"""请对以下对话进行主题概括,要求: +1. 用一句话总结对话的主要主题和内容 +2. 提炼关键信息和讨论要点 +3. 简洁明了,不超过100字 +4. 只返回概括内容,不要有其他说明 + +对话内容: +{conversation_text} + +请给出主题概括:""" + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': [ + {'role': 'system', 'content': '你是一个专业的对话分析助手,擅长总结对话主题。'}, + {'role': 'user', 'content': summary_prompt} + ], + 'temperature': 0.3, # 使用较低温度以获得更稳定的概括 + 'max_tokens': 200, + 'stream': False + } + + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=30 + ) + + response.raise_for_status() + result = response.json() + + if 'choices' in result and len(result['choices']) > 0: + summary = result['choices'][0]['message']['content'].strip() + return summary + else: + print("✗ 未能获取有效的概括结果") + return None + + except Exception as e: + print(f"✗ 生成概括时出错: {e}") + # 返回一个默认概括 + return f"对话记录 ({len(messages)} 条消息)" + + def get_recent_messages(self, count: int = 10) -> List[Dict[str, str]]: + """ + 获取最近的N条短期记忆消息(从数据库) + + Args: + count: 要获取的消息数量 + + Returns: + 消息列表 + """ + messages = self.db.get_short_term_messages(limit=count) + return [{'role': msg['role'], 'content': msg['content']} for msg in messages] + + def get_all_summaries(self) -> List[Dict[str, Any]]: + """ + 获取所有长期记忆概括(从数据库) + + Returns: + 概括列表 + """ + return self.db.get_long_term_summaries() + + def get_long_term_summaries(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """ + 获取长期记忆概括(支持限制数量) + + Args: + limit: 限制返回的概括数量,None表示返回全部 + + Returns: + 概括列表 + """ + summaries = self.db.get_long_term_summaries() + if limit is not None and limit > 0: + return summaries[:limit] + return summaries + + def get_statistics(self) -> Dict[str, Any]: + """ + 获取记忆统计信息(从数据库) + + Returns: + 统计信息字典 + """ + # 从数据库获取消息 + short_term_messages = self.db.get_short_term_messages() + long_term_summaries = self.db.get_long_term_summaries() + + short_user = sum(1 for msg in short_term_messages if msg['role'] == 'user') + short_assistant = sum(1 for msg in short_term_messages if msg['role'] == 'assistant') + + # 获取知识库统计 + db_stats = self.db.get_statistics() + + # 获取知识库详细统计 + kb_stats = self.knowledge_base.get_statistics() + + return { + 'short_term': { + 'total_messages': len(short_term_messages), + 'user_messages': short_user, + 'assistant_messages': short_assistant, + 'rounds': short_user + }, + 'long_term': { + 'total_summaries': len(long_term_summaries), + 'total_archived_rounds': sum(s.get('rounds', 0) for s in long_term_summaries), + 'total_archived_messages': sum(s.get('message_count', 0) for s in long_term_summaries) + }, + 'knowledge_base': { + 'total_entities': db_stats['entities_count'], + 'total_base_knowledge': db_stats['base_knowledge_count'], + 'total_knowledge': kb_stats['total_knowledge'], # 添加总知识数以保持兼容性 + 'total_definitions': kb_stats['total_definitions'], + 'total_related_info': kb_stats['total_related_info'] + }, + 'total_conversations': self.db.get_metadata('total_conversations', 0), + 'database_size_kb': db_stats.get('db_size_kb', 0) + } + + def clear_all_memory(self): + """ + 清空所有记忆(短期、长期) + """ + self.db.clear_short_term_memory() + self.db.clear_long_term_memory() + self.db.set_metadata('total_conversations', 0) + print("✓ 所有记忆已清空") + + def get_context_for_chat(self, recent_count: int = 10) -> str: + """ + 获取用于聊天的上下文(包含长期记忆概括和短期记忆) + + Args: + recent_count: 最近消息数量 + + Returns: + 格式化的上下文字符串 + """ + context_parts = [] + + # 添加长期记忆概括(如果有) + long_term_summaries = self.db.get_long_term_summaries() + if long_term_summaries: + context_parts.append("【历史对话主题回顾】") + for i, summary in enumerate(long_term_summaries[-5:], 1): # 只取最近5个概括 + context_parts.append(f"{i}. {summary['summary']}") + context_parts.append("") + + return "\n".join(context_parts) if context_parts else "" + + # ==================== Stage B.4 Web 后端兼容接口 ==================== + + def list_topics( + self, + user_id: str = "default", + days: int = 7, + limit: int = 200, + ) -> List[Dict[str, Any]]: + """ + 列出最近 N 天内的话题(基于 long_term_memory 概括)。 + + Args: + user_id: 用户标识(当前 schema 未持久化 user_id,保留参数以备扩展)。 + days: 时间范围(天),默认 7。 + limit: 返回条数上限,默认 200。 + + Returns: + 话题字典列表,字段:uuid / topic / start_time / end_time / message_count。 + 失败时返回空列表。 + """ + try: + days_int = max(1, min(int(days), 365)) + except (TypeError, ValueError): + days_int = 7 + try: + limit_int = max(1, min(int(limit), 1000)) + except (TypeError, ValueError): + limit_int = 200 + + try: + cutoff = (datetime.now() - timedelta(days=days_int)).isoformat() + except Exception: # noqa: BLE001 + cutoff = datetime.now().isoformat() + + rows: List[Dict[str, Any]] = [] + try: + summaries = self.db.get_long_term_summaries() or [] + except Exception: # noqa: BLE001 + return [] + + for item in summaries: + try: + created_at = item.get('created_at') or '' + if created_at and created_at < cutoff: + continue + topic_text = (item.get('summary') or '').strip() + # topic 取 summary 前若干字符作为短标题 + topic_short = topic_text[:40] + ('…' if len(topic_text) > 40 else '') + try: + msg_count = int(item.get('message_count') or 0) + except (TypeError, ValueError): + msg_count = 0 + rows.append({ + 'uuid': str(item.get('uuid') or ''), + 'topic': topic_short or '未命名话题', + 'start_time': str(item.get('created_at') or ''), + 'end_time': str(item.get('ended_at') or '') or None, + 'message_count': msg_count, + }) + if len(rows) >= limit_int: + break + except Exception: # noqa: BLE001 + # 单条记录解析失败:跳过这一条,继续下一条 + continue + + # 按 start_time 倒序 + try: + rows.sort(key=lambda x: x.get('start_time') or '', reverse=True) + except Exception: # noqa: BLE001 + pass + return rows + + def get_topic_context( + self, + topic_uuid: str, + user_id: str = "default", + ) -> Dict[str, Any]: + """ + 获取话题的上下文消息(Stage B.4 Web 后端专用)。 + + 重要:当前 schema 中归档时会从 short_term_memory 物理删除原消息, + 因此 ``messages`` 字段以 long_term_memory.summary 自身 + 元数据作为兜底。 + 若未来在 short_term_memory 增加 topic_uuid 关联列,本方法可平滑扩展。 + + Returns: + dict: {topic_uuid, topic, summary, messages, start_time, end_time} + 失败 / 未找到时返回 {topic_uuid, messages: []}。 + """ + if not topic_uuid or not isinstance(topic_uuid, str): + return {'topic_uuid': str(topic_uuid or ''), 'messages': []} + + item: Optional[Dict[str, Any]] = None + try: + summaries = self.db.get_long_term_summaries() or [] + for s in summaries: + try: + if str(s.get('uuid') or '') == topic_uuid: + item = s + break + except Exception: # noqa: BLE001 + continue + except Exception: # noqa: BLE001 + item = None + + if not item: + return {'topic_uuid': topic_uuid, 'messages': []} + + summary_text = (item.get('summary') or '').strip() + topic_short = summary_text[:40] + ('…' if len(summary_text) > 40 else '') + started_at = str(item.get('created_at') or '') + ended_at = str(item.get('ended_at') or '') or None + + # messages:把 summary 拆成系统消息;并附加元数据作为 assistant 消息 + messages: List[Dict[str, Any]] = [] + if summary_text: + messages.append({ + 'role': 'system', + 'content': f"[话题摘要] {summary_text}", + 'timestamp': started_at, + }) + try: + msg_count = int(item.get('message_count') or 0) + except (TypeError, ValueError): + msg_count = 0 + try: + rounds = int(item.get('rounds') or 0) + except (TypeError, ValueError): + rounds = 0 + if msg_count or rounds: + messages.append({ + 'role': 'assistant', + 'content': f"该话题共 {rounds} 轮 / {msg_count} 条消息。", + 'timestamp': ended_at or started_at, + }) + + return { + 'topic_uuid': topic_uuid, + 'topic': topic_short or '未命名话题', + 'summary': summary_text or None, + 'messages': messages, + 'start_time': started_at or None, + 'end_time': ended_at, + } + + +if __name__ == '__main__': + print("=" * 60) + print("长效记忆管理器测试") + print("=" * 60) + + manager = LongTermMemoryManager() + + print("\n当前记忆统计:") + stats = manager.get_statistics() + print(f"短期记忆: {stats['short_term']['rounds']} 轮对话 ({stats['short_term']['total_messages']} 条消息)") + print(f"长期记忆: {stats['long_term']['total_summaries']} 个主题概括") + print(f"知识库实体: {stats['knowledge_base']['total_entities']} 个") + print(f"总对话轮数: {stats['total_conversations']} 轮") + print(f"数据库大小: {stats['database_size_kb']:.2f} KB") + + long_term_summaries = manager.get_all_summaries() + if long_term_summaries: + print("\n长期记忆概括:") + for i, summary in enumerate(long_term_summaries, 1): + print(f"{i}. [{summary['created_at'][:10]}] {summary['summary']}") + else: + print("\n暂无长期记忆概括") + + print("\n✓ 测试完成") diff --git a/src/limbic/hippocampus/module.py b/src/limbic/hippocampus/module.py new file mode 100644 index 0000000..7fe3921 --- /dev/null +++ b/src/limbic/hippocampus/module.py @@ -0,0 +1,252 @@ +""" +HippocampusModule - 海马体模块(v4.0 神经系统接入层)。 + +将 src.limbic.hippocampus 中的记忆、会话、知识库能力包装为 BaseModule, +使其可以通过 CentralRouter 被其他模块调用。 + +当前保留 SimpleHippocampus(module_id: limbic.hippocampus)作为 MVP 轻量实现, +本模块使用 module_id: limbic.hippocampus.full,提供更完整的持久化能力。 +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +from src.core.database_manager import DatabaseManager +from src.limbic.hippocampus.knowledge_store import KnowledgeBase +from src.limbic.hippocampus.memory_store import LongTermMemoryManager +from src.limbic.hippocampus.session_store import SessionStore +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +class HippocampusModule(BaseModule): + """ + 完整版海马体模块。 + + 负责持久化会话、短期/长期记忆、知识库查询。 + """ + + module_id = "limbic.hippocampus.full" + module_type = "limbic" + + def __init__(self, router: "CentralRouter", db_manager: DatabaseManager | None = None) -> None: + super().__init__(router) + self._db = db_manager or DatabaseManager() + self._session_store = SessionStore(self._db) + self._memory_manager: LongTermMemoryManager | None = None + self._knowledge_base: KnowledgeBase | None = None + # 高频通道内存缓存(memory_query / memory_context / memory_stats) + self._cache: Dict[str, Tuple[float, Any]] = {} + self._cache_ttl_seconds = 5.0 + + async def initialize(self) -> None: + self._memory_manager = LongTermMemoryManager(db_manager=self._db) + self._knowledge_base = KnowledgeBase(db_manager=self._db) + await super().initialize() + + async def shutdown(self) -> None: + self._memory_manager = None + self._knowledge_base = None + await super().shutdown() + + # ------------------------------------------------------------------ + # 缓存层 helpers + # ------------------------------------------------------------------ + def _cache_key(self, channel: str, payload: Dict[str, Any]) -> str: + """基于通道与 payload 构造缓存 key(忽略 trace_id 等元数据)。""" + items = sorted( + (k, v) for k, v in payload.items() + if k not in ("trace_id", "timestamp", "metadata") + ) + return f"{channel}:{items}" + + def _get_cached(self, key: str) -> Optional[Any]: + """读取缓存,过期返回 None 并清理。""" + entry = self._cache.get(key) + if entry is None: + return None + ts, value = entry + if time.time() - ts > self._cache_ttl_seconds: + self._cache.pop(key, None) + return None + return value + + def _set_cached(self, key: str, value: Any) -> None: + """写入缓存。""" + self._cache[key] = (time.time(), value) + + def _invalidate_memory_cache(self) -> None: + """记忆写入后使相关读取缓存失效。""" + prefixes = ("memory_query:", "memory_context:", "memory_stats:") + for key in list(self._cache.keys()): + if key.startswith(prefixes): + self._cache.pop(key, None) + + async def handle(self, packet: Packet) -> Packet: + """ + 处理记忆、会话、知识库相关请求。 + + Channels: + - memory_query: 查询相关记忆 + - memory_store: 存储消息 + - memory_context: 获取聊天上下文字符串 + - memory_stats: 获取记忆统计 + - session_create/list/get/delete/update_title + - message_add/list + """ + channel = packet.channel + payload = packet.payload + + if channel == "memory_query": + return self._handle_memory_query(packet, payload) + if channel == "memory_store": + return self._handle_memory_store(packet, payload) + if channel == "memory_context": + return self._handle_memory_context(packet, payload) + if channel == "memory_stats": + return self._handle_memory_stats(packet, payload) + if channel == "session_create": + return self._handle_session_create(packet, payload) + if channel == "session_list": + return self._handle_session_list(packet, payload) + if channel == "session_get": + return self._handle_session_get(packet, payload) + if channel == "session_delete": + return self._handle_session_delete(packet, payload) + if channel == "session_update_title": + return self._handle_session_update_title(packet, payload) + if channel == "message_add": + return self._handle_message_add(packet, payload) + if channel == "message_list": + return self._handle_message_list(packet, payload) + + return packet.response({ + "status": "unknown_channel", + "channel": channel, + }) + + def _handle_memory_query(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + if self._memory_manager is None: + return packet.error("HippocampusModule not initialized", code="NOT_INITIALIZED") + + key = self._cache_key("memory_query", payload) + cached = self._get_cached(key) + if cached is not None: + return packet.response(cached) + + limit = payload.get("limit", 10) + messages = self._memory_manager.get_recent_messages(count=limit) + result = { + "memories": messages, + "total": len(messages), + } + self._set_cached(key, result) + return packet.response(result) + + def _handle_memory_store(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + if self._memory_manager is None: + return packet.error("HippocampusModule not initialized", code="NOT_INITIALIZED") + + role = payload.get("role", "user") + content = payload.get("content", "") + self._memory_manager.add_message(role, content) + self._invalidate_memory_cache() + return packet.response({"status": "stored"}) + + def _handle_memory_context(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + if self._memory_manager is None: + return packet.error("HippocampusModule not initialized", code="NOT_INITIALIZED") + + key = self._cache_key("memory_context", payload) + cached = self._get_cached(key) + if cached is not None: + return packet.response(cached) + + context = self._memory_manager.get_context_for_chat( + recent_count=payload.get("recent_count", 10) + ) + result = {"context": context} + self._set_cached(key, result) + return packet.response(result) + + def _handle_memory_stats(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + if self._memory_manager is None: + return packet.error("HippocampusModule not initialized", code="NOT_INITIALIZED") + + key = self._cache_key("memory_stats", payload) + cached = self._get_cached(key) + if cached is not None: + return packet.response(cached) + + stats = self._memory_manager.get_statistics() + self._set_cached(key, stats) + return packet.response(stats) + + def _handle_session_create(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + user_id = payload.get("user_id", "default") + title = payload.get("title", "新会话") + session_id = self._session_store.create(user_id, title) + return packet.response({"session_id": session_id}) + + def _handle_session_list(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + user_id = payload.get("user_id", "default") + sessions = self._session_store.list( + user_id, + limit=payload.get("limit", 50), + offset=payload.get("offset", 0), + ) + return packet.response({"sessions": sessions}) + + def _handle_session_get(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + session_id = payload.get("session_id") + if session_id is None: + return packet.error("session_id required", code="INVALID_REQUEST") + session = self._session_store.get(int(session_id)) + return packet.response({"session": session}) + + def _handle_session_delete(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + session_id = payload.get("session_id") + if session_id is None: + return packet.error("session_id required", code="INVALID_REQUEST") + deleted = self._session_store.delete(int(session_id)) + return packet.response({"deleted": deleted}) + + def _handle_session_update_title(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + session_id = payload.get("session_id") + title = payload.get("title") + if session_id is None or title is None: + return packet.error("session_id and title required", code="INVALID_REQUEST") + updated = self._session_store.update_title(int(session_id), title) + return packet.response({"updated": updated}) + + def _handle_message_add(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + session_id = payload.get("session_id") + role = payload.get("role", "user") + content = payload.get("content", "") + if session_id is None: + return packet.error("session_id required", code="INVALID_REQUEST") + message_id = self._session_store.add_message( + int(session_id), role, content, + emotion_json=payload.get("emotion_json"), + conn_id=payload.get("conn_id"), + ) + return packet.response({"message_id": message_id}) + + def _handle_message_list(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + session_id = payload.get("session_id") + if session_id is None: + return packet.error("session_id required", code="INVALID_REQUEST") + messages = self._session_store.list_messages( + int(session_id), + limit=payload.get("limit", 200), + before_id=payload.get("before_id"), + ) + return packet.response({"messages": messages}) + + +__all__ = ["HippocampusModule"] diff --git a/src/limbic/hippocampus/session_store.py b/src/limbic/hippocampus/session_store.py new file mode 100644 index 0000000..7c9b270 --- /dev/null +++ b/src/limbic/hippocampus/session_store.py @@ -0,0 +1,295 @@ +""" +SessionStore - 海马体中的会话存储模块。 + +负责聊天会话(chat_sessions)与消息(chat_messages)的持久化, +对应 v3.1.0 的 ChatSessionRepository,迁移到 v4.0 神经系统架构的 limbic/hippocampus 层。 +""" + +from __future__ import annotations + +import time +from typing import Any, Dict, List, Optional + + +class SessionStore: + """ + 聊天会话仓库。 + + Args: + db_manager: ``DatabaseManager`` 实例。 + """ + + def __init__(self, db_manager: Any) -> None: + self._db = db_manager + + # ------------------------------------------------------------------ + # 内部 helper + # ------------------------------------------------------------------ + def _row(self, row: Any) -> Optional[Dict[str, Any]]: + if row is None: + return None + if isinstance(row, dict): + return row + return dict(row) + + # ------------------------------------------------------------------ + # Session 操作 + # ------------------------------------------------------------------ + def create(self, user_id: str, title: str = "新会话") -> int: + """ + 创建新会话并返回 session_id。 + + Args: + user_id: 归属用户标识 + title: 初始标题(默认 "新会话") + + Returns: + 新插入行的自增 id + """ + now = time.time() + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + INSERT INTO chat_sessions (user_id, title, created_at, updated_at, message_count) + VALUES (?, ?, ?, ?, 0) + ''', + (user_id, title, now, now), + ) + new_id = cursor.lastrowid + return int(new_id) if new_id is not None else 0 + + def list( + self, + user_id: str, + limit: int = 50, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + 列出用户会话,按 updated_at 倒序。 + + Args: + user_id: 用户标识 + limit: 最多返回条数 + offset: 起始偏移 + + Returns: + session 字典列表 + """ + try: + safe_limit = max(1, min(int(limit), 200)) + except (TypeError, ValueError): + safe_limit = 50 + try: + safe_offset = max(0, int(offset)) + except (TypeError, ValueError): + safe_offset = 0 + + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + SELECT * FROM chat_sessions + WHERE user_id = ? + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + ''', + (user_id, safe_limit, safe_offset), + ) + rows = cursor.fetchall() + return [self._row(r) for r in rows] + + def get(self, session_id: int) -> Optional[Dict[str, Any]]: + """ + 按 id 查单条 session。 + + Args: + session_id: 会话 id + + Returns: + session 字典或 None + """ + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM chat_sessions WHERE id = ?', (int(session_id),)) + row = cursor.fetchone() + return self._row(row) + + def delete(self, session_id: int) -> bool: + """ + 删除 session(依赖 ON DELETE CASCADE 自动删 messages)。 + + Args: + session_id: 会话 id + + Returns: + 是否实际删除了行 + """ + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM chat_sessions WHERE id = ?', (int(session_id),)) + return cursor.rowcount > 0 + + def update_title(self, session_id: int, title: str) -> bool: + """ + 更新会话标题(同步刷新 updated_at)。 + + Args: + session_id: 会话 id + title: 新标题 + + Returns: + 是否实际更新了行 + """ + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + UPDATE chat_sessions + SET title = ?, updated_at = ? + WHERE id = ? + ''', + (title, time.time(), int(session_id)), + ) + return cursor.rowcount > 0 + + def touch(self, session_id: int) -> None: + """ + 刷新 session 的 ``updated_at`` + 重新计算 ``message_count``。 + """ + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + UPDATE chat_sessions + SET updated_at = ?, + message_count = ( + SELECT COUNT(*) FROM chat_messages WHERE session_id = ? + ) + WHERE id = ? + ''', + (time.time(), int(session_id), int(session_id)), + ) + + # ------------------------------------------------------------------ + # Message 操作 + # ------------------------------------------------------------------ + def add_message( + self, + session_id: int, + role: str, + content: str, + emotion_json: Optional[str] = None, + conn_id: Optional[str] = None, + ) -> int: + """ + 插入一条消息 + 自动 touch session。 + + Args: + session_id: 归属会话 id + role: 角色(user / assistant / system) + content: 文本内容 + emotion_json: 序列化后的情感数据(可空) + conn_id: WebSocket 连接 id(可空) + + Returns: + 新插入 message 的自增 id + """ + now = time.time() + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + INSERT INTO chat_messages + (session_id, role, content, emotion_json, conn_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', + (int(session_id), role, content, emotion_json, conn_id, now), + ) + new_id = cursor.lastrowid + # 自动维护 session 元信息 + cursor.execute( + ''' + UPDATE chat_sessions + SET updated_at = ?, + message_count = ( + SELECT COUNT(*) FROM chat_messages WHERE session_id = ? + ) + WHERE id = ? + ''', + (now, int(session_id), int(session_id)), + ) + return int(new_id) if new_id is not None else 0 + + def update_message_emotion(self, message_id: int, emotion_json: str) -> bool: + """ + 异步更新某条消息的 emotion_json(emotion_update 事件触发时调用)。 + + Args: + message_id: 消息 id + emotion_json: 序列化后的情感数据 + + Returns: + 是否实际更新了行 + """ + with self._db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + ''' + UPDATE chat_messages + SET emotion_json = ? + WHERE id = ? + ''', + (emotion_json, int(message_id)), + ) + return cursor.rowcount > 0 + + def list_messages( + self, + session_id: int, + limit: int = 200, + before_id: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """ + 拉取会话的消息列表,按 id 升序。 + + Args: + session_id: 会话 id + limit: 最多返回条数 + before_id: 可选;仅返回 id < before_id 的消息(向后翻页) + + Returns: + message 字典列表 + """ + try: + safe_limit = max(1, min(int(limit), 1000)) + except (TypeError, ValueError): + safe_limit = 200 + + with self._db.get_connection() as conn: + cursor = conn.cursor() + if before_id is not None: + cursor.execute( + ''' + SELECT * FROM chat_messages + WHERE session_id = ? AND id < ? + ORDER BY id ASC + LIMIT ? + ''', + (int(session_id), int(before_id), safe_limit), + ) + else: + cursor.execute( + ''' + SELECT * FROM chat_messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + ''', + (int(session_id), safe_limit), + ) + rows = cursor.fetchall() + return [self._row(r) for r in rows] + + +__all__ = ["SessionStore"] diff --git a/src/limbic/hippocampus/simple_hippocampus.py b/src/limbic/hippocampus/simple_hippocampus.py new file mode 100644 index 0000000..bef91e5 --- /dev/null +++ b/src/limbic/hippocampus/simple_hippocampus.py @@ -0,0 +1,103 @@ +""" +SimpleHippocampus - 海马体的 MVP 实现。 + +仅保留最近 N 条记忆,用于验证: +- 记忆查询(recall) +- 记忆存储(remember,通过事件订阅) +- 与 Cortex 的跨层通信 +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List + +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet, PacketType + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +class SimpleHippocampus(BaseModule): + """ + 简化版海马体。 + + 用内存中的 defaultdict(list) 按 user_id 存储最近消息, + 最大保留 MAX_MEMORY 条。 + """ + + module_id = "limbic.hippocampus" + module_type = "limbic" + MAX_MEMORY = 10 + + def __init__(self, router: "CentralRouter") -> None: + super().__init__(router) + self._memories: Dict[str, List[Dict]] = defaultdict(list) + + async def initialize(self) -> None: + # 订阅 memory_store 事件 + self.router.subscribe("memory_store", self._on_memory_store) + await super().initialize() + + async def handle(self, packet: Packet) -> Packet: + """ + 处理 memory_query / memory_store 请求。 + """ + if packet.channel == "memory_query": + return self._handle_query(packet) + if packet.channel == "memory_store": + return self._handle_store(packet) + + return packet.response({"status": "unknown_channel", "channel": packet.channel}) + + def _handle_query(self, packet: Packet) -> Packet: + """ + 根据 query 返回相关记忆。MVP 阶段简单返回最近 N 条。 + """ + user_id = packet.metadata.get("user_id", "default") + limit = packet.payload.get("limit", 3) + memories = self._memories.get(user_id, []) + + # MVP:简单返回最后 limit 条 + relevant = memories[-limit:] if memories else [] + + return packet.response({ + "memories": relevant, + "total": len(memories), + }) + + def _handle_store(self, packet: Packet) -> Packet: + """ + 同步存储记忆(直接请求方式)。 + """ + self._store( + user_id=packet.payload.get("user_id", "default"), + role=packet.payload.get("role", "user"), + content=packet.payload.get("content", ""), + ) + return packet.response({"status": "stored"}) + + async def _on_memory_store(self, packet: Packet) -> None: + """ + 事件订阅方式存储记忆。 + """ + if packet.channel != "memory_store": + return + self._store( + user_id=packet.payload.get("user_id", "default"), + role=packet.payload.get("role", "user"), + content=packet.payload.get("content", ""), + ) + + def _store(self, user_id: str, role: str, content: str) -> None: + """ + 存储一条记忆。 + """ + self._memories[user_id].append({ + "role": role, + "content": content, + }) + # 限制容量 + if len(self._memories[user_id]) > self.MAX_MEMORY: + self._memories[user_id].pop(0) diff --git a/src/nervous_system/__init__.py b/src/nervous_system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nervous_system/app.py b/src/nervous_system/app.py new file mode 100644 index 0000000..ba6bf9b --- /dev/null +++ b/src/nervous_system/app.py @@ -0,0 +1,149 @@ +""" +nervous_system/app.py - v4.0 MVP FastAPI 入口。 + +这是一个独立的入口,用于验证新的神经系统架构: +- CentralRouter +- HTTP Gateway +- EchoCortex +- SimpleHippocampus + +不会破坏现有的 v3.1.0 Web 后端。 +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from src.cerebellum.module import CerebellumModule +from src.cortex.echo_cortex import EchoCortex +from src.cortex.llm_core import LLMCore +from src.hypothalamus.module import HypothalamusModule +from src.limbic.amygdala.module import AmygdalaModule +from src.limbic.hippocampus.module import HippocampusModule +from src.limbic.hippocampus.simple_hippocampus import SimpleHippocampus +from src.prefrontal.module import PrefrontalModule +from src.prefrontal.workflow_module import WorkflowModule +from src.nervous_system.gateway.http_gateway import HTTPGateway +from src.nervous_system.gateway.llm_gateway import LLMGateway +from src.nervous_system.gateway.ws_gateway import WebSocketGateway +from src.nervous_system.router.central_router import CentralRouter +from src.nervous_system.router.pipeline import AuditMiddleware, TimingMiddleware + +logger = logging.getLogger(__name__) + + +class NeoApp: + """ + 新版 Neo Agent 应用容器。 + """ + + def __init__(self) -> None: + self.router = CentralRouter() + self.http_gateway = HTTPGateway(self.router) + self.ws_gateway = WebSocketGateway(self.router) + self.llm_gateway = LLMGateway(self.router) + self.echo_cortex = EchoCortex(self.router) + self.hippocampus = SimpleHippocampus(self.router) + self.hippocampus_full = HippocampusModule(self.router) + self.amygdala = AmygdalaModule(self.router) + self.hypothalamus = HypothalamusModule(self.router) + self.prefrontal = PrefrontalModule(self.router) + self.workflow = WorkflowModule(self.router) + self.cerebellum = CerebellumModule(self.router) + self.llm_core = LLMCore(self.router) + + async def initialize(self) -> None: + """ + 注册模块、添加中间件、初始化。 + """ + self.router.add_middleware(AuditMiddleware()) + self.router.add_middleware(TimingMiddleware()) + + self.router.register_module(self.echo_cortex.module_id, self.echo_cortex) + self.router.register_module(self.hippocampus.module_id, self.hippocampus) + self.router.register_module(self.hippocampus_full.module_id, self.hippocampus_full) + self.router.register_module(self.amygdala.module_id, self.amygdala) + self.router.register_module(self.hypothalamus.module_id, self.hypothalamus) + self.router.register_module(self.prefrontal.module_id, self.prefrontal) + self.router.register_module(self.workflow.module_id, self.workflow) + self.router.register_module(self.cerebellum.module_id, self.cerebellum) + self.router.register_module(self.llm_core.module_id, self.llm_core) + self.router.register_module(self.llm_gateway.module_id, self.llm_gateway) + + await self.router.initialize() + await self.http_gateway.start() + await self.ws_gateway.start() + logger.info("[NeoApp] v4.0 MVP 初始化完成") + + async def shutdown(self) -> None: + """ + 关闭应用。 + """ + await self.http_gateway.stop() + await self.ws_gateway.stop() + await self.router.shutdown() + logger.info("[NeoApp] v4.0 MVP 已关闭") + + +# 全局应用实例 +neo_app = NeoApp() + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """ + FastAPI 生命周期管理。 + """ + await neo_app.initialize() + yield + await neo_app.shutdown() + + +app = FastAPI( + title="Neo Agent v4.0 MVP", + version="4.0.0-mvp", + description="基于人脑认知架构的新一代 Neo Agent MVP", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/v4/health") +async def health_v4() -> dict: + """ + v4.0 健康检查。 + """ + return { + "status": "ok", + "version": "4.0.0-mvp", + "modules": list(neo_app.router._modules.keys()), + } + + +@app.post("/api/v4/chat") +async def chat_v4(request: Request) -> JSONResponse: + """ + v4.0 MVP 聊天接口。 + + 请求体: + {"content": "你好"} + + 响应: + {"data": {"role": "assistant", "content": "..."}, "trace_id": "..."} + """ + gateway_request = await neo_app.http_gateway.parse_fastapi_request(request) + response = await neo_app.http_gateway.handle(gateway_request) + return JSONResponse(status_code=response.status_code, content=response.body) diff --git a/src/nervous_system/base_module.py b/src/nervous_system/base_module.py new file mode 100644 index 0000000..dbf7dd6 --- /dev/null +++ b/src/nervous_system/base_module.py @@ -0,0 +1,110 @@ +""" +BaseModule - 所有认知功能模块的抽象基类。 + +每个模块(大脑皮层、海马体、杏仁核等)都必须继承此类, +并通过 CentralRouter 进行通信,而不是直接调用其他模块的方法。 +""" + +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, AsyncIterator, Dict + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + from src.nervous_system.router.packet import Packet + + +class BaseModule(ABC): + """ + 功能模块基类。 + + 子类必须实现: + - module_id: 模块唯一标识,如 "cortex.llm_core" + - module_type: 模块类型,如 "cortex" / "limbic" / "prefrontal" + - initialize / shutdown / handle + """ + + module_id: str = "base" + module_type: str = "base" + + def __init__(self, router: "CentralRouter") -> None: + self.router = router + self._initialized = False + + async def initialize(self) -> None: + """ + 异步初始化。子类可重写以加载模型、连接数据库等。 + """ + self._initialized = True + + async def shutdown(self) -> None: + """ + 异步关闭。子类可重写以释放资源。 + """ + self._initialized = False + + @abstractmethod + async def handle(self, packet: "Packet") -> "Packet": + """ + 处理来自 CentralRouter 的 Packet,返回响应 Packet。 + """ + ... + + async def handle_stream(self, packet: "Packet") -> AsyncIterator["Packet"]: + """ + 处理来自 CentralRouter 的流式请求。 + + 默认实现一次性 yield 响应包的 chunk / done 事件; + 子类可重写以提供真正的异步生成器流式输出。 + """ + from src.nervous_system.router.packet import PacketType + + response = await self.handle(packet) + if response.is_error(): + yield response + return + + if response.packet_type == PacketType.RESPONSE: + yield packet.stream_chunk(response.payload) + yield packet.stream_done() + + async def emit(self, channel: str, payload: Dict[str, Any]) -> None: + """ + 向事件总线发布事件。 + """ + from src.nervous_system.router.packet import Packet, PacketType + + packet = Packet( + trace_id=uuid.uuid4().hex, + source=self.module_id, + target="", + packet_type=PacketType.EVENT, + channel=channel, + payload=payload, + ) + await self.router.publish(packet) + + async def request( + self, + target: str, + channel: str, + payload: Dict[str, Any], + metadata: Dict[str, Any] | None = None, + ) -> "Packet": + """ + 向目标模块发送请求并等待响应。 + """ + from src.nervous_system.router.packet import Packet, PacketType + + packet = Packet( + trace_id=uuid.uuid4().hex, + source=self.module_id, + target=target, + packet_type=PacketType.REQUEST, + channel=channel, + payload=payload, + metadata=metadata or {}, + ) + return await self.router.route(packet) diff --git a/src/nervous_system/gateway/__init__.py b/src/nervous_system/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nervous_system/gateway/base_gateway.py b/src/nervous_system/gateway/base_gateway.py new file mode 100644 index 0000000..ace56bb --- /dev/null +++ b/src/nervous_system/gateway/base_gateway.py @@ -0,0 +1,57 @@ +""" +BaseGateway - 所有外部网关的抽象基类。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + + +@dataclass +class GatewayRequest: + """ + 网关统一请求格式。 + """ + + method: str # GET / POST / WS_MESSAGE 等 + path: str # 请求路径 + headers: Dict[str, str] = field(default_factory=dict) + query_params: Dict[str, str] = field(default_factory=dict) + body: Dict[str, Any] = field(default_factory=dict) + metadata: Dict[str, Any] = field(default_factory=dict) # user_id, trace_id 等 + + +@dataclass +class GatewayResponse: + """ + 网关统一响应格式。 + """ + + status_code: int + body: Dict[str, Any] = field(default_factory=dict) + headers: Dict[str, str] = field(default_factory=dict) + + +class BaseGateway(ABC): + """ + 外部网关基类。所有外部交互(HTTP/WebSocket/LLM/Storage)都必须通过网关实现。 + """ + + gateway_id: str = "base" + + @abstractmethod + async def start(self) -> None: + """启动网关。""" + ... + + @abstractmethod + async def stop(self) -> None: + """停止网关。""" + ... + + @abstractmethod + async def handle(self, request: GatewayRequest) -> GatewayResponse: + """处理一个请求。""" + ... diff --git a/src/nervous_system/gateway/http_gateway.py b/src/nervous_system/gateway/http_gateway.py new file mode 100644 index 0000000..8af6fcd --- /dev/null +++ b/src/nervous_system/gateway/http_gateway.py @@ -0,0 +1,134 @@ +""" +HTTPGateway - HTTP 外部访问网关。 + +职责: +- 将外部 HTTP 请求转换为 GatewayRequest +- 将 GatewayRequest 通过 CentralRouter 转发给内部模块 +- 将内部模块的 Packet 响应转换为 GatewayResponse +- 统一处理鉴权、限流、日志 +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, Dict + +from src.nervous_system.gateway.base_gateway import BaseGateway, GatewayRequest, GatewayResponse +from src.nervous_system.router.central_router import CentralRouter +from src.nervous_system.router.packet import Packet, PacketType + +logger = logging.getLogger(__name__) + + +class HTTPGateway(BaseGateway): + """ + HTTP 网关。 + + 在 FastAPI 中使用示例: + @app.post("/api/v4/chat") + async def chat_v4(request: Request): + gateway_request = await http_gateway.parse_request(request) + response = await http_gateway.handle(gateway_request) + return JSONResponse(status_code=response.status_code, content=response.body) + """ + + gateway_id = "http" + + def __init__(self, router: CentralRouter) -> None: + self.router = router + + async def start(self) -> None: + logger.info("[HTTPGateway] 已启动") + + async def stop(self) -> None: + logger.info("[HTTPGateway] 已停止") + + async def handle(self, request: GatewayRequest) -> GatewayResponse: + """ + 处理 HTTP 请求。 + + 当前 MVP 仅支持 /api/v4/chat,转发给 cortex.echo 或 cortex.llm_core。 + """ + trace_id = request.metadata.get("trace_id") or uuid.uuid4().hex + user_id = request.metadata.get("user_id") or "default" + + packet = Packet( + trace_id=trace_id, + source="nervous_system.gateway.http", + target=self._resolve_target(request.path), + packet_type=PacketType.REQUEST, + channel=self._resolve_channel(request.path), + payload=request.body, + metadata={ + "user_id": user_id, + "http_method": request.method, + "http_path": request.path, + "query_params": request.query_params, + }, + ) + + response = await self.router.route(packet) + + if response.is_error(): + return GatewayResponse( + status_code=500, + body={ + "error": response.payload.get("error", "unknown error"), + "code": response.payload.get("code", "INTERNAL_ERROR"), + "trace_id": response.trace_id, + }, + ) + + return GatewayResponse( + status_code=200, + body={ + "data": response.payload, + "trace_id": response.trace_id, + "metadata": response.metadata, + }, + ) + + async def parse_fastapi_request(self, request) -> GatewayRequest: + """ + 将 FastAPI Request 转换为 GatewayRequest。 + """ + from fastapi import Request + + body = {} + try: + body = await request.json() + except Exception: + pass + + return GatewayRequest( + method=request.method, + path=request.url.path, + headers=dict(request.headers), + query_params=dict(request.query_params), + body=body, + metadata={ + "user_id": request.headers.get("X-User-Id", "default"), + "trace_id": request.headers.get("X-Trace-Id", ""), + }, + ) + + def _resolve_target(self, path: str) -> str: + """ + 根据路径解析目标模块。MVP 阶段硬编码映射。 + """ + if path.startswith("/api/v4/chat"): + return "cortex.echo" + if path.startswith("/api/v4/memory"): + return "limbic.hippocampus" + return "cortex.echo" + + def _resolve_channel(self, path: str) -> str: + """ + 根据路径解析业务通道。 + """ + if path.startswith("/api/v4/chat"): + return "chat" + if path.startswith("/api/v4/memory"): + return "memory" + return "default" diff --git a/src/nervous_system/gateway/llm_gateway.py b/src/nervous_system/gateway/llm_gateway.py new file mode 100644 index 0000000..90967b5 --- /dev/null +++ b/src/nervous_system/gateway/llm_gateway.py @@ -0,0 +1,98 @@ +""" +LLMGateway - LLM 外部访问网关。 + +职责: +- 所有 LLM API 调用的唯一入口 +- 负载均衡、重试、熔断(MVP 阶段预留接口) +- Token 计数、成本统计(MVP 阶段预留接口) +- 响应缓存(MVP 阶段预留接口) + +v4.0 架构下,内部模块不直接调用 LangChain/OpenAI,而是通过 LLMGateway +向 cortex.llm_core 发送请求。 +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncIterator, Dict, List, Optional, Union + +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.central_router import CentralRouter +from src.nervous_system.router.packet import Packet, PacketType + +logger = logging.getLogger(__name__) + + +class LLMGateway(BaseModule): + """ + LLM 网关模块。 + + 既是一个 BaseModule(可被 CentralRouter 管理), + 也对外提供简化的 chat / embed / vision 接口。 + """ + + module_id = "nervous_system.gateway.llm" + module_type = "nervous_system" + + def __init__(self, router: CentralRouter) -> None: + super().__init__(router) + + async def handle(self, packet: Packet) -> Packet: + """ + 处理来自 Router 的 LLM 请求,转发给 cortex.llm_core。 + """ + if packet.channel in ("llm_chat", "llm_stream", "llm_template", "llm_info"): + return await self.router.route(Packet( + trace_id=packet.trace_id, + source=self.module_id, + target="cortex.llm_core", + packet_type=PacketType.REQUEST, + channel=packet.channel, + payload=packet.payload, + metadata=packet.metadata, + )) + + return Packet.error(packet, f"未知 LLM 通道: {packet.channel}", code="UNKNOWN_LLM_CHANNEL") + + async def chat( + self, + messages: List[Dict[str, str]], + task_type: str = "main", + trace_id: Optional[str] = None, + ) -> str: + """ + 同步聊天接口(简化版)。 + """ + response = await self.router.route(Packet( + trace_id=trace_id or "", + source=self.module_id, + target="cortex.llm_core", + packet_type=PacketType.REQUEST, + channel="llm_chat", + payload={"messages": messages, "task_type": task_type}, + )) + if response.is_error(): + raise RuntimeError(response.payload.get("error", "LLM error")) + return response.payload.get("content", "") + + async def chat_with_template( + self, + template: str, + variables: Dict[str, Any], + task_type: str = "main", + trace_id: Optional[str] = None, + ) -> str: + """ + 模板聊天接口。 + """ + response = await self.router.route(Packet( + trace_id=trace_id or "", + source=self.module_id, + target="cortex.llm_core", + packet_type=PacketType.REQUEST, + channel="llm_template", + payload={"template": template, "variables": variables, "task_type": task_type}, + )) + if response.is_error(): + raise RuntimeError(response.payload.get("error", "LLM error")) + return response.payload.get("content", "") diff --git a/src/nervous_system/gateway/ws_gateway.py b/src/nervous_system/gateway/ws_gateway.py new file mode 100644 index 0000000..f4fd261 --- /dev/null +++ b/src/nervous_system/gateway/ws_gateway.py @@ -0,0 +1,150 @@ +""" +WebSocketGateway - WebSocket 外部访问网关。 + +职责: +- 管理 WebSocket 连接生命周期 +- 将客户端消息转换为 GatewayRequest +- 通过 CentralRouter 与内部模块通信(支持同步与流式路由) +- 将内部事件/响应推送给客户端 +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, AsyncIterator, Dict + +from src.nervous_system.gateway.base_gateway import BaseGateway, GatewayRequest, GatewayResponse +from src.nervous_system.router.central_router import CentralRouter +from src.nervous_system.router.packet import Packet, PacketType + +logger = logging.getLogger(__name__) + + +class WebSocketGateway(BaseGateway): + """ + WebSocket 网关。 + + 将 /ws/chat 等 WebSocket 消息路由到 CentralRouter, + 并支持流式返回(SSE/WS chunk)。 + """ + + gateway_id = "websocket" + + def __init__(self, router: CentralRouter) -> None: + self.router = router + self._connections: Dict[str, Any] = {} + + async def start(self) -> None: + logger.info("[WebSocketGateway] 已启动") + + async def stop(self) -> None: + self._connections.clear() + logger.info("[WebSocketGateway] 已停止") + + async def handle(self, request: GatewayRequest) -> GatewayResponse: + """ + 处理一条 WebSocket 消息(同步路径)。 + + 默认将 /ws/chat 消息转发给 prefrontal.workflow 的 chat_workflow 通道。 + """ + packet = self._build_packet(request) + response = await self.router.route(packet) + + if response.is_error(): + return GatewayResponse( + status_code=500, + body={ + "type": "error", + "error": response.payload.get("error", "unknown error"), + "code": response.payload.get("code", "INTERNAL_ERROR"), + "trace_id": response.trace_id, + }, + ) + + return GatewayResponse( + status_code=200, + body={ + "type": "message", + "data": response.payload, + "trace_id": response.trace_id, + }, + ) + + async def handle_stream( + self, + request: GatewayRequest, + ) -> AsyncIterator[Packet]: + """ + 流式处理一条 WebSocket 消息。 + + 将请求构造为 Packet 后,调用 CentralRouter.route_stream, + 直接 yield 目标模块返回的流式 Packet(chunk / done / error)。 + """ + packet = self._build_packet(request) + target = self._resolve_target(request.path) + channel = self._resolve_channel(request.path) + packet.target = target + packet.channel = channel + + async for response_packet in self.router.route_stream(packet): + yield response_packet + + async def register_connection(self, conn_id: str, websocket: Any) -> None: + """注册一个 WebSocket 连接。""" + self._connections[conn_id] = websocket + logger.debug("[WebSocketGateway] 注册连接: %s", conn_id) + + async def unregister_connection(self, conn_id: str) -> None: + """注销一个 WebSocket 连接。""" + self._connections.pop(conn_id, None) + logger.debug("[WebSocketGateway] 注销连接: %s", conn_id) + + async def push(self, conn_id: str, message: Dict[str, Any]) -> None: + """向指定连接推送消息。""" + ws = self._connections.get(conn_id) + if ws is None: + logger.warning("[WebSocketGateway] 连接不存在: %s", conn_id) + return + try: + await ws.send_json(message) + except Exception as exc: + logger.error("[WebSocketGateway] 推送失败: %s", exc) + + def _build_packet(self, request: GatewayRequest) -> Packet: + """将 GatewayRequest 转换为 CentralRouter 可路由的 Packet。""" + trace_id = request.metadata.get("trace_id") or uuid.uuid4().hex + user_id = request.metadata.get("user_id") or "default" + conn_id = request.metadata.get("conn_id") or uuid.uuid4().hex + + target = self._resolve_target(request.path) + channel = self._resolve_channel(request.path) + + return Packet( + trace_id=trace_id, + source="nervous_system.gateway.websocket", + target=target, + packet_type=PacketType.REQUEST, + channel=channel, + payload=request.body, + metadata={ + "user_id": user_id, + "conn_id": conn_id, + **request.metadata, + }, + ) + + def _resolve_target(self, path: str) -> str: + """根据 WebSocket 路径解析目标模块。""" + if path == "/ws/chat" or path.endswith("/ws/chat"): + return "prefrontal.workflow" + return "cortex.echo" + + def _resolve_channel(self, path: str) -> str: + """根据 WebSocket 路径解析业务通道。""" + if path == "/ws/chat" or path.endswith("/ws/chat"): + return "chat_workflow" + return "chat" + + +__all__ = ["WebSocketGateway"] \ No newline at end of file diff --git a/src/nervous_system/router/__init__.py b/src/nervous_system/router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nervous_system/router/central_router.py b/src/nervous_system/router/central_router.py new file mode 100644 index 0000000..adc7544 --- /dev/null +++ b/src/nervous_system/router/central_router.py @@ -0,0 +1,227 @@ +""" +CentralRouter - 神经系统中央路由器。 + +所有内部模块间通信的中枢。负责: +- 按 target 路由 Packet +- 管理事件订阅 +- 执行中间件链 +- 全链路追踪 +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, AsyncIterator, Callable, Coroutine, Dict, List, Optional + +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet, PacketType + +logger = logging.getLogger(__name__) + +Middleware = Callable[[Packet, Callable[[Packet], Coroutine[Any, Any, Packet]]], Coroutine[Any, Any, Packet]] +EventHandler = Callable[[Packet], Coroutine[Any, Any, None]] + + +class CentralRouter: + """ + 中央路由器。 + + 使用方式: + router = CentralRouter() + router.register_module("cortex.llm_core", llm_module) + router.add_middleware(audit_middleware) + await router.initialize() + response = await router.route(packet) + """ + + def __init__(self) -> None: + self._modules: Dict[str, BaseModule] = {} + self._subscribers: Dict[str, List[EventHandler]] = {} + self._middlewares: List[Middleware] = [] + self._event_queue: asyncio.Queue[Packet] = asyncio.Queue() + self._event_task: Optional[asyncio.Task] = None + self._running = False + + def register_module(self, module_id: str, module: BaseModule) -> None: + """ + 注册一个功能模块。 + """ + if module_id in self._modules: + raise ValueError(f"模块已注册: {module_id}") + self._modules[module_id] = module + logger.debug("[CentralRouter] 注册模块: %s", module_id) + + def unregister_module(self, module_id: str) -> None: + """ + 注销模块。 + """ + self._modules.pop(module_id, None) + + def add_middleware(self, middleware: Middleware) -> None: + """ + 添加中间件。中间件按添加顺序执行。 + """ + self._middlewares.append(middleware) + + def subscribe(self, channel: str, handler: EventHandler) -> None: + """ + 订阅某个通道的事件。 + """ + self._subscribers.setdefault(channel, []).append(handler) + + def unsubscribe(self, channel: str, handler: EventHandler) -> None: + """ + 取消订阅。 + """ + if channel in self._subscribers: + self._subscribers[channel] = [h for h in self._subscribers[channel] if h != handler] + + async def initialize(self) -> None: + """ + 初始化所有已注册模块,并启动事件分发任务。 + """ + self._running = True + for module in self._modules.values(): + try: + await module.initialize() + except Exception as exc: + logger.error("[CentralRouter] 初始化模块 %s 失败: %s", module.module_id, exc) + raise + self._event_task = asyncio.create_task(self._event_loop()) + logger.info("[CentralRouter] 初始化完成,模块数: %d", len(self._modules)) + + async def shutdown(self) -> None: + """ + 关闭所有模块和事件循环。 + """ + self._running = False + if self._event_task: + self._event_task.cancel() + try: + await self._event_task + except asyncio.CancelledError: + pass + for module in self._modules.values(): + try: + await module.shutdown() + except Exception as exc: + logger.error("[CentralRouter] 关闭模块 %s 失败: %s", module.module_id, exc) + logger.info("[CentralRouter] 已关闭") + + async def route(self, packet: Packet) -> Packet: + """ + 路由一个 Packet 到目标模块并返回响应。 + """ + start_time = time.time() + packet.metadata.setdefault("router_hops", []).append({ + "module": "central_router", + "action": "route", + "timestamp": start_time, + }) + + handler = self._build_handler() + try: + response = await handler(packet) + except Exception as exc: + logger.exception("[CentralRouter] 路由异常: %s", exc) + response = Packet.error(packet, str(exc), code="ROUTER_ERROR") + + elapsed_ms = (time.time() - start_time) * 1000 + logger.debug("[CentralRouter] 路由耗时 %.3fms: %s -> %s", elapsed_ms, packet.source, packet.target) + return response + + async def route_stream(self, packet: Packet) -> AsyncIterator[Packet]: + """ + 流式路由一个 Packet 到目标模块。 + + 直接调用目标模块的 handle_stream,绕过同步中间件链, + 但会记录 trace 并在出错时 yield ERROR/STREAM_ERROR 包。 + """ + start_time = time.time() + packet.metadata.setdefault("router_hops", []).append({ + "module": "central_router", + "action": "route_stream", + "timestamp": start_time, + }) + + target_module = self._modules.get(packet.target) + if target_module is None: + yield Packet.error(packet, f"目标模块未找到: {packet.target}", code="MODULE_NOT_FOUND") + return + + try: + async for chunk in target_module.handle_stream(packet): + yield chunk + except Exception as exc: + logger.exception("[CentralRouter] 流式路由异常: %s", exc) + yield packet.stream_error(str(exc), code="ROUTER_ERROR") + + elapsed_ms = (time.time() - start_time) * 1000 + logger.debug("[CentralRouter] 流式路由耗时 %.3fms: %s -> %s", elapsed_ms, packet.source, packet.target) + + async def publish(self, packet: Packet) -> None: + """ + 发布事件到队列,由事件循环异步分发给订阅者。 + """ + await self._event_queue.put(packet) + + def _build_handler(self) -> Callable[[Packet], Coroutine[Any, Any, Packet]]: + """ + 构建中间件链,最终调用目标模块的 handle。 + """ + async def final_handler(packet: Packet) -> Packet: + if packet.packet_type == PacketType.EVENT: + # 事件类型直接分发,不返回响应 + await self._dispatch_event(packet) + return packet.response({"status": "dispatched"}) + + target_module = self._modules.get(packet.target) + if target_module is None: + return Packet.error(packet, f"目标模块未找到: {packet.target}", code="MODULE_NOT_FOUND") + + return await target_module.handle(packet) + + handler: Callable[[Packet], Coroutine[Any, Any, Packet]] = final_handler + for middleware in reversed(self._middlewares): + current = middleware + next_handler = handler + handler = lambda packet, m=current, n=next_handler: m(packet, n) + + return handler + + async def _dispatch_event(self, packet: Packet) -> None: + """ + 分发事件给订阅者。 + """ + handlers = self._subscribers.get(packet.channel, []) + if not handlers: + logger.debug("[CentralRouter] 通道 %s 无订阅者", packet.channel) + return + + await asyncio.gather( + *(self._safe_handle(handler, packet) for handler in handlers), + return_exceptions=True, + ) + + async def _safe_handle(self, handler: EventHandler, packet: Packet) -> None: + try: + await handler(packet) + except Exception as exc: + logger.error("[CentralRouter] 事件处理异常: %s", exc) + + async def _event_loop(self) -> None: + """ + 后台事件分发循环。 + """ + while self._running: + try: + packet = await asyncio.wait_for(self._event_queue.get(), timeout=1.0) + await self._dispatch_event(packet) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + except Exception as exc: + logger.error("[CentralRouter] 事件循环异常: %s", exc) diff --git a/src/nervous_system/router/packet.py b/src/nervous_system/router/packet.py new file mode 100644 index 0000000..8948946 --- /dev/null +++ b/src/nervous_system/router/packet.py @@ -0,0 +1,144 @@ +""" +Packet - 神经系统数据包 + +所有内部模块间通信的基本单元,类似神经系统中的动作电位信号。 +每个 Packet 携带 trace_id,支持全链路追踪。 +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, Optional + + +class PacketType(Enum): + """数据包类型。""" + + REQUEST = "request" # 同步/异步请求 + EVENT = "event" # 事件发布(广播) + RESPONSE = "response" # 请求响应 + ERROR = "error" # 错误响应 + STREAM = "stream" # 流式数据包(chunk / done / error) + + +class Priority(Enum): + """数据包优先级。""" + + LOW = 0 + NORMAL = 1 + HIGH = 2 + CRITICAL = 3 + + +@dataclass +class Packet: + """ + 神经系统数据包。 + + Attributes: + trace_id: 全链路追踪 ID,同一个用户请求的所有 Packet 共享。 + source: 来源模块 ID,如 "cortex.llm_core"。 + target: 目标模块 ID(EVENT 类型可为空字符串)。 + packet_type: 数据包类型。 + channel: 业务通道,如 "chat" / "memory" / "emotion" / "schedule"。 + payload: 业务数据字典。 + metadata: 元数据,用于中间件传递上下文(user_id、权限等)。 + priority: 优先级。 + timestamp: 创建时间戳(秒级浮点数)。 + """ + + source: str + target: str + packet_type: PacketType + channel: str + payload: Dict[str, Any] = field(default_factory=dict) + metadata: Dict[str, Any] = field(default_factory=dict) + trace_id: str = field(default_factory=lambda: uuid.uuid4().hex) + priority: Priority = Priority.NORMAL + timestamp: float = field(default_factory=time.time) + + @staticmethod + def error(origin: Packet, message: str, code: str = "INTERNAL_ERROR") -> Packet: + """ + 基于原始 Packet 生成错误响应包。 + """ + return Packet( + trace_id=origin.trace_id, + source="nervous_system.router", + target=origin.source, + packet_type=PacketType.ERROR, + channel=origin.channel, + payload={"error": message, "code": code, "original_target": origin.target}, + metadata=origin.metadata, + ) + + def is_request(self) -> bool: + return self.packet_type == PacketType.REQUEST + + def is_event(self) -> bool: + return self.packet_type == PacketType.EVENT + + def is_response(self) -> bool: + return self.packet_type == PacketType.RESPONSE + + def is_error(self) -> bool: + return self.packet_type == PacketType.ERROR + + def response(self, payload: Dict[str, Any]) -> Packet: + """ + 基于当前请求包构造响应包。 + """ + return Packet( + trace_id=self.trace_id, + source=self.target, + target=self.source, + packet_type=PacketType.RESPONSE, + channel=self.channel, + payload=payload, + metadata=self.metadata, + ) + + def stream_chunk(self, payload: Dict[str, Any]) -> Packet: + """ + 基于当前请求包构造流式数据包。 + """ + return Packet( + trace_id=self.trace_id, + source=self.target, + target=self.source, + packet_type=PacketType.STREAM, + channel=self.channel, + payload={"stream_event": "chunk", **payload}, + metadata=self.metadata, + ) + + def stream_done(self) -> Packet: + """ + 基于当前请求包构造流式结束包。 + """ + return Packet( + trace_id=self.trace_id, + source=self.target, + target=self.source, + packet_type=PacketType.STREAM, + channel=self.channel, + payload={"stream_event": "done"}, + metadata=self.metadata, + ) + + def stream_error(self, message: str, code: str = "STREAM_ERROR") -> Packet: + """ + 基于当前请求包构造流式错误包。 + """ + return Packet( + trace_id=self.trace_id, + source=self.target, + target=self.source, + packet_type=PacketType.STREAM, + channel=self.channel, + payload={"stream_event": "error", "error": message, "code": code}, + metadata=self.metadata, + ) diff --git a/src/nervous_system/router/pipeline.py b/src/nervous_system/router/pipeline.py new file mode 100644 index 0000000..4431093 --- /dev/null +++ b/src/nervous_system/router/pipeline.py @@ -0,0 +1,110 @@ +""" +Pipeline - 路由中间件示例。 + +提供常用的中间件实现: +- AuditMiddleware: 审计日志 +- TraceMiddleware: 追踪链路上的跳数 +- AuthMiddleware: 简单访问控制 +- TimingMiddleware: 记录模块处理耗时 +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Callable, Coroutine + +from src.nervous_system.router.packet import Packet + +logger = logging.getLogger(__name__) + +NextHandler = Callable[[Packet], Coroutine[Any, Any, Packet]] +Middleware = Callable[[Packet, NextHandler], Coroutine[Any, Any, Packet]] + + +class AuditMiddleware: + """ + 审计中间件:记录每个 Packet 的路由路径。 + """ + + name = "audit" + + async def __call__(self, packet: Packet, next_handler: NextHandler) -> Packet: + logger.info( + "[Audit] trace=%s %s -> %s type=%s channel=%s", + packet.trace_id, + packet.source, + packet.target, + packet.packet_type.value, + packet.channel, + ) + response = await next_handler(packet) + logger.info( + "[Audit] trace=%s response type=%s", + packet.trace_id, + response.packet_type.value, + ) + return response + + +class TraceMiddleware: + """ + 追踪中间件:记录 Packet 经过的模块跳数。 + """ + + name = "trace" + + async def __call__(self, packet: Packet, next_handler: NextHandler) -> Packet: + packet.metadata.setdefault("hops", []).append({ + "middleware": self.name, + "timestamp": time.time(), + }) + response = await next_handler(packet) + response.metadata.setdefault("hops", []).extend(packet.metadata.get("hops", [])) + return response + + +class TimingMiddleware: + """ + 耗时中间件:记录每个请求的总处理耗时。 + """ + + name = "timing" + + async def __call__(self, packet: Packet, next_handler: NextHandler) -> Packet: + start = time.time() + response = await next_handler(packet) + elapsed_ms = (time.time() - start) * 1000 + response.metadata.setdefault("timings", []).append({ + "middleware": self.name, + "elapsed_ms": elapsed_ms, + }) + return response + + +class AuthMiddleware: + """ + 访问控制中间件:检查 metadata 中是否包含 user_id。 + + 示例用途:保护敏感模块(如 memory、schedule)不被匿名请求访问。 + """ + + name = "auth" + + def __init__(self, protected_targets: list[str] | None = None) -> None: + self.protected_targets = set(protected_targets or []) + + async def __call__(self, packet: Packet, next_handler: NextHandler) -> Packet: + if packet.target in self.protected_targets and not packet.metadata.get("user_id"): + from src.nervous_system.router.packet import PacketType + + return Packet( + trace_id=packet.trace_id, + source="nervous_system.auth", + target=packet.source, + packet_type=PacketType.ERROR, + channel=packet.channel, + payload={"error": "unauthorized", "code": "UNAUTHORIZED"}, + metadata=packet.metadata, + ) + return await next_handler(packet) diff --git a/src/nps/nps_invoker.py b/src/nps/nps_invoker.py index b081d23..4c2ac42 100644 --- a/src/nps/nps_invoker.py +++ b/src/nps/nps_invoker.py @@ -10,6 +10,7 @@ import requests from src.tools.debug_logger import get_debug_logger from src.nps.nps_registry import NPSRegistry, NPSTool +from src.core import llm_providers # v3.1.0: 统一供应商解析 load_dotenv() @@ -32,8 +33,12 @@ def __init__(self, registry: NPSRegistry = None, **kwargs): **kwargs: 其他参数(用于向后兼容,会被忽略) """ # API配置 - self.api_key = os.getenv('SILICONFLOW_API_KEY') - self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + # v3.1.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = llm_providers.resolve_api_key() + try: + self.api_url = llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') # NPS LLM 判断配置(带错误处理) diff --git a/src/prefrontal/__init__.py b/src/prefrontal/__init__.py new file mode 100644 index 0000000..1f8b3bb --- /dev/null +++ b/src/prefrontal/__init__.py @@ -0,0 +1,5 @@ +""" +Prefrontal Cortex - 前额叶:规划与决策。 + +包含日程管理、主动引擎、事件管理等规划相关模块。 +""" diff --git a/src/prefrontal/event/__init__.py b/src/prefrontal/event/__init__.py new file mode 100644 index 0000000..840b041 --- /dev/null +++ b/src/prefrontal/event/__init__.py @@ -0,0 +1,3 @@ +""" +Prefrontal / Event - 事件管理子模块。 +""" diff --git a/src/prefrontal/event/event_manager.py b/src/prefrontal/event/event_manager.py new file mode 100644 index 0000000..0ec87ad --- /dev/null +++ b/src/prefrontal/event/event_manager.py @@ -0,0 +1,755 @@ +""" +事件管理模块 +为智能体提供事件驱动功能,支持通知型和任务型事件 +""" + +import json +import uuid +import asyncio +import inspect +from datetime import datetime +from typing import Dict, Any, List, Optional, Callable, Union +from enum import Enum +from src.core.database_manager import DatabaseManager +from src.tools.debug_logger import get_debug_logger + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class EventType(Enum): + """事件类型枚举""" + NOTIFICATION = "notification" # 通知型事件 + TASK = "task" # 任务型事件 + + +class EventPriority(Enum): + """事件优先级枚举""" + LOW = 1 + MEDIUM = 2 + HIGH = 3 + URGENT = 4 + + +class EventStatus(Enum): + """事件状态枚举""" + PENDING = "pending" # 待处理 + PROCESSING = "processing" # 处理中 + COMPLETED = "completed" # 已完成 + FAILED = "failed" # 失败 + CANCELLED = "cancelled" # 已取消 + + +class Event: + """ + 事件基类 + """ + + def __init__( + self, + event_id: str = None, + title: str = "", + description: str = "", + event_type: EventType = EventType.NOTIFICATION, + priority: EventPriority = EventPriority.MEDIUM, + created_at: str = None, + status: EventStatus = EventStatus.PENDING, + metadata: Dict[str, Any] = None + ): + """ + 初始化事件 + + Args: + event_id: 事件唯一标识符 + title: 事件标题 + description: 事件描述 + event_type: 事件类型 + priority: 事件优先级 + created_at: 创建时间 + status: 事件状态 + metadata: 附加元数据 + """ + self.event_id = event_id or str(uuid.uuid4()) + self.title = title + self.description = description + self.event_type = event_type + self.priority = priority + self.created_at = created_at or datetime.now().isoformat() + self.status = status + self.metadata = metadata or {} + + def to_dict(self) -> Dict[str, Any]: + """ + 将事件转换为字典 + + Returns: + 事件字典表示 + """ + return { + 'event_id': self.event_id, + 'title': self.title, + 'description': self.description, + 'event_type': self.event_type.value, + 'priority': self.priority.value, + 'created_at': self.created_at, + 'status': self.status.value, + 'metadata': self.metadata + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Event': + """ + 从字典创建事件 + + Args: + data: 事件字典数据 + + Returns: + 事件对象 + """ + return cls( + event_id=data.get('event_id'), + title=data.get('title', ''), + description=data.get('description', ''), + event_type=EventType(data.get('event_type', 'notification')), + priority=EventPriority(data.get('priority', 2)), + created_at=data.get('created_at'), + status=EventStatus(data.get('status', 'pending')), + metadata=data.get('metadata', {}) + ) + + +class NotificationEvent(Event): + """ + 通知型事件 + 智能体需要立即理解并向用户说明的外部信息 + """ + + def __init__(self, **kwargs): + """初始化通知型事件""" + super().__init__(event_type=EventType.NOTIFICATION, **kwargs) + + +class TaskEvent(Event): + """ + 任务型事件 + 智能体需要理解任务要求、规划并完成任务 + """ + + def __init__( + self, + task_requirements: str = "", + completion_criteria: str = "", + **kwargs + ): + """ + 初始化任务型事件 + + Args: + task_requirements: 任务要求描述 + completion_criteria: 任务完成标准 + **kwargs: 其他事件参数 + """ + super().__init__(event_type=EventType.TASK, **kwargs) + self.metadata['task_requirements'] = task_requirements + self.metadata['completion_criteria'] = completion_criteria + self.metadata['subtasks'] = [] # 子任务列表 + self.metadata['progress'] = [] # 进度记录 + + +class EventManager: + """ + 事件管理器 + 负责事件的创建、存储、检索和处理 + """ + + def __init__(self, db_manager: DatabaseManager = None): + """ + 初始化事件管理器 + + Args: + db_manager: 数据库管理器实例 + """ + self.db = db_manager or DatabaseManager() + # 进程内监听器:用于把事件转发到 WebSocket / GUI 订阅者 + # Listeners registry: key=event_type(str), value=List[Callable] + self._listeners: Dict[str, List[Callable]] = {} + self._initialize_database() + + debug_logger.log_module('EventManager', '事件管理器初始化完成') + + def _initialize_database(self): + """初始化数据库表""" + with self.db.get_connection() as conn: + # 创建事件表 + conn.execute(''' + CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + event_type TEXT NOT NULL, + priority INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + completed_at TEXT, + metadata TEXT + ) + ''') + + # 创建事件处理日志表 + conn.execute(''' + CREATE TABLE IF NOT EXISTS event_logs ( + log_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + log_type TEXT NOT NULL, + log_content TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (event_id) REFERENCES events(event_id) + ) + ''') + + debug_logger.log_info('EventManager', '数据库表初始化完成') + + def create_event( + self, + title: str, + description: str, + event_type: EventType, + priority: EventPriority = EventPriority.MEDIUM, + task_requirements: str = "", + completion_criteria: str = "" + ) -> Event: + """ + 创建新事件 + + Args: + title: 事件标题 + description: 事件描述 + event_type: 事件类型 + priority: 事件优先级 + task_requirements: 任务要求(仅任务型事件) + completion_criteria: 完成标准(仅任务型事件) + + Returns: + 创建的事件对象 + """ + debug_logger.log_module('EventManager', '创建新事件', { + 'title': title, + 'type': event_type.value + }) + + if event_type == EventType.TASK: + event = TaskEvent( + title=title, + description=description, + priority=priority, + task_requirements=task_requirements, + completion_criteria=completion_criteria + ) + else: + event = NotificationEvent( + title=title, + description=description, + priority=priority + ) + + # 保存到数据库 + try: + with self.db.get_connection() as conn: + conn.execute(''' + INSERT INTO events ( + event_id, title, description, event_type, + priority, status, created_at, metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + event.event_id, + event.title, + event.description, + event.event_type.value, + event.priority.value, + event.status.value, + event.created_at, + json.dumps(event.metadata, ensure_ascii=False) + )) + + debug_logger.log_info('EventManager', '事件创建成功', { + 'event_id': event.event_id + }) + + return event + + except Exception as e: + debug_logger.log_error('EventManager', f'创建事件失败: {str(e)}', e) + raise RuntimeError(f"Failed to create event: {str(e)}") from e + + def add_event(self, **kwargs) -> Event: + """ + 事件添加的便捷入口(与 create_event 等价,供神经系统 channel 使用)。 + + Returns: + 创建的事件对象 + """ + return self.create_event(**kwargs) + + def get_event(self, event_id: str) -> Optional[Event]: + """ + 获取指定事件 + + Args: + event_id: 事件ID + + Returns: + 事件对象,不存在时返回None + """ + with self.db.get_connection() as conn: + cursor = conn.execute( + 'SELECT * FROM events WHERE event_id = ?', + (event_id,) + ) + row = cursor.fetchone() + + if row: + data = { + 'event_id': row[0], + 'title': row[1], + 'description': row[2], + 'event_type': row[3], + 'priority': row[4], + 'status': row[5], + 'created_at': row[6], + 'metadata': json.loads(row[9]) if row[9] else {} + } + return Event.from_dict(data) + + return None + + def get_pending_events(self, limit: int = 10) -> List[Event]: + """ + 获取待处理的事件列表(按优先级和创建时间排序) + + Args: + limit: 返回数量限制 + + Returns: + 事件列表 + """ + with self.db.get_connection() as conn: + cursor = conn.execute(''' + SELECT * FROM events + WHERE status = ? + ORDER BY priority DESC, created_at ASC + LIMIT ? + ''', (EventStatus.PENDING.value, limit)) + + events = [] + for row in cursor.fetchall(): + data = { + 'event_id': row[0], + 'title': row[1], + 'description': row[2], + 'event_type': row[3], + 'priority': row[4], + 'status': row[5], + 'created_at': row[6], + 'metadata': json.loads(row[9]) if row[9] else {} + } + events.append(Event.from_dict(data)) + + return events + + def get_all_events( + self, + status: Optional[EventStatus] = None, + event_type: Optional[EventType] = None, + limit: int = 100 + ) -> List[Event]: + """ + 获取所有事件(可选过滤) + + Args: + status: 状态过滤 + event_type: 类型过滤 + limit: 返回数量限制 + + Returns: + 事件列表 + """ + query = 'SELECT * FROM events WHERE 1=1' + params = [] + + if status: + query += ' AND status = ?' + params.append(status.value) + + if event_type: + query += ' AND event_type = ?' + params.append(event_type.value) + + query += ' ORDER BY created_at DESC LIMIT ?' + params.append(limit) + + with self.db.get_connection() as conn: + cursor = conn.execute(query, params) + + events = [] + for row in cursor.fetchall(): + data = { + 'event_id': row[0], + 'title': row[1], + 'description': row[2], + 'event_type': row[3], + 'priority': row[4], + 'status': row[5], + 'created_at': row[6], + 'metadata': json.loads(row[9]) if row[9] else {} + } + events.append(Event.from_dict(data)) + + return events + + def get_events( + self, + status: Optional[EventStatus] = None, + event_type: Optional[EventType] = None, + limit: int = 100, + ) -> List[Event]: + """ + 列出事件(v4.0 神经系统入口)。 + + Returns: + 事件列表 + """ + return self.get_all_events(status=status, event_type=event_type, limit=limit) + + def update_event_status( + self, + event_id: str, + status: EventStatus, + log_message: str = "" + ) -> bool: + """ + 更新事件状态 + + Args: + event_id: 事件ID + status: 新状态 + log_message: 日志消息 + + Returns: + 是否成功 + """ + try: + now = datetime.now().isoformat() + + with self.db.get_connection() as conn: + # 更新事件状态 + conn.execute(''' + UPDATE events + SET status = ?, updated_at = ? + WHERE event_id = ? + ''', (status.value, now, event_id)) + + # 如果是完成状态,记录完成时间 + if status == EventStatus.COMPLETED: + conn.execute(''' + UPDATE events + SET completed_at = ? + WHERE event_id = ? + ''', (now, event_id)) + + # 添加日志 + if log_message: + self.add_event_log(event_id, 'status_change', log_message) + + debug_logger.log_info('EventManager', '事件状态更新', { + 'event_id': event_id, + 'new_status': status.value + }) + + return True + + except Exception as e: + debug_logger.log_error('EventManager', f'更新事件状态失败: {str(e)}', e) + return False + + def add_event_log( + self, + event_id: str, + log_type: str, + log_content: str + ): + """ + 添加事件处理日志 + + Args: + event_id: 事件ID + log_type: 日志类型 + log_content: 日志内容 + """ + with self.db.get_connection() as conn: + conn.execute(''' + INSERT INTO event_logs (event_id, log_type, log_content, created_at) + VALUES (?, ?, ?, ?) + ''', (event_id, log_type, log_content, datetime.now().isoformat())) + + def get_event_logs(self, event_id: str) -> List[Dict[str, Any]]: + """ + 获取事件处理日志 + + Args: + event_id: 事件ID + + Returns: + 日志列表 + """ + with self.db.get_connection() as conn: + cursor = conn.execute(''' + SELECT log_type, log_content, created_at + FROM event_logs + WHERE event_id = ? + ORDER BY created_at ASC + ''', (event_id,)) + + logs = [] + for row in cursor.fetchall(): + logs.append({ + 'log_type': row[0], + 'log_content': row[1], + 'created_at': row[2] + }) + + return logs + + def delete_event(self, event_id: str) -> bool: + """ + 删除事件 + + Args: + event_id: 事件ID + + Returns: + 是否成功 + """ + try: + with self.db.get_connection() as conn: + # 删除事件日志 + conn.execute( + 'DELETE FROM event_logs WHERE event_id = ?', + (event_id,) + ) + + # 删除事件 + conn.execute( + 'DELETE FROM events WHERE event_id = ?', + (event_id,) + ) + + debug_logger.log_info('EventManager', '事件删除成功', { + 'event_id': event_id + }) + + return True + + except Exception as e: + debug_logger.log_error('EventManager', f'删除事件失败: {str(e)}', e) + return False + + def get_statistics(self) -> Dict[str, Any]: + """ + 获取事件统计信息 + + Returns: + 统计信息字典 + """ + with self.db.get_connection() as conn: + cursor = conn.execute(''' + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN event_type = ? THEN 1 ELSE 0 END) as notifications, + SUM(CASE WHEN event_type = ? THEN 1 ELSE 0 END) as tasks + FROM events + ''', ( + EventStatus.PENDING.value, + EventStatus.PROCESSING.value, + EventStatus.COMPLETED.value, + EventType.NOTIFICATION.value, + EventType.TASK.value + )) + + row = cursor.fetchone() + return { + 'total_events': row[0] or 0, + 'pending': row[1] or 0, + 'processing': row[2] or 0, + 'completed': row[3] or 0, + 'notifications': row[4] or 0, + 'tasks': row[5] or 0 + } + + # ===================================================================== + # Stage D.1 / D.2 集成:进程内事件发布与订阅 + # 用于把 scheduler / proactive 等内部事件转发到 Web 后端 WebSocket 客户端 + # ===================================================================== + + def subscribe(self, event_type: str, callback: Callable) -> Callable: + """ + 注册事件订阅者(按 event_type 字符串匹配)。 + Register an in-process listener for a given event type. + + Args: + event_type: 事件类型字符串,例如 "scheduler_tick" / "proactive_message" + callback: 回调函数,签名 callback(event_type: str, payload: dict) + 可以是同步函数或 async 协程函数。 + + Returns: + 取消订阅的函数 unsubscribe(),方便调用方做清理。 + """ + try: + key = str(event_type or '').strip() or '*' + self._listeners.setdefault(key, []).append(callback) + + def _unsubscribe() -> None: + self.unsubscribe(event_type, callback) + + return _unsubscribe + except Exception as e: + debug_logger.log_error('EventManager', f'订阅事件失败({event_type}): {e}', e) + return lambda: None + + def unsubscribe(self, event_type: str, callback: Callable) -> bool: + """ + 取消事件订阅。 + """ + try: + key = str(event_type or '').strip() or '*' + listeners = self._listeners.get(key) + if not listeners: + return False + try: + listeners.remove(callback) + except ValueError: + return False + if not listeners: + self._listeners.pop(key, None) + return True + except Exception as e: + debug_logger.log_error('EventManager', f'取消订阅失败({event_type}): {e}', e) + return False + + def publish(self, event_or_type: Union[str, 'Event', Any], + payload: Optional[Dict[str, Any]] = None) -> None: + """ + 发布事件到所有匹配的订阅者(不阻塞)。 + Publish an event to all matching listeners without blocking. + + 支持两种签名(保持向后兼容): + 1) publish(event_type: str, payload: dict) + - 用于 BackgroundScheduler / ProactiveEngine 等内部模块 + 2) publish(event: Event) + - 兼容未来 / 现有以 Event 对象发布的调用方 + + 异常保护:单个 listener 抛错不会影响其他 listener。 + """ + try: + # 解析 event_type 与 payload + event_type: str + if isinstance(event_or_type, str): + event_type = event_or_type + payload_dict: Dict[str, Any] = dict(payload or {}) + else: + # 视为 Event 对象 + event = event_or_type + event_type = ( + getattr(event, 'event_type', None).value + if hasattr(getattr(event, 'event_type', None), 'value') + else str(getattr(event, 'event_type', 'event')) + ) + payload_dict = { + 'event_id': getattr(event, 'event_id', None), + 'title': getattr(event, 'title', ''), + 'description': getattr(event, 'description', ''), + 'content': ( + getattr(event, 'content', '') + or (event.metadata.get('content') if getattr(event, 'metadata', None) else '') + ), + 'priority': getattr(event, 'priority', None).value + if hasattr(getattr(event, 'priority', None), 'value') + else getattr(event, 'priority', None), + 'metadata': dict(getattr(event, 'metadata', {}) or {}), + 'created_at': getattr(event, 'created_at', None), + } + # 注入显式 payload(如果传入) + if payload: + payload_dict.update(payload) + + # 合并时间戳(仅在缺失时填充) + if 'timestamp' not in payload_dict: + payload_dict['timestamp'] = datetime.now().isoformat() + + # 调度同步 listener;async listener 走 asyncio 事件循环 + listeners = list(self._listeners.get(event_type, [])) + \ + list(self._listeners.get('*', [])) + for cb in listeners: + self._dispatch_listener(cb, event_type, payload_dict) + except Exception as e: + debug_logger.log_error('EventManager', f'发布事件失败: {e}', e) + + @staticmethod + def _dispatch_listener(cb: Callable, event_type: str, + payload: Dict[str, Any]) -> None: + """ + 分发事件到单个 listener。同步函数直接调用,async 协程尝试调度到事件循环。 + """ + try: + if inspect.iscoroutinefunction(cb): + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # 当前已有运行中的事件循环,调度为 task + loop.create_task(cb(event_type, payload)) + else: + # 没有运行中的循环,直接同步等待 + loop.run_until_complete(cb(event_type, payload)) + except RuntimeError: + # 没有事件循环时降级:run_until_complete 兜底 + try: + asyncio.run(cb(event_type, payload)) + except Exception as inner_e: + debug_logger.log_error( + 'EventManager', + f'async listener 执行失败({event_type}): {inner_e}', + inner_e, + ) + else: + cb(event_type, payload) + except Exception as e: + debug_logger.log_error('EventManager', f'listener 执行失败({event_type}): {e}', e) + + +# 进程内单例(供 BackgroundScheduler / ProactiveEngine 等模块无依赖访问) +_global_event_manager: Optional[EventManager] = None +_global_event_manager_lock = None + + +def get_event_manager() -> EventManager: + """ + 获取全局 EventManager 单例(线程安全)。 + """ + global _global_event_manager + try: + import threading as _threading + global _global_event_manager_lock + if _global_event_manager_lock is None: + _global_event_manager_lock = _threading.Lock() + with _global_event_manager_lock: + if _global_event_manager is None: + _global_event_manager = EventManager() + except Exception as e: + debug_logger.log_error('EventManager', f'获取全局 EventManager 失败: {e}', e) + if _global_event_manager is None: + _global_event_manager = EventManager() + return _global_event_manager diff --git a/src/prefrontal/module.py b/src/prefrontal/module.py new file mode 100644 index 0000000..176f588 --- /dev/null +++ b/src/prefrontal/module.py @@ -0,0 +1,268 @@ +""" +PrefrontalModule - 前额叶模块(v4.0 神经系统接入层)。 + +将 src.prefrontal.schedule、src.prefrontal.proactive、src.prefrontal.event +中的规划与决策能力包装为 BaseModule,使其可以通过 CentralRouter 被其他模块调用。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from src.core.database_manager import DatabaseManager +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet +from src.prefrontal.event.event_manager import ( + Event, + EventManager, + EventPriority, + EventStatus, + EventType, +) +from src.prefrontal.proactive.proactive_engine import ProactiveEngine +from src.prefrontal.schedule.schedule_manager import ( + Schedule, + ScheduleManager, + SchedulePriority, + ScheduleType, +) + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +def _parse_datetime(value: Optional[str]) -> Optional[datetime]: + """将 ISO 格式字符串解析为 datetime,失败时返回 None。""" + if value is None: + return None + try: + return datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + + +def _coerce_schedule_type(value: Any) -> Any: + """若 value 为字符串,则转换为 ScheduleType 枚举。""" + if isinstance(value, str): + try: + return ScheduleType(value) + except ValueError: + return value + return value + + +def _coerce_schedule_priority(value: Any) -> Any: + """若 value 为整数/字符串,则转换为 SchedulePriority 枚举。""" + if isinstance(value, int): + try: + return SchedulePriority(value) + except ValueError: + return value + if isinstance(value, str): + try: + return SchedulePriority(int(value)) + except (ValueError, TypeError): + return value + return value + + +def _coerce_event_type(value: Any) -> Any: + """若 value 为字符串,则转换为 EventType 枚举。""" + if isinstance(value, str): + try: + return EventType(value) + except ValueError: + return value + return value + + +def _coerce_event_priority(value: Any) -> Any: + """若 value 为整数/字符串,则转换为 EventPriority 枚举。""" + if isinstance(value, int): + try: + return EventPriority(value) + except ValueError: + return value + if isinstance(value, str): + try: + return EventPriority(int(value)) + except (ValueError, TypeError): + return value + return value + + +def _coerce_event_status(value: Any) -> Any: + """若 value 为字符串,则转换为 EventStatus 枚举。""" + if isinstance(value, str): + try: + return EventStatus(value) + except ValueError: + return value + return value + + +def _schedule_to_dict(schedule: Schedule) -> Dict[str, Any]: + """将 Schedule 对象序列化为字典。""" + return schedule.to_dict() + + +def _event_to_dict(event: Event) -> Dict[str, Any]: + """将 Event 对象序列化为字典。""" + return event.to_dict() + + +class PrefrontalModule(BaseModule): + """ + 前额叶模块。 + + 负责日程管理、主动决策、事件管理等规划相关能力。 + """ + + module_id = "prefrontal.planner" + module_type = "prefrontal" + + def __init__( + self, + router: "CentralRouter", + db_manager: Optional[DatabaseManager] = None, + ) -> None: + super().__init__(router) + self._db = db_manager or DatabaseManager() + self._schedule_manager = ScheduleManager(self._db) + self._event_manager = EventManager(self._db) + self._proactive_engine = ProactiveEngine( + db_manager=self._db, + event_manager=self._event_manager, + ) + + async def handle(self, packet: Packet) -> Packet: + """ + 处理规划相关请求。 + + Channels: + - schedule_list: 列出日程 + - schedule_add: 创建日程 + - schedule_delete: 删除日程 + - proactive_should_send: 主动消息发送决策 + - proactive_schedule_next: 排定下次主动消息 + - event_list: 列出事件 + - event_add: 创建事件 + """ + channel = packet.channel + payload = packet.payload + + try: + if channel == "schedule_list": + return self._handle_schedule_list(packet, payload) + if channel == "schedule_add": + return self._handle_schedule_add(packet, payload) + if channel == "schedule_delete": + return self._handle_schedule_delete(packet, payload) + if channel == "proactive_should_send": + return self._handle_proactive_should_send(packet, payload) + if channel == "proactive_schedule_next": + return self._handle_proactive_schedule_next(packet, payload) + if channel == "event_list": + return self._handle_event_list(packet, payload) + if channel == "event_add": + return self._handle_event_add(packet, payload) + except Exception as exc: # noqa: BLE001 + return Packet.error(packet, str(exc), code="INTERNAL_ERROR") + + return packet.response({ + "status": "unknown_channel", + "channel": channel, + }) + + def _handle_schedule_list(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + kwargs = { + "start_time": payload.get("start_time"), + "end_time": payload.get("end_time"), + "queryable_only": payload.get("queryable_only", True), + "active_only": payload.get("active_only", True), + "include_inactive": payload.get("include_inactive", False), + "limit": payload.get("limit", 1000), + } + schedules = self._schedule_manager.list_schedules(**kwargs) + return packet.response({ + "schedules": [_schedule_to_dict(s) for s in schedules], + "total": len(schedules), + }) + + def _handle_schedule_add(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + kwargs = dict(payload) + if "schedule_type" in kwargs: + kwargs["schedule_type"] = _coerce_schedule_type(kwargs["schedule_type"]) + if "priority" in kwargs: + kwargs["priority"] = _coerce_schedule_priority(kwargs["priority"]) + + success, schedule, message = self._schedule_manager.add_schedule(**kwargs) + return packet.response({ + "success": success, + "schedule": _schedule_to_dict(schedule) if schedule else None, + "message": message, + }) + + def _handle_schedule_delete(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + schedule_id = payload.get("schedule_id") + if not schedule_id: + return Packet.error(packet, "schedule_id required", code="INVALID_REQUEST") + deleted = self._schedule_manager.delete_schedule(schedule_id) + return packet.response({"deleted": deleted}) + + def _handle_proactive_should_send( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + user = payload.get("user") + if not user: + return Packet.error(packet, "user required", code="INVALID_REQUEST") + now = _parse_datetime(payload.get("now")) + should_send, reason = self._proactive_engine.should_send(user, now=now) + return packet.response({"should_send": should_send, "reason": reason}) + + def _handle_proactive_schedule_next( + self, packet: Packet, payload: Dict[str, Any] + ) -> Packet: + user = payload.get("user") + if not user: + return Packet.error(packet, "user required", code="INVALID_REQUEST") + now = _parse_datetime(payload.get("now")) + delay_hours = payload.get("delay_hours") + if delay_hours is not None: + try: + delay_hours = float(delay_hours) + except (ValueError, TypeError): + return Packet.error(packet, "delay_hours must be a number", code="INVALID_REQUEST") + self._proactive_engine.schedule_next_proactive( + user, now=now, delay_hours=delay_hours + ) + return packet.response({"status": "scheduled"}) + + def _handle_event_list(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + status = _coerce_event_status(payload.get("status")) + event_type = _coerce_event_type(payload.get("event_type")) + limit = payload.get("limit", 100) + events = self._event_manager.get_events( + status=status, event_type=event_type, limit=limit + ) + return packet.response({ + "events": [_event_to_dict(e) for e in events], + "total": len(events), + }) + + def _handle_event_add(self, packet: Packet, payload: Dict[str, Any]) -> Packet: + kwargs = dict(payload) + if "event_type" in kwargs: + kwargs["event_type"] = _coerce_event_type(kwargs["event_type"]) + if "priority" in kwargs: + kwargs["priority"] = _coerce_event_priority(kwargs["priority"]) + + event = self._event_manager.add_event(**kwargs) + return packet.response({ + "event": _event_to_dict(event), + "event_id": event.event_id, + }) + + +__all__ = ["PrefrontalModule"] diff --git a/src/prefrontal/proactive/__init__.py b/src/prefrontal/proactive/__init__.py new file mode 100644 index 0000000..2651db1 --- /dev/null +++ b/src/prefrontal/proactive/__init__.py @@ -0,0 +1,3 @@ +""" +Prefrontal / Proactive - 主动决策与想法池子模块。 +""" diff --git a/src/prefrontal/proactive/proactive_engine.py b/src/prefrontal/proactive/proactive_engine.py new file mode 100644 index 0000000..31bb78b --- /dev/null +++ b/src/prefrontal/proactive/proactive_engine.py @@ -0,0 +1,403 @@ +""" +主动决策与想法池模块 +- 想法池(impulse pool):多来源候选(habit_care / open_loop_recall / creative_share / weather_note) +- 核心决策:should_send 综合多因子(驱动力 / 就绪度 / 关系温度 / 闸门 / 复核) +- 主动消息经 EventManager 投递回 GUI + +参考架构:spec §4 ADDED Requirements → ProactiveEngine +""" + +import os +import json +import random +import uuid +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional, Tuple + +from src.core.database_manager import DatabaseManager +from src.core.llm_helper import LLMHelper +from src.core.prompt_manager import get_prompt_manager +from src.prefrontal.event.event_manager import EventManager, EventPriority, NotificationEvent +from src.tools.debug_logger import get_debug_logger + +ENABLE_PROACTIVE_ENGINE = os.getenv('ENABLE_PROACTIVE_ENGINE', 'false').lower() == 'true' + +debug_logger = get_debug_logger() + + +class ProactiveEngine: + """ + 主动决策与想法池。 + - bot_proactive_drive: Bot 内在驱动(创作冲动 / 梦境残留 / 生活状态影响) + - proactive_inner_readiness: 当下情绪就绪度(情绪轮 + 关系温度) + - proactive_impulse_pool: 候选想法(habit_care / open_loop_recall / creative_share / weather_note) + - should_send: 多因子决策 + - bot_proactive_drive: 触发主动消息经 EventManager 投递 + """ + + QUIET_HOURS_START = 23 + QUIET_HOURS_END = 7 + DEFAULT_DAILY_LIMIT = 2 + MIN_INTERVAL_MINUTES = 60 + DEFAULT_REVIEW_COOLDOWN_HOURS = 4 + + def __init__(self, db_manager: DatabaseManager = None, + prompt_manager=None, + event_manager: Optional[EventManager] = None, + life_state=None, + emotion_wheel=None, + open_loop_tracker=None, + creative_writer=None, + user_habits=None): + self.db = db_manager or DatabaseManager() + self.prompt_manager = prompt_manager or get_prompt_manager() + self.event_manager = event_manager + self.life_state = life_state + self.emotion_wheel = emotion_wheel + self.open_loop_tracker = open_loop_tracker + self.creative_writer = creative_writer + self.user_habits = user_habits + self.enabled = ENABLE_PROACTIVE_ENGINE + + # ===== 多因子决策 ===== + + def should_send(self, user: str, + now: Optional[datetime] = None) -> Tuple[bool, str]: + """ + 多因子决策: + - bot_proactive_drive + - proactive_inner_readiness + - 静默时段闸门 + - effective_user_daily_limit + - 复核冷却 + 返回 (bool, reason)。 + """ + if not self.enabled: + return False, 'disabled' + now = now or datetime.now() + if self._is_quiet_time(now): + return False, 'quiet_hours' + if not self._cooldown_passed(user, now): + return False, 'cooldown' + if self._daily_limit_reached(user, now): + return False, 'daily_limit' + drive = self.bot_proactive_drive(user, now=now) + readiness = self.proactive_inner_readiness(user, now=now) + # 简单阈值:驱动力 + 就绪度 >= 0.5 + score = drive.get('score', 0.0) + readiness.get('score', 0.0) + if score < 0.5: + return False, f'low_score({score:.2f})' + return True, f'pass(score={score:.2f})' + + def schedule_next_proactive(self, user: str, now: Optional[datetime] = None, + delay_hours: Optional[float] = None) -> None: + """记录下次主动消息时间。""" + if not self.enabled: + return + now = now or datetime.now() + if delay_hours is None: + delay_hours = random.uniform(2.0, 6.0) + next_at = (now + timedelta(hours=delay_hours)).isoformat() + self.db.set_metadata(f'next_proactive_{user}', next_at) + debug_logger.log_info('ProactiveEngine', '已排定下次主动消息', { + 'user': user, 'next_at': next_at, + }) + + # ===== 内在驱动 / 就绪度 ===== + + def bot_proactive_drive(self, user: Optional[str] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """ + Bot 内在驱动:综合 创作冲动 + 梦境残留 + 生活状态 + 返回 {score(0-1), components: {creative, dream, life_state}} + """ + if not self.enabled: + return {'score': 0.0, 'components': {}} + creative = 0.0 + if self.creative_writer and getattr(self.creative_writer, 'enabled', False): + try: + finished = self.creative_writer.get_finished_projects(limit=3) + creative = min(1.0, 0.4 * len(finished)) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'创作驱动评估失败: {e}', e) + + dream = 0.0 + if self.life_state and getattr(self.life_state, 'enabled', False): + try: + snap = self.life_state.get_current_state_snapshot() or {} + # 疲惫/低落时降低驱动 + mood = snap.get('mood', '') + if mood in ('焦虑', '低落', '疲倦'): + dream = 0.2 + else: + dream = 0.5 + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'生活状态评估失败: {e}', e) + + score = (creative + dream) / 2.0 + return { + 'score': round(score, 3), + 'components': { + 'creative': round(creative, 3), + 'dream': round(dream, 3), + }, + } + + def proactive_inner_readiness(self, user: str, + now: Optional[datetime] = None) -> Dict[str, Any]: + """ + 当下情绪就绪度:情绪轮正向 + 关系温度 + 返回 {score(0-1), emotion_summary} + """ + if not self.enabled: + return {'score': 0.0, 'emotion_summary': ''} + score = 0.0 + summary = '' + if self.emotion_wheel and getattr(self.emotion_wheel, 'enabled', False): + try: + state = {'emotions': {}} + # 简化的情绪轮评分 + score = 0.5 + summary = '情绪轮活跃' + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'情绪就绪度评估失败: {e}', e) + + # 关系温度:从情感分析历史读取 + try: + latest = self.db.get_latest_emotion() + if latest: + overall = int(latest.get('overall_score', 0)) + if overall >= 60: + score = max(score, 0.6) + elif overall < 30: + score = min(score, 0.3) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'关系温度评估失败: {e}', e) + + return {'score': round(score, 3), 'emotion_summary': summary} + + # ===== 想法池 ===== + + def proactive_impulse_pool(self, user: str) -> List[Dict[str, Any]]: + """ + 候选想法列表。 + 来源:habit_care / open_loop_recall / creative_share / weather_note + """ + if not self.enabled: + return [] + ideas: List[Dict[str, Any]] = [] + + # habit_care + if self.user_habits and getattr(self.user_habits, 'enabled', False): + try: + ideas.extend(self._ideas_from_habits(user)) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'habit 想法失败: {e}', e) + + # open_loop_recall + if self.open_loop_tracker and getattr(self.open_loop_tracker, 'enabled', False): + try: + ideas.extend(self._ideas_from_open_loops(user)) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'open_loop 想法失败: {e}', e) + + # creative_share + if self.creative_writer and getattr(self.creative_writer, 'enabled', False): + try: + ideas.extend(self._ideas_from_creative(user)) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'creative 想法失败: {e}', e) + + # weather_note + ideas.extend(self._ideas_from_weather(user)) + return ideas + + def _ideas_from_habits(self, user: str) -> List[Dict[str, Any]]: + ideas = [] + try: + event = self.user_habits.habit_proactive_event(user) + except Exception: + return ideas + if not event: + return ideas + ideas.append({ + 'id': str(uuid.uuid4()), + 'reason': 'habit_care', + 'motive': event.get('content', '该吃饭了'), + 'window_start': datetime.now().isoformat(), + 'window_end': (datetime.now() + timedelta(hours=2)).isoformat(), + 'priority': 'normal', + 'used': False, + }) + return ideas + + def _ideas_from_open_loops(self, user: str) -> List[Dict[str, Any]]: + ideas = [] + try: + loops = self.db.get_open_loops(status='open', limit=3) + except Exception: + return ideas + for lp in loops: + ideas.append({ + 'id': str(uuid.uuid4()), + 'reason': 'open_loop_recall', + 'motive': f"想起之前的「{lp.get('topic', '?')}」,要不要继续聊聊?", + 'window_start': datetime.now().isoformat(), + 'window_end': (datetime.now() + timedelta(hours=3)).isoformat(), + 'priority': 'low', + 'used': False, + 'related_loop_uuid': lp.get('uuid'), + }) + return ideas + + def _ideas_from_creative(self, user: str) -> List[Dict[str, Any]]: + ideas = [] + try: + finished = self.creative_writer.get_finished_projects(limit=3) + except Exception: + return ideas + for proj in finished: + ideas.append({ + 'id': str(uuid.uuid4()), + 'reason': 'creative_share', + 'motive': f"刚把《{proj.get('title', '?')}》写完了,要不要看看?", + 'window_start': datetime.now().isoformat(), + 'window_end': (datetime.now() + timedelta(hours=4)).isoformat(), + 'priority': 'normal', + 'used': False, + 'related_project_uuid': proj.get('uuid'), + }) + return ideas + + def _ideas_from_weather(self, user: str) -> List[Dict[str, Any]]: + # 简化的天气提示:随机注入(实际接入 weather 模块时替换) + notes = [ + '今天天气不错,要不要出去走走?', + '外面下雨啦,记得带伞。', + '天气转凉了,多穿点。', + ] + return [{ + 'id': str(uuid.uuid4()), + 'reason': 'weather_note', + 'motive': random.choice(notes), + 'window_start': datetime.now().isoformat(), + 'window_end': (datetime.now() + timedelta(hours=6)).isoformat(), + 'priority': 'low', + 'used': False, + }] + + # ===== 投递 ===== + + def bot_proactive_drive_send(self, user: str, + idea: Dict[str, Any]) -> bool: + """ + 触发主动消息并经 EventManager 投递回 GUI / Web 端。 + + Stage D.2: 通过 EventManager.publish("proactive_message", payload) 推送 + 独立 EventManager 单例 + try/except 保护,绝不阻塞主流程。 + """ + if not self.enabled: + return False + message = (idea or {}).get('motive', '') if isinstance(idea, dict) else '' + if not message: + debug_logger.log_info('ProactiveEngine', 'idea 为空,跳过投递') + return False + + payload: Dict[str, Any] = { + 'content': message, + 'timestamp': datetime.now().isoformat(), + 'user': user, + 'reason': (idea or {}).get('reason', '') if isinstance(idea, dict) else '', + 'idea_id': (idea or {}).get('id', '') if isinstance(idea, dict) else '', + 'priority': (idea or {}).get('priority', 'normal') if isinstance(idea, dict) else 'normal', + 'source': 'ProactiveEngine', + } + + # Stage D.2: 优先使用注入的 event_manager;否则走全局单例 + em = self.event_manager + if em is None: + try: + from src.prefrontal.event.event_manager import get_event_manager + em = get_event_manager() + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'获取 EventManager 失败: {e}', e) + em = None + + # 推送给 EventManager(绝对 try/except,绝不阻塞主流程) + try: + if em is not None: + em.publish('proactive_message', payload) + else: + # 兜底:保留旧的 NotificationEvent 写库方式(如果 EventManager 不可用) + event = NotificationEvent( + event_id=str(uuid.uuid4()), + title='主动消息', + description=message, + source='ProactiveEngine', + priority=EventPriority.MEDIUM, + metadata={'content': message, 'user': user, + 'reason': payload['reason'], + 'idea_id': payload['idea_id']}, + ) + # 即便 fallback 也仅记录日志,不抛出 + debug_logger.log_info('ProactiveEngine', 'fallback 通知(无 EventManager)', { + 'event_id': event.event_id, + }) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'proactive_message 推送失败: {e}', e) + + debug_logger.log_info('ProactiveEngine', '主动消息已投递', { + 'user': user, 'reason': payload['reason'], + }) + # 更新今日主动次数 + try: + self._increment_daily_count(user) + except Exception as e: + debug_logger.log_error('ProactiveEngine', f'更新今日主动次数失败: {e}', e) + return True + + # ===== 工具方法 ===== + + def _is_quiet_time(self, now: datetime) -> bool: + h = now.hour + if self.QUIET_HOURS_START > self.QUIET_HOURS_END: + return h >= self.QUIET_HOURS_START or h < self.QUIET_HOURS_END + return self.QUIET_HOURS_START <= h < self.QUIET_HOURS_END + + def _cooldown_passed(self, user: str, now: datetime) -> bool: + last = self.db.get_metadata(f'last_proactive_{user}', '') + if not last: + return True + try: + last_dt = datetime.fromisoformat(last) + except (ValueError, TypeError): + return True + delta = (now - last_dt).total_seconds() / 60.0 + return delta >= self.MIN_INTERVAL_MINUTES + + def _daily_limit_reached(self, user: str, now: datetime) -> bool: + today = now.date().isoformat() + last_day = self.db.get_metadata(f'last_proactive_day_{user}', '') + if last_day != today: + return False + count = int(self.db.get_metadata(f'proactive_count_{today}_{user}', 0)) + return count >= self.effective_user_daily_limit(user, now) + + def effective_user_daily_limit(self, user: str, now: datetime) -> int: + """默认上限;可被角色设定或用户偏好覆盖。""" + override = self.db.get_metadata(f'proactive_limit_{user}', None) + if override is not None: + try: + return int(override) + except (TypeError, ValueError): + pass + return self.DEFAULT_DAILY_LIMIT + + def _increment_daily_count(self, user: str) -> None: + now = datetime.now() + today = now.date().isoformat() + key = f'proactive_count_{today}_{user}' + count = int(self.db.get_metadata(key, 0)) + 1 + self.db.set_metadata(key, count) + self.db.set_metadata(f'last_proactive_day_{user}', today) + self.db.set_metadata(f'last_proactive_{user}', now.isoformat()) diff --git a/src/prefrontal/schedule/__init__.py b/src/prefrontal/schedule/__init__.py new file mode 100644 index 0000000..04a60c3 --- /dev/null +++ b/src/prefrontal/schedule/__init__.py @@ -0,0 +1,3 @@ +""" +Prefrontal / Schedule - 日程规划子模块。 +""" diff --git a/src/prefrontal/schedule/schedule_generator.py b/src/prefrontal/schedule/schedule_generator.py new file mode 100644 index 0000000..7cfc44f --- /dev/null +++ b/src/prefrontal/schedule/schedule_generator.py @@ -0,0 +1,315 @@ +""" +临时日程生成工具 +用于在空闲时间段自动生成临时日程 +""" + +import os +import re +import json +import time +from typing import List, Dict, Any, Tuple +from datetime import datetime, timedelta +from dotenv import load_dotenv +import requests +from src.prefrontal.schedule.schedule_manager import ScheduleManager, ScheduleType, SchedulePriority +from src.cortex.providers import registry as llm_providers # v3.1.0: 统一供应商解析 +from src.tools.debug_logger import get_debug_logger + +load_dotenv() + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class TemporaryScheduleGenerator: + """ + 临时日程生成器 + 在用户询问日程时,如果没有临时日程,则生成1-3个合理的临时日程 + """ + + def __init__(self, schedule_manager: ScheduleManager = None): + """ + 初始化临时日程生成器 + + Args: + schedule_manager: 日程管理器实例 + """ + self.schedule_manager = schedule_manager or ScheduleManager() + # v3.1.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = llm_providers.resolve_api_key() + try: + self.api_url = llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') + self.temperature = 0.8 + self.max_tokens = 1000 + + debug_logger.log_module('TemporaryScheduleGenerator', '临时日程生成器初始化完成') + + def generate_temporary_schedules( + self, + date: str, + character_name: str = "智能体", + character_info: Dict[str, str] = None, + context: str = "" + ) -> List[Dict[str, Any]]: + """ + 为指定日期生成临时日程 + + Args: + date: 日期(ISO格式,如 "2024-01-15") + character_name: 智能体名称 + character_info: 智能体信息 + context: 对话上下文 + + Returns: + 生成的日程列表 + """ + debug_logger.log_module('TemporaryScheduleGenerator', '开始生成临时日程', { + 'date': date + }) + + # 获取空闲时间段 + free_slots = self.schedule_manager.get_free_time_slots(date, slot_duration_minutes=60) + + if not free_slots: + debug_logger.log_info('TemporaryScheduleGenerator', '没有可用的空闲时间段') + return [] + + # 使用LLM生成临时日程建议 + character_info = character_info or {} + hobby = character_info.get('hobby', '阅读、学习') + personality = character_info.get('personality', '活泼开朗') + + system_prompt = f"""你是一个日程规划助手,帮助智能体{character_name}规划临时日程。 + +{character_name}的信息: +- 性格:{personality} +- 爱好:{hobby} + +任务:根据提供的空闲时间段,生成1-3个合理的临时日程建议。 + +要求: +1. 日程内容应该符合{character_name}的性格和爱好 +2. 日程应该多样化,包括学习、娱乐、休息等 +3. 考虑时间段的特点(如早上适合学习,晚上适合放松) +4. 部分日程可以涉及用户参与(如"一起看电影"),这类日程需要标注involves_user=true + +请以JSON格式输出日程建议列表: +[ + {{ + "title": "日程标题", + "description": "详细描述", + "time_slot_index": 0, + "duration_hours": 1.5, + "involves_user": false, + "reason": "选择这个活动的原因" + }}, + ... +] + +注意:time_slot_index是指使用第几个空闲时间段(从0开始)""" + + # 格式化空闲时间段信息 + slots_info = [] + for i, (start, end) in enumerate(free_slots): + start_dt = datetime.fromisoformat(start) + end_dt = datetime.fromisoformat(end) + duration = (end_dt - start_dt).total_seconds() / 3600 + time_period = self._get_time_period(start_dt.hour) + slots_info.append(f"时间段{i}: {start_dt.strftime('%H:%M')}-{end_dt.strftime('%H:%M')} ({time_period}, {duration:.1f}小时)") + + user_prompt = f"""可用的空闲时间段: +{chr(10).join(slots_info)} + +{f"对话上下文:{context}" if context else ""} + +请为{character_name}生成1-3个临时日程建议。""" + + try: + # 调用LLM生成日程 + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': messages, + 'temperature': self.temperature, + 'max_tokens': self.max_tokens, + 'stream': False + } + + debug_logger.log_request('TemporaryScheduleGenerator', self.api_url, payload, headers) + + start_time = time.time() + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=30 + ) + elapsed_time = time.time() - start_time + + debug_logger.log_response('TemporaryScheduleGenerator', response.status_code, response.text, elapsed_time) + + if response.status_code == 200: + result = response.json() + content = result['choices'][0]['message']['content'].strip() + + # 尝试解析JSON结果 + try: + # 提取JSON部分 + json_match = re.search(r'```(?:json)?\s*(\[.*?\])\s*```', content, re.DOTALL) + if json_match: + content = json_match.group(1) + + suggestions = json.loads(content) + + # 创建临时日程 + created_schedules = [] + for suggestion in suggestions[:3]: # 最多3个 + slot_index = suggestion.get('time_slot_index', 0) + if slot_index >= len(free_slots): + continue + + start, end = free_slots[slot_index] + duration_hours = suggestion.get('duration_hours', 1.0) + + # 计算实际的结束时间 + start_dt = datetime.fromisoformat(start) + actual_end = start_dt + timedelta(hours=duration_hours) + slot_end_dt = datetime.fromisoformat(end) + + # 确保不超过时间段 + if actual_end > slot_end_dt: + actual_end = slot_end_dt + + involves_user = suggestion.get('involves_user', False) + + # 创建临时日程 + success, schedule, message = self.schedule_manager.create_schedule( + title=suggestion.get('title', '临时活动'), + description=suggestion.get('description', ''), + schedule_type=ScheduleType.TEMPORARY, + start_time=start_dt.isoformat(), + end_time=actual_end.isoformat(), + priority=SchedulePriority.LOW, + generated_reason=suggestion.get('reason', ''), + involves_user=involves_user + ) + + if success: + created_schedules.append(schedule.to_dict()) + debug_logger.log_info('TemporaryScheduleGenerator', f'临时日程创建成功: {schedule.title}') + + return created_schedules + + except json.JSONDecodeError as e: + debug_logger.log_error('TemporaryScheduleGenerator', f'解析JSON失败: {content}', e) + return self._create_fallback_schedules(free_slots[:1], character_name) + else: + debug_logger.log_error('TemporaryScheduleGenerator', f'API调用失败: {response.status_code}') + return self._create_fallback_schedules(free_slots[:1], character_name) + + except Exception as e: + debug_logger.log_error('TemporaryScheduleGenerator', f'生成临时日程异常: {str(e)}', e) + return self._create_fallback_schedules(free_slots[:1], character_name) + + def _get_time_period(self, hour: int) -> str: + """ + 根据小时数获取时间段描述 + + Args: + hour: 小时数(0-23) + + Returns: + 时间段描述 + """ + if 5 <= hour < 9: + return "早晨" + elif 9 <= hour < 12: + return "上午" + elif 12 <= hour < 14: + return "中午" + elif 14 <= hour < 18: + return "下午" + elif 18 <= hour < 22: + return "晚上" + else: + return "深夜" + + def _create_fallback_schedules( + self, + free_slots: List[Tuple[str, str]], + character_name: str + ) -> List[Dict[str, Any]]: + """ + 创建降级的临时日程(当LLM不可用时) + + Args: + free_slots: 空闲时间段列表 + character_name: 智能体名称 + + Returns: + 创建的日程列表 + """ + if not free_slots: + return [] + + created_schedules = [] + + # 使用第一个空闲时间段 + start, end = free_slots[0] + start_dt = datetime.fromisoformat(start) + end_dt = start_dt + timedelta(hours=1.5) + + # 根据时间段选择活动 + hour = start_dt.hour + if 9 <= hour < 12: + title = "阅读时光" + description = "安静地阅读一本喜欢的书" + elif 14 <= hour < 18: + title = "学习充电" + description = "学习新知识,充实自己" + else: + title = "休闲放松" + description = "放松心情,做些喜欢的事" + + success, schedule, _ = self.schedule_manager.create_schedule( + title=title, + description=description, + schedule_type=ScheduleType.TEMPORARY, + start_time=start_dt.isoformat(), + end_time=end_dt.isoformat(), + priority=SchedulePriority.LOW, + generated_reason="自动生成的临时日程" + ) + + if success: + created_schedules.append(schedule.to_dict()) + + return created_schedules + + def has_temporary_schedules_today(self) -> bool: + """ + 检查今天是否已有临时日程 + + Returns: + 是否已有临时日程 + """ + today = datetime.now().date().isoformat() + start_of_day = f"{today}T00:00:00" + end_of_day = f"{today}T23:59:59" + + schedules = self.schedule_manager.get_schedules_by_time_range(start_of_day, end_of_day) + + # 检查是否有临时日程 + return any(s.schedule_type == ScheduleType.TEMPORARY for s in schedules) diff --git a/src/prefrontal/schedule/schedule_manager.py b/src/prefrontal/schedule/schedule_manager.py new file mode 100644 index 0000000..7d886f6 --- /dev/null +++ b/src/prefrontal/schedule/schedule_manager.py @@ -0,0 +1,917 @@ +""" +智能体日程管理模块(v4.0 前额叶实现) +实现日程的创建、管理、冲突检测和优先级控制 +""" + +import json +import uuid +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional, Tuple +from enum import Enum +from src.core.database_manager import DatabaseManager +from src.tools.debug_logger import get_debug_logger + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class ScheduleType(Enum): + """日程类型枚举""" + RECURRING = "recurring" # 周期日程(如课程表) + APPOINTMENT = "appointment" # 预约日程(用户提及或意图识别) + TEMPORARY = "temporary" # 临时日程(LLM自动生成) + + +class SchedulePriority(Enum): + """日程优先级枚举""" + LOW = 1 # 低优先级(临时日程) + MEDIUM = 2 # 中等优先级(一般预约) + HIGH = 3 # 高优先级(重要预约) + CRITICAL = 4 # 关键优先级(周期日程如课程) + + +class CollaborationStatus(Enum): + """协作确认状态枚举""" + NOT_REQUIRED = "not_required" # 不需要协作 + PENDING = "pending" # 待确认 + CONFIRMED = "confirmed" # 已确认 + REJECTED = "rejected" # 已拒绝 + + +class Schedule: + """ + 日程基类 + """ + + def __init__( + self, + schedule_id: str = None, + title: str = "", + description: str = "", + schedule_type: ScheduleType = ScheduleType.APPOINTMENT, + priority: SchedulePriority = SchedulePriority.MEDIUM, + start_time: str = None, + end_time: str = None, + created_at: str = None, + is_active: bool = True, + collaboration_status: CollaborationStatus = CollaborationStatus.NOT_REQUIRED, + is_queryable: bool = True, + metadata: Dict[str, Any] = None + ): + """ + 初始化日程 + + Args: + schedule_id: 日程唯一标识符 + title: 日程标题 + description: 日程描述 + schedule_type: 日程类型 + priority: 日程优先级 + start_time: 开始时间(ISO格式) + end_time: 结束时间(ISO格式) + created_at: 创建时间 + is_active: 是否激活 + collaboration_status: 协作确认状态 + is_queryable: 是否可被查询 + metadata: 附加元数据 + """ + self.schedule_id = schedule_id or str(uuid.uuid4()) + self.title = title + self.description = description + self.schedule_type = schedule_type + self.priority = priority + self.start_time = start_time + self.end_time = end_time + self.created_at = created_at or datetime.now().isoformat() + self.is_active = is_active + self.collaboration_status = collaboration_status + self.is_queryable = is_queryable + self.metadata = metadata or {} + + def to_dict(self) -> Dict[str, Any]: + """ + 将日程转换为字典 + + Returns: + 日程字典表示 + """ + return { + 'schedule_id': self.schedule_id, + 'title': self.title, + 'description': self.description, + 'schedule_type': self.schedule_type.value, + 'priority': self.priority.value, + 'start_time': self.start_time, + 'end_time': self.end_time, + 'created_at': self.created_at, + 'is_active': self.is_active, + 'collaboration_status': self.collaboration_status.value, + 'is_queryable': self.is_queryable, + 'metadata': self.metadata + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Schedule': + """ + 从字典创建日程 + + Args: + data: 日程字典数据 + + Returns: + 日程对象 + """ + return cls( + schedule_id=data.get('schedule_id'), + title=data.get('title', ''), + description=data.get('description', ''), + schedule_type=ScheduleType(data.get('schedule_type', 'appointment')), + priority=SchedulePriority(data.get('priority', 2)), + start_time=data.get('start_time'), + end_time=data.get('end_time'), + created_at=data.get('created_at'), + is_active=data.get('is_active', True), + collaboration_status=CollaborationStatus(data.get('collaboration_status', 'not_required')), + is_queryable=data.get('is_queryable', True), + metadata=data.get('metadata', {}) + ) + + +class RecurringSchedule(Schedule): + """ + 周期日程 + 支持每周重复的固定日程,如课程表 + """ + + def __init__( + self, + weekday: int = None, # 0-6 表示周一到周日 + recurrence_pattern: str = "", # 重复模式描述 + **kwargs + ): + """ + 初始化周期日程 + + Args: + weekday: 星期几(0-6,0=周一) + recurrence_pattern: 重复模式(如 "每周一、三、五") + **kwargs: 其他日程参数 + """ + # 设置默认优先级,如果kwargs中没有指定 + if 'priority' not in kwargs: + kwargs['priority'] = SchedulePriority.CRITICAL + super().__init__( + schedule_type=ScheduleType.RECURRING, + **kwargs + ) + self.metadata['weekday'] = weekday + self.metadata['recurrence_pattern'] = recurrence_pattern + + +class AppointmentSchedule(Schedule): + """ + 预约日程 + 用户主动提及或通过意图识别创建的日程 + """ + + def __init__( + self, + source: str = "user", # 来源:user(用户提及)或intent(意图识别) + confirmed_by_agent: bool = False, # 智能体是否已确认 + **kwargs + ): + """ + 初始化预约日程 + + Args: + source: 日程来源 + confirmed_by_agent: 智能体是否已确认 + **kwargs: 其他日程参数 + """ + super().__init__( + schedule_type=ScheduleType.APPOINTMENT, + **kwargs + ) + self.metadata['source'] = source + self.metadata['confirmed_by_agent'] = confirmed_by_agent + + +class TemporarySchedule(Schedule): + """ + 临时日程 + LLM在空闲时段自动生成的补充日程 + """ + + def __init__( + self, + generated_reason: str = "", # 生成原因 + involves_user: bool = False, # 是否涉及用户参与 + **kwargs + ): + """ + 初始化临时日程 + + Args: + generated_reason: 生成原因 + involves_user: 是否涉及用户参与 + **kwargs: 其他日程参数 + """ + # 如果涉及用户,需要协作确认且默认不可查询 + if involves_user: + kwargs['collaboration_status'] = CollaborationStatus.PENDING + kwargs['is_queryable'] = False + + # 设置默认优先级,如果kwargs中没有指定 + if 'priority' not in kwargs: + kwargs['priority'] = SchedulePriority.LOW + + super().__init__( + schedule_type=ScheduleType.TEMPORARY, + **kwargs + ) + self.metadata['generated_reason'] = generated_reason + self.metadata['involves_user'] = involves_user + + +class ScheduleManager: + """ + 日程管理器 + 负责日程的创建、查询、冲突检测和优先级管理 + """ + + def __init__(self, db_manager: DatabaseManager = None): + """ + 初始化日程管理器 + + Args: + db_manager: 数据库管理器实例 + """ + self.db = db_manager or DatabaseManager() + self._initialize_database() + + debug_logger.log_module('ScheduleManager', '日程管理器初始化完成') + + def _initialize_database(self): + """初始化数据库表""" + with self.db.get_connection() as conn: + # 创建日程表 + conn.execute(''' + CREATE TABLE IF NOT EXISTS schedules ( + schedule_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + schedule_type TEXT NOT NULL, + priority INTEGER NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + is_active INTEGER DEFAULT 1, + collaboration_status TEXT NOT NULL, + is_queryable INTEGER DEFAULT 1, + metadata TEXT + ) + ''') + + # 创建索引以提高查询性能 + conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_type ON schedules(schedule_type)') + conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_time ON schedules(start_time, end_time)') + conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_active ON schedules(is_active)') + conn.execute('CREATE INDEX IF NOT EXISTS idx_schedules_queryable ON schedules(is_queryable)') + + # 显式提交 + conn.commit() + + debug_logger.log_info('ScheduleManager', '数据库表初始化完成') + + def create_schedule( + self, + title: str, + description: str, + schedule_type: ScheduleType, + start_time: str, + end_time: str, + priority: SchedulePriority = None, + check_conflict: bool = True, + check_similarity: bool = True, + **kwargs + ) -> Tuple[bool, Optional[Schedule], str]: + """ + 创建新日程 + + Args: + title: 日程标题 + description: 日程描述 + schedule_type: 日程类型 + start_time: 开始时间(ISO格式) + end_time: 结束时间(ISO格式) + priority: 日程优先级(如果未指定,根据类型自动设置) + check_conflict: 是否检查冲突 + check_similarity: 是否检查相似日程 + **kwargs: 其他参数 + + Returns: + (是否成功, 日程对象或None, 消息) + """ + debug_logger.log_module('ScheduleManager', '创建新日程', { + 'title': title, + 'type': schedule_type.value, + 'start': start_time, + 'end': end_time + }) + + # 如果未指定优先级,根据类型设置默认优先级 + if priority is None: + if schedule_type == ScheduleType.RECURRING: + priority = SchedulePriority.CRITICAL + elif schedule_type == ScheduleType.APPOINTMENT: + priority = SchedulePriority.MEDIUM + else: # TEMPORARY + priority = SchedulePriority.LOW + + # 检查当天是否有相似日程 + if check_similarity: + from src.prefrontal.schedule.schedule_similarity_checker import ( + ScheduleSimilarityChecker, get_schedules_on_same_day + ) + + # 获取当天的所有日程 + same_day_schedules = get_schedules_on_same_day(self, start_time) + + if same_day_schedules: + # 使用LLM检查相似度 + checker = ScheduleSimilarityChecker() + new_schedule_dict = { + 'title': title, + 'description': description, + 'start_time': start_time, + 'end_time': end_time, + 'schedule_type': schedule_type.value, + 'metadata': kwargs.get('metadata', {}) + } + + has_similar, schedule_to_delete, result = checker.check_similar_schedules( + new_schedule_dict, + same_day_schedules + ) + + if has_similar: + if schedule_to_delete: + # LLM建议保留新日程,删除旧日程 + self.delete_schedule(schedule_to_delete) + message = f"检测到相似日程,已删除旧日程并创建新日程。原因:{result.get('reason', '新日程更详细')}" + debug_logger.log_info('ScheduleManager', message) + # 继续创建新日程 + else: + # LLM建议保留旧日程,不创建新日程 + message = f"检测到相似日程,保留已有日程。原因:{result.get('reason', '已有日程更详细')}" + debug_logger.log_info('ScheduleManager', message) + return False, None, message + + # 检查日程冲突 + if check_conflict: + has_conflict, conflict_schedule = self.check_conflict(start_time, end_time, priority) + if has_conflict: + message = f"日程冲突:时间段 {start_time} 到 {end_time} 已有优先级更高或相同的日程 '{conflict_schedule.title}'" + debug_logger.log_info('ScheduleManager', message) + return False, None, message + + # 创建对应类型的日程对象 + if schedule_type == ScheduleType.RECURRING: + # 过滤掉 TEMPORARY 专用参数 + filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ['involves_user', 'generated_reason']} + schedule = RecurringSchedule( + title=title, + description=description, + start_time=start_time, + end_time=end_time, + priority=priority, + **filtered_kwargs + ) + elif schedule_type == ScheduleType.APPOINTMENT: + # 过滤掉 TEMPORARY 专用参数 + filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ['involves_user', 'generated_reason']} + schedule = AppointmentSchedule( + title=title, + description=description, + start_time=start_time, + end_time=end_time, + priority=priority, + **filtered_kwargs + ) + else: # TEMPORARY + schedule = TemporarySchedule( + title=title, + description=description, + start_time=start_time, + end_time=end_time, + priority=priority, + **kwargs + ) + + # 保存到数据库 + try: + with self.db.get_connection() as conn: + conn.execute(''' + INSERT INTO schedules ( + schedule_id, title, description, schedule_type, + priority, start_time, end_time, created_at, + is_active, collaboration_status, is_queryable, metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + schedule.schedule_id, + schedule.title, + schedule.description, + schedule.schedule_type.value, + schedule.priority.value, + schedule.start_time, + schedule.end_time, + schedule.created_at, + 1 if schedule.is_active else 0, + schedule.collaboration_status.value, + 1 if schedule.is_queryable else 0, + json.dumps(schedule.metadata, ensure_ascii=False) + )) + + message = f"日程创建成功:{title}" + debug_logger.log_info('ScheduleManager', message, { + 'schedule_id': schedule.schedule_id + }) + + return True, schedule, message + + except Exception as e: + message = f"创建日程失败: {str(e)}" + debug_logger.log_error('ScheduleManager', message, e) + return False, None, message + + def add_schedule(self, **kwargs) -> Tuple[bool, Optional[Schedule], str]: + """ + 日程添加的便捷入口(与 create_schedule 等价,供神经系统 channel 使用)。 + + Returns: + (是否成功, 日程对象或None, 消息) + """ + return self.create_schedule(**kwargs) + + def check_conflict( + self, + start_time: str, + end_time: str, + new_priority: SchedulePriority, + exclude_schedule_id: str = None + ) -> Tuple[bool, Optional[Schedule]]: + """ + 检查时间段是否与现有日程冲突 + 高优先级日程可以覆盖低优先级日程 + + Args: + start_time: 开始时间(ISO格式) + end_time: 结束时间(ISO格式) + new_priority: 新日程的优先级 + exclude_schedule_id: 排除的日程ID(用于更新时) + + Returns: + (是否冲突, 冲突的日程对象或None) + """ + with self.db.get_connection() as conn: + # 查询时间段内的所有激活日程 + query = ''' + SELECT * FROM schedules + WHERE is_active = 1 + AND ( + (start_time < ? AND end_time > ?) + OR (start_time >= ? AND start_time < ?) + OR (end_time > ? AND end_time <= ?) + ) + ''' + params = [end_time, start_time, start_time, end_time, start_time, end_time] + + if exclude_schedule_id: + query += ' AND schedule_id != ?' + params.append(exclude_schedule_id) + + cursor = conn.execute(query, params) + + for row in cursor.fetchall(): + existing_priority = SchedulePriority(row[4]) # priority字段 + + # 如果现有日程的优先级 >= 新日程的优先级,则冲突 + if existing_priority.value >= new_priority.value: + data = { + 'schedule_id': row[0], + 'title': row[1], + 'description': row[2], + 'schedule_type': row[3], + 'priority': row[4], + 'start_time': row[5], + 'end_time': row[6], + 'created_at': row[7], + 'is_active': row[9] == 1, + 'collaboration_status': row[10], + 'is_queryable': row[11] == 1, + 'metadata': json.loads(row[12]) if row[12] else {} + } + conflicting_schedule = Schedule.from_dict(data) + return True, conflicting_schedule + + return False, None + + def get_schedule(self, schedule_id: str) -> Optional[Schedule]: + """ + 获取指定日程 + + Args: + schedule_id: 日程ID + + Returns: + 日程对象,不存在时返回None + """ + with self.db.get_connection() as conn: + cursor = conn.execute( + 'SELECT * FROM schedules WHERE schedule_id = ?', + (schedule_id,) + ) + row = cursor.fetchone() + + if row: + data = { + 'schedule_id': row[0], + 'title': row[1], + 'description': row[2], + 'schedule_type': row[3], + 'priority': row[4], + 'start_time': row[5], + 'end_time': row[6], + 'created_at': row[7], + 'is_active': row[9] == 1, + 'collaboration_status': row[10], + 'is_queryable': row[11] == 1, + 'metadata': json.loads(row[12]) if row[12] else {} + } + return Schedule.from_dict(data) + + return None + + def get_schedules_by_time_range( + self, + start_time: str, + end_time: str, + queryable_only: bool = True, + active_only: bool = True, + include_inactive: bool = False + ) -> List[Schedule]: + """ + 获取时间范围内的所有日程 + + Args: + start_time: 开始时间(ISO格式) + end_time: 结束时间(ISO格式) + queryable_only: 是否只返回可查询的日程 + active_only: 是否只返回激活的日程(优先级高于include_inactive) + include_inactive: 是否包含未激活的日程(当active_only=False时生效) + + Returns: + 日程列表 + """ + query = ''' + SELECT * FROM schedules + WHERE ( + (start_time < ? AND end_time > ?) + OR (start_time >= ? AND start_time < ?) + ) + ''' + params = [end_time, start_time, start_time, end_time] + + if queryable_only: + query += ' AND is_queryable = 1' + + if active_only: + query += ' AND is_active = 1' + elif not include_inactive: + query += ' AND is_active = 1' + + query += ' ORDER BY start_time ASC' + + with self.db.get_connection() as conn: + cursor = conn.execute(query, params) + + schedules = [] + for row in cursor.fetchall(): + data = { + 'schedule_id': row[0], + 'title': row[1], + 'description': row[2], + 'schedule_type': row[3], + 'priority': row[4], + 'start_time': row[5], + 'end_time': row[6], + 'created_at': row[7], + 'is_active': row[9] == 1, + 'collaboration_status': row[10], + 'is_queryable': row[11] == 1, + 'metadata': json.loads(row[12]) if row[12] else {} + } + schedules.append(Schedule.from_dict(data)) + + return schedules + + def list_schedules( + self, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + queryable_only: bool = True, + active_only: bool = True, + include_inactive: bool = False, + limit: int = 1000, + ) -> List[Schedule]: + """ + 列出日程(v4.0 神经系统入口)。 + + 如果提供 start_time / end_time,则委托给 get_schedules_by_time_range; + 否则返回按开始时间排序的日程列表。 + + Returns: + 日程列表 + """ + if start_time and end_time: + return self.get_schedules_by_time_range( + start_time, end_time, + queryable_only=queryable_only, + active_only=active_only, + include_inactive=include_inactive, + ) + + query = 'SELECT * FROM schedules WHERE 1=1' + params: List[Any] = [] + + if active_only: + query += ' AND is_active = 1' + elif not include_inactive: + query += ' AND is_active = 1' + + if queryable_only: + query += ' AND is_queryable = 1' + + query += ' ORDER BY start_time ASC LIMIT ?' + params.append(limit) + + with self.db.get_connection() as conn: + cursor = conn.execute(query, params) + + schedules = [] + for row in cursor.fetchall(): + data = { + 'schedule_id': row[0], + 'title': row[1], + 'description': row[2], + 'schedule_type': row[3], + 'priority': row[4], + 'start_time': row[5], + 'end_time': row[6], + 'created_at': row[7], + 'is_active': row[9] == 1, + 'collaboration_status': row[10], + 'is_queryable': row[11] == 1, + 'metadata': json.loads(row[12]) if row[12] else {} + } + schedules.append(Schedule.from_dict(data)) + + return schedules + + def get_pending_collaboration_schedules(self) -> List[Schedule]: + """ + 获取待确认的协作日程 + + Returns: + 待确认的日程列表 + """ + with self.db.get_connection() as conn: + cursor = conn.execute(''' + SELECT * FROM schedules + WHERE collaboration_status = ? + AND is_active = 1 + ORDER BY created_at ASC + ''', (CollaborationStatus.PENDING.value,)) + + schedules = [] + for row in cursor.fetchall(): + data = { + 'schedule_id': row[0], + 'title': row[1], + 'description': row[2], + 'schedule_type': row[3], + 'priority': row[4], + 'start_time': row[5], + 'end_time': row[6], + 'created_at': row[7], + 'is_active': row[9] == 1, + 'collaboration_status': row[10], + 'is_queryable': row[11] == 1, + 'metadata': json.loads(row[12]) if row[12] else {} + } + schedules.append(Schedule.from_dict(data)) + + return schedules + + def confirm_collaboration(self, schedule_id: str, confirmed: bool) -> bool: + """ + 确认或拒绝协作日程 + + Args: + schedule_id: 日程ID + confirmed: 是否确认 + + Returns: + 是否操作成功 + """ + try: + new_status = CollaborationStatus.CONFIRMED if confirmed else CollaborationStatus.REJECTED + + with self.db.get_connection() as conn: + # 更新协作状态 + conn.execute(''' + UPDATE schedules + SET collaboration_status = ?, + is_queryable = ?, + updated_at = ? + WHERE schedule_id = ? + ''', ( + new_status.value, + 1 if confirmed else 0, # 确认后可查询,拒绝后不可查询 + datetime.now().isoformat(), + schedule_id + )) + + debug_logger.log_info('ScheduleManager', '协作日程状态更新', { + 'schedule_id': schedule_id, + 'confirmed': confirmed + }) + + return True + + except Exception as e: + debug_logger.log_error('ScheduleManager', f'更新协作状态失败: {str(e)}', e) + return False + + def update_schedule( + self, + schedule_id: str, + **kwargs + ) -> bool: + """ + 更新日程信息 + + Args: + schedule_id: 日程ID + **kwargs: 要更新的字段 + + Returns: + 是否更新成功 + """ + # 白名单验证允许更新的列名 + allowed_columns = { + 'title', 'description', 'start_time', 'end_time', + 'priority', 'is_active', 'collaboration_status', 'is_queryable' + } + + try: + if not kwargs: + return False + + # 过滤只允许白名单中的列名 + safe_kwargs = {k: v for k, v in kwargs.items() if k in allowed_columns} + if not safe_kwargs: + debug_logger.log_info('ScheduleManager', '没有有效的字段可更新') + return False + + set_clause = ", ".join([f"{k} = ?" for k in safe_kwargs.keys()]) + set_clause += ", updated_at = ?" + values = list(safe_kwargs.values()) + [datetime.now().isoformat(), schedule_id] + + with self.db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(f''' + UPDATE schedules + SET {set_clause} + WHERE schedule_id = ? + ''', values) + return cursor.rowcount > 0 + except Exception as e: + debug_logger.log_error('ScheduleManager', f'更新日程时出错: {str(e)}', e) + return False + + def delete_schedule(self, schedule_id: str) -> bool: + """ + 删除日程(软删除,设置为非激活状态) + + Args: + schedule_id: 日程ID + + Returns: + 是否删除成功 + """ + try: + with self.db.get_connection() as conn: + conn.execute(''' + UPDATE schedules + SET is_active = 0, updated_at = ? + WHERE schedule_id = ? + ''', (datetime.now().isoformat(), schedule_id)) + + debug_logger.log_info('ScheduleManager', '日程删除成功', { + 'schedule_id': schedule_id + }) + + return True + + except Exception as e: + debug_logger.log_error('ScheduleManager', f'删除日程失败: {str(e)}', e) + return False + + def get_free_time_slots( + self, + date: str, + slot_duration_minutes: int = 60 + ) -> List[Tuple[str, str]]: + """ + 获取指定日期的空闲时间段 + + Args: + date: 日期(ISO格式,如 "2024-01-15") + slot_duration_minutes: 最小时间段长度(分钟) + + Returns: + 空闲时间段列表 [(start_time, end_time), ...] + """ + # 获取当天的所有日程 + start_of_day = f"{date}T00:00:00" + end_of_day = f"{date}T23:59:59" + + schedules = self.get_schedules_by_time_range(start_of_day, end_of_day) + + # 按开始时间排序 + schedules.sort(key=lambda s: s.start_time) + + # 计算空闲时间段 + free_slots = [] + current_time = datetime.fromisoformat(start_of_day) + end_time = datetime.fromisoformat(end_of_day) + + for schedule in schedules: + schedule_start = datetime.fromisoformat(schedule.start_time) + schedule_end = datetime.fromisoformat(schedule.end_time) + + # 如果当前时间到日程开始时间之间有足够的空隙 + if (schedule_start - current_time).total_seconds() / 60 >= slot_duration_minutes: + free_slots.append(( + current_time.isoformat(), + schedule_start.isoformat() + )) + + # 更新当前时间到日程结束时间 + if schedule_end > current_time: + current_time = schedule_end + + # 检查最后一个日程到当天结束是否有空闲时间 + if (end_time - current_time).total_seconds() / 60 >= slot_duration_minutes: + free_slots.append(( + current_time.isoformat(), + end_time.isoformat() + )) + + return free_slots + + def get_statistics(self) -> Dict[str, Any]: + """ + 获取日程统计信息 + + Returns: + 统计信息字典 + """ + with self.db.get_connection() as conn: + cursor = conn.execute(''' + SELECT + COUNT(*) as total, + SUM(CASE WHEN schedule_type = ? THEN 1 ELSE 0 END) as recurring, + SUM(CASE WHEN schedule_type = ? THEN 1 ELSE 0 END) as appointments, + SUM(CASE WHEN schedule_type = ? THEN 1 ELSE 0 END) as temporary, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN collaboration_status = ? THEN 1 ELSE 0 END) as pending_collaboration + FROM schedules + ''', ( + ScheduleType.RECURRING.value, + ScheduleType.APPOINTMENT.value, + ScheduleType.TEMPORARY.value, + CollaborationStatus.PENDING.value + )) + + row = cursor.fetchone() + return { + 'total_schedules': row[0] or 0, + 'recurring': row[1] or 0, + 'appointments': row[2] or 0, + 'temporary': row[3] or 0, + 'active': row[4] or 0, + 'pending_collaboration': row[5] or 0 + } diff --git a/src/prefrontal/schedule/schedule_similarity_checker.py b/src/prefrontal/schedule/schedule_similarity_checker.py new file mode 100644 index 0000000..b1ff31e --- /dev/null +++ b/src/prefrontal/schedule/schedule_similarity_checker.py @@ -0,0 +1,276 @@ +""" +日程相似度检查工具 +用于检查同一天内的相似日程,并使用LLM判断应保留哪一个 +""" + +import os +import json +import time +from typing import List, Dict, Any, Optional, Tuple +from datetime import datetime + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + # dotenv 不可用时跳过 + pass + +import requests +from src.cortex.providers import registry as llm_providers # v3.1.0: 统一供应商解析 +from src.tools.debug_logger import get_debug_logger + +# 获取debug日志记录器 +debug_logger = get_debug_logger() + + +class ScheduleSimilarityChecker: + """ + 日程相似度检查工具 + 使用LLM判断两个日程是否相似,以及应该保留哪一个 + """ + + def __init__(self): + """初始化日程相似度检查工具""" + # v3.1.0: 用 llm_providers 解析(保留旧 SILICONFLOW_API_KEY 兼容) + self.api_key = llm_providers.resolve_api_key() + try: + self.api_url = llm_providers.resolve_base_url(llm_providers.resolve_provider()) + except Exception: + self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') + self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') + self.temperature = 0.3 # 使用较低的温度以获得更确定的输出 + self.max_tokens = 800 + + debug_logger.log_module('ScheduleSimilarityChecker', '日程相似度检查工具初始化完成') + + def check_similar_schedules( + self, + new_schedule: Dict[str, Any], + existing_schedules: List[Dict[str, Any]] + ) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]: + """ + 检查新日程是否与当天已有日程相似 + + Args: + new_schedule: 新日程信息字典 + existing_schedules: 当天已有的日程列表 + + Returns: + (是否有相似日程, 应保留的日程ID, LLM判断结果) + """ + if not existing_schedules: + debug_logger.log_info('ScheduleSimilarityChecker', '当天没有其他日程,无需检查相似度') + return False, None, None + + debug_logger.log_module('ScheduleSimilarityChecker', '开始检查日程相似度', { + 'new_schedule': new_schedule.get('title'), + 'existing_count': len(existing_schedules) + }) + + # 逐一检查每个已有日程 + for existing in existing_schedules: + result = self._compare_two_schedules(new_schedule, existing) + + if result and result.get('is_similar'): + # 发现相似日程 + schedule_to_keep = result.get('keep_schedule') + + if schedule_to_keep == 'new': + # 保留新日程,删除旧日程 + debug_logger.log_info('ScheduleSimilarityChecker', + f"发现相似日程,LLM建议保留新日程「{new_schedule.get('title')}」") + return True, existing['schedule_id'], result + elif schedule_to_keep == 'existing': + # 保留旧日程,不创建新日程 + debug_logger.log_info('ScheduleSimilarityChecker', + f"发现相似日程,LLM建议保留已有日程「{existing.get('title')}」") + return True, None, result + + # 没有发现相似日程 + debug_logger.log_info('ScheduleSimilarityChecker', '未发现相似日程') + return False, None, None + + def _compare_two_schedules( + self, + schedule1: Dict[str, Any], + schedule2: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + """ + 使用LLM比较两个日程是否相似 + + Args: + schedule1: 第一个日程(新日程) + schedule2: 第二个日程(已有日程) + + Returns: + LLM判断结果字典,包含is_similar和keep_schedule字段 + """ + system_prompt = """你是一个日程管理专家。你需要判断两个日程是否相似,以及应该保留哪一个。 + +判断标准: +1. 如果两个日程的主题、活动内容基本一致,视为相似日程 +2. 即使时间、地点略有差异,但如果核心活动相同,也视为相似 +3. 如果一个日程明确说明是与另一个日程不同的活动,则不相似 +4. 如果两个日程的标题或描述明确表明是不同的事情,则不相似 + +如果两个日程相似,应该保留信息更详细、更完整的那一个: +- 比较标题和描述的详细程度 +- 比较时间信息的明确程度 +- 比较其他元数据的丰富程度 + +请返回JSON格式的判断结果: +{ + "is_similar": true/false, // 是否相似 + "reason": "判断理由", // 详细说明判断的理由 + "keep_schedule": "new"/"existing"/"none" // 应保留哪个(new=新日程, existing=已有日程, none=不相似无需选择) +}""" + + # 格式化日程信息 + schedule1_info = self._format_schedule_for_llm(schedule1, "新日程") + schedule2_info = self._format_schedule_for_llm(schedule2, "已有日程") + + user_prompt = f"""请判断以下两个日程是否相似,如果相似,应该保留哪一个: + +{schedule1_info} + +{schedule2_info} + +请分析这两个日程,并返回JSON格式的判断结果。""" + + try: + # 调用LLM进行相似度判断 + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'model': self.model_name, + 'messages': messages, + 'temperature': self.temperature, + 'max_tokens': self.max_tokens, + 'stream': False + } + + debug_logger.log_request('ScheduleSimilarityChecker', self.api_url, payload, headers) + + start_time = time.time() + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=30 + ) + elapsed_time = time.time() - start_time + + debug_logger.log_response('ScheduleSimilarityChecker', response.status_code, response.text, elapsed_time) + + if response.status_code == 200: + result = response.json() + content = result['choices'][0]['message']['content'].strip() + + # 尝试解析JSON结果 + try: + # 提取JSON部分(可能被包裹在markdown代码块中) + import re + json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL) + if json_match: + content = json_match.group(1) + + parsed_result = json.loads(content) + + # 验证结果格式 + if 'is_similar' in parsed_result and 'keep_schedule' in parsed_result: + debug_logger.log_info('ScheduleSimilarityChecker', + f"LLM判断结果:相似={parsed_result['is_similar']}, 保留={parsed_result.get('keep_schedule')}") + return parsed_result + else: + debug_logger.log_error('ScheduleSimilarityChecker', 'LLM返回结果格式不正确') + return None + + except json.JSONDecodeError as e: + debug_logger.log_error('ScheduleSimilarityChecker', f'解析LLM响应JSON失败: {str(e)}') + debug_logger.log_info('ScheduleSimilarityChecker', f'原始响应: {content}') + return None + else: + debug_logger.log_error('ScheduleSimilarityChecker', f'LLM API请求失败: {response.status_code}') + return None + + except requests.exceptions.Timeout: + debug_logger.log_error('ScheduleSimilarityChecker', 'LLM API请求超时') + return None + except Exception as e: + debug_logger.log_error('ScheduleSimilarityChecker', f'相似度检查失败: {str(e)}', e) + return None + + def _format_schedule_for_llm(self, schedule: Dict[str, Any], label: str) -> str: + """ + 格式化日程信息供LLM分析 + + Args: + schedule: 日程信息字典 + label: 标签(如"新日程"或"已有日程") + + Returns: + 格式化的日程信息字符串 + """ + lines = [f"【{label}】"] + lines.append(f"标题:{schedule.get('title', '无')}") + lines.append(f"描述:{schedule.get('description', '无')}") + lines.append(f"开始时间:{schedule.get('start_time', '无')}") + lines.append(f"结束时间:{schedule.get('end_time', '无')}") + lines.append(f"类型:{schedule.get('schedule_type', '无')}") + + # 添加元数据信息(如果有) + metadata = schedule.get('metadata', {}) + if metadata: + lines.append(f"附加信息:{json.dumps(metadata, ensure_ascii=False)}") + + return '\n'.join(lines) + + +def get_schedules_on_same_day( + schedule_manager, + target_date: str, + exclude_schedule_id: str = None +) -> List[Dict[str, Any]]: + """ + 获取指定日期当天的所有日程 + + Args: + schedule_manager: 日程管理器实例 + target_date: 目标日期(ISO格式,如"2024-01-15T10:00:00") + exclude_schedule_id: 要排除的日程ID(可选) + + Returns: + 当天的日程列表 + """ + try: + # 解析目标日期 + dt = datetime.fromisoformat(target_date.replace('Z', '+00:00')) + day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = dt.replace(hour=23, minute=59, second=59, microsecond=999999) + + # 查询当天的日程 + schedules = schedule_manager.get_schedules_by_time_range( + start_time=day_start.isoformat(), + end_time=day_end.isoformat(), + include_inactive=False + ) + + # 排除指定的日程ID + if exclude_schedule_id: + schedules = [s for s in schedules if s.schedule_id != exclude_schedule_id] + + # 转换为字典列表 + return [s.to_dict() for s in schedules] + + except Exception as e: + debug_logger.log_error('ScheduleSimilarityChecker', f'获取当天日程失败: {str(e)}', e) + return [] diff --git a/src/prefrontal/workflow_module.py b/src/prefrontal/workflow_module.py new file mode 100644 index 0000000..3122393 --- /dev/null +++ b/src/prefrontal/workflow_module.py @@ -0,0 +1,141 @@ +""" +WorkflowModule - 跨模块工作流的神经系统接入层。 + +将 src.core.cross_module_workflow.ChatWorkflow 包装为 BaseModule, +通过 CentralRouter 对外暴露 chat_workflow 通道,支持同步与流式两种调用方式。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator + +from src.core.cross_module_workflow import ChatWorkflow +from src.nervous_system.base_module import BaseModule +from src.nervous_system.router.packet import Packet, PacketType + +if TYPE_CHECKING: + from src.nervous_system.router.central_router import CentralRouter + + +class WorkflowModule(BaseModule): + """ + 工作流模块。 + + module_id: prefrontal.workflow + 负责编排多模块协作,提供统一的 chat_workflow 入口。 + """ + + module_id = "prefrontal.workflow" + module_type = "prefrontal" + + def __init__(self, router: "CentralRouter") -> None: + super().__init__(router) + self._workflow: ChatWorkflow | None = None + + async def initialize(self) -> None: + self._workflow = ChatWorkflow(self.router) + await super().initialize() + + async def shutdown(self) -> None: + self._workflow = None + await super().shutdown() + + async def handle(self, packet: Packet) -> Packet: + """ + 同步处理工作流请求。 + + Channel: + - chat_workflow: 收集完整流式结果后统一返回 + """ + channel = packet.channel + + if channel == "chat_workflow": + return await self._handle_chat_workflow_sync(packet) + + return packet.response({ + "status": "unknown_channel", + "channel": channel, + }) + + async def handle_stream(self, packet: Packet) -> AsyncIterator[Packet]: + """ + 流式处理工作流请求。 + + Channel: + - chat_workflow: 逐 token 返回 assistant chunk,最后 yield done + """ + channel = packet.channel + + if channel == "chat_workflow": + user_input = packet.payload.get("user_input", "") + user_id = packet.payload.get("user_id", "default") + session_id = packet.payload.get("session_id") + context = packet.payload.get("context", {}) + + if not user_input: + yield Packet.error(packet, "user_input required", code="INVALID_REQUEST") + return + + if self._workflow is None: + yield Packet.error(packet, "Workflow not initialized", code="NOT_INITIALIZED") + return + + async for event in self._workflow.run_stream( + user_input, user_id=user_id, session_id=session_id, context=context + ): + event_type = event.get("event") + if event_type == "chunk": + content = event.get("content", "") + if content: + yield packet.stream_chunk({"role": "assistant", "content": content}) + elif event_type == "done": + yield packet.stream_done() + elif event_type == "workflow_result": + # 将工作流结果附加到最后一个 chunk,再补一次 done + yield packet.stream_chunk({ + "role": "assistant", + "content": "", + "workflow_result": event, + }) + yield packet.stream_done() + return + + # 其他 channel 委托给 handle 并模拟流式 + response = await self.handle(packet) + if response.is_error(): + yield response + return + yield packet.stream_chunk(response.payload) + yield packet.stream_done() + + async def _handle_chat_workflow_sync(self, packet: Packet) -> Packet: + user_input = packet.payload.get("user_input", "") + user_id = packet.payload.get("user_id", "default") + session_id = packet.payload.get("session_id") + context = packet.payload.get("context", {}) + + if not user_input: + return Packet.error(packet, "user_input required", code="INVALID_REQUEST") + + if self._workflow is None: + return Packet.error(packet, "Workflow not initialized", code="NOT_INITIALIZED") + + chunks: list[str] = [] + workflow_result: dict = {} + async for event in self._workflow.run_stream( + user_input, user_id=user_id, session_id=session_id, context=context + ): + if event.get("event") == "chunk": + chunks.append(event.get("content", "")) + elif event.get("event") == "workflow_result": + workflow_result = event + + full_content = "".join(chunks) + return packet.response({ + "role": "assistant", + "content": full_content, + "workflow": workflow_result, + }) + + +__all__ = ["WorkflowModule"] \ No newline at end of file diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 8296633..305b9c2 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -9,7 +9,6 @@ from src.tools.expression_style import ExpressionStyleManager from src.tools.interrupt_question_tool import InterruptQuestionTool from src.tools.schedule_intent_tool import ScheduleIntentTool - from src.tools.tooltip_utils import ToolTip, create_treeview_tooltip from src.tools.settings_migration import SettingsMigration """ @@ -19,6 +18,5 @@ 'expression_style', 'interrupt_question_tool', 'schedule_intent_tool', - 'tooltip_utils', 'settings_migration', ] diff --git a/src/tools/agent_vision.py b/src/tools/agent_vision.py index bc42931..a6d8258 100644 --- a/src/tools/agent_vision.py +++ b/src/tools/agent_vision.py @@ -1,951 +1,10 @@ """ -智能体视觉工具模块 -通过读取数据库中的环境描述来模拟智能体的伪视觉功能 -当用户询问周围环境时,智能体自动决定是否使用此工具 -""" - -import os -import re -import time -from typing import Dict, Any, List, Optional -from datetime import datetime -from dotenv import load_dotenv -import requests -from src.core.database_manager import DatabaseManager -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class AgentVisionTool: - """ - 智能体视觉工具类 - 负责检测用户查询是否涉及环境,并从数据库读取相应的环境描述 - """ - - def __init__(self, db_manager: DatabaseManager = None): - """ - 初始化视觉工具 - - Args: - db_manager: 数据库管理器实例 - """ - self.db = db_manager or DatabaseManager() - - # API配置(用于智能判断是否需要使用视觉工具) - self.api_key = os.getenv('SILICONFLOW_API_KEY') - self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') - - # 环境相关关键词(用于快速判断) - # 包含常见的位置查询关键词,如"在哪"用于检测"你在哪?"等查询 - self.environment_keywords = [ - '周围', '周边', '环境', '这里', '附近', '哪里', '什么地方', '在哪', - '看到', '看见', '观察', '眼前', '面前', '旁边', '身边', - '房间', '屋子', '地方', '场景', '景色', '风景', - '有什么', '有哪些', '能看到', '可以看到' - ] - - debug_logger.log_module('AgentVisionTool', '视觉工具初始化完成', { - 'keywords_count': len(self.environment_keywords) - }) - - def should_use_vision_llm(self, user_query: str) -> bool: - """ - 使用LLM智能判断是否需要使用视觉工具 - - 注意: - - 输入清理可防止基本的注入攻击,但不是完全安全 - - 响应解析目前仅支持中文,需要国际化时需修改 - - 同步HTTP请求可能造成阻塞,高频调用时建议使用异步方式 - - Args: - user_query: 用户查询 - - Returns: - 是否需要使用视觉 - """ - debug_logger.log_module('AgentVisionTool', '使用LLM判断是否需要视觉', { - 'query': user_query - }) - - try: - # 对用户输入进行清理和验证,防止prompt注入攻击 - # 1. 移除可疑模式(系统提示、角色扮演等) - suspicious_patterns = [ - 'system:', 'assistant:', 'user:', - 'ignore previous', 'ignore all previous', - 'new instructions', 'forget everything', - '###', '---', '```' - ] - - # 检查是否包含可疑模式 - query_lower = user_query.lower() - for pattern in suspicious_patterns: - if pattern in query_lower: - debug_logger.log_error('AgentVisionTool', f"检测到可疑输入模式: {pattern}", None) - # 对于可疑输入,使用关键词匹配而非LLM - return self._check_vision_keywords(user_query) - - # 2. 基本清理 - cleaned_query = user_query.replace('"', '\\"').replace('\n', ' ').strip() - - # 3. 限制查询长度 - if len(cleaned_query) > 500: - cleaned_query = cleaned_query[:500] - - # 构建判断提示词 - judge_prompt = f"""请判断以下用户问题是否需要智能体观察周围环境才能回答。 - -用户问题:"{cleaned_query}" - -需要观察环境的情况包括但不限于: -1. 询问智能体的位置或所在地(如:你在哪?你在哪里?) -2. 询问周围有什么、看到什么 -3. 询问环境、房间、场景相关的问题 -4. 询问附近、旁边、面前的事物 -5. 需要了解当前环境状态才能回答的问题 - -请只回答"是"或"否",不要有其他内容。""" - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - # 从环境变量读取配置或使用默认值 - try: - llm_temperature = float(os.getenv('VISION_LLM_TEMPERATURE', '0.3')) - except ValueError: - llm_temperature = 0.3 - debug_logger.log_info('AgentVisionTool', '无效的VISION_LLM_TEMPERATURE,使用默认值0.3') - - try: - llm_max_tokens = int(os.getenv('VISION_LLM_MAX_TOKENS', '10')) - except ValueError: - llm_max_tokens = 10 - debug_logger.log_info('AgentVisionTool', '无效的VISION_LLM_MAX_TOKENS,使用默认值10') - - try: - llm_timeout = int(os.getenv('VISION_LLM_TIMEOUT', '10')) - except ValueError: - llm_timeout = 10 - debug_logger.log_info('AgentVisionTool', '无效的VISION_LLM_TIMEOUT,使用默认值10') - - payload = { - 'model': self.model_name, - 'messages': [ - {'role': 'system', 'content': '你是一个智能判断助手,负责判断用户问题是否需要观察环境。'}, - {'role': 'user', 'content': judge_prompt} - ], - 'temperature': llm_temperature, - 'max_tokens': llm_max_tokens - } - - debug_logger.log_info('AgentVisionTool', '发送LLM判断请求') - - response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=llm_timeout - ) - - response.raise_for_status() - result = response.json() - - if 'choices' in result and len(result['choices']) > 0: - answer = result['choices'][0]['message']['content'].strip() - # 更精确的判断:完全匹配"是"或以"是"开头 - # 注意:此判断逻辑仅适用于中文,国际化时需要修改 - needs_vision = (answer == '是' or answer.startswith('是,') or answer.startswith('是。')) - - debug_logger.log_info('AgentVisionTool', 'LLM判断完成', { - 'query': user_query, - 'answer': answer, - 'needs_vision': needs_vision - }) - - return needs_vision - else: - debug_logger.log_info('AgentVisionTool', 'LLM响应无效,回退到关键词匹配') - return self._fallback_to_keyword(user_query) - - except Exception as e: - debug_logger.log_error('AgentVisionTool', f'LLM判断失败: {str(e)}', e) - # 如果LLM调用失败,回退到关键词匹配 - return self._fallback_to_keyword(user_query) - - def _fallback_to_keyword(self, user_query: str) -> bool: - """ - 回退到关键词匹配(内部方法) - - Args: - user_query: 用户查询 - - Returns: - 是否需要使用视觉 - """ - debug_logger.log_info('AgentVisionTool', '使用关键词匹配作为后备方案') - return self.should_use_vision_keyword(user_query) - - def should_use_vision_keyword(self, user_query: str) -> bool: - """ - 判断是否需要使用视觉工具(基于关键词快速判断) - 这是备用方法,当LLM不可用时使用 - - Args: - user_query: 用户查询 - - Returns: - 是否需要使用视觉 - """ - debug_logger.log_module('AgentVisionTool', '使用关键词判断是否需要视觉', { - 'query_length': len(user_query) - }) - - # 快速关键词匹配 - query_lower = user_query.lower() - for keyword in self.environment_keywords: - if keyword in query_lower: - debug_logger.log_info('AgentVisionTool', '检测到环境相关关键词', { - 'keyword': keyword, - 'query': user_query - }) - return True - - debug_logger.log_info('AgentVisionTool', '未检测到环境相关关键词', { - 'query': user_query - }) - return False - - def should_use_vision(self, user_query: str, use_llm: bool = True) -> bool: - """ - 判断是否需要使用视觉工具(智能判断) - 优先使用LLM进行智能判断,如果LLM不可用或禁用则使用关键词匹配 - - Args: - user_query: 用户查询 - use_llm: 是否使用LLM进行智能判断,默认为True - - Returns: - 是否需要使用视觉 - """ - if use_llm: - return self.should_use_vision_llm(user_query) - else: - return self.should_use_vision_keyword(user_query) - - def get_vision_context(self, user_query: str) -> Optional[Dict[str, Any]]: - """ - 获取视觉上下文(环境描述) - - Args: - user_query: 用户查询 - - Returns: - 视觉上下文字典,包含环境描述和物体信息 - """ - debug_logger.log_module('AgentVisionTool', '开始获取视觉上下文', { - 'query': user_query - }) - - # 检查是否需要使用视觉 - if not self.should_use_vision(user_query): - debug_logger.log_info('AgentVisionTool', '不需要使用视觉工具', { - 'reason': '未检测到环境相关查询' - }) - return None - - # 获取当前激活的环境 - environment = self.db.get_active_environment() - if not environment: - debug_logger.log_info('AgentVisionTool', '没有激活的环境', { - 'suggestion': '请先创建并激活一个环境' - }) - return None - - debug_logger.log_info('AgentVisionTool', '找到激活的环境', { - 'env_name': environment['name'], - 'env_uuid': (environment['uuid'][:8] + '...') if len(environment['uuid']) > 8 else environment['uuid'] - }) - - # 获取环境中的物体 - objects = self.db.get_environment_objects(environment['uuid'], visible_only=True) - - debug_logger.log_info('AgentVisionTool', '获取环境物体', { - 'objects_count': len(objects) - }) - - # 构建视觉上下文 - vision_context = { - 'environment': environment, - 'objects': objects, - 'object_count': len(objects), - 'query': user_query, - 'timestamp': datetime.now().isoformat() - } - - # 记录视觉工具使用 - objects_viewed = ', '.join([obj['name'] for obj in objects]) - context_text = self._format_vision_context(vision_context) - - self.db.log_vision_tool_usage( - query=user_query, - environment_uuid=environment['uuid'], - objects_viewed=objects_viewed, - context_provided=context_text[:500], # 只保存前500字符 - triggered_by='auto' - ) - - debug_logger.log_info('AgentVisionTool', '视觉上下文获取完成', { - 'environment': environment['name'], - 'objects_count': len(objects), - 'context_length': len(context_text) - }) - - return vision_context - - def _format_vision_context(self, vision_context: Dict[str, Any]) -> str: - """ - 格式化视觉上下文为文本描述 - - Args: - vision_context: 视觉上下文字典 - - Returns: - 格式化的文本描述 - """ - if not vision_context: - return "" - - # 检查是否为域级别的上下文 - if vision_context.get('type') == 'domain': - domain = vision_context['domain'] - current_env = vision_context.get('current_environment') - - context_parts = ["【智能体视觉感知 - 域级别】"] - context_parts.append(f"\n所在域: {domain['name']}") - if domain.get('description'): - context_parts.append(f"\n域描述: {domain['description']}") - if current_env: - context_parts.append(f"\n当前具体位置: {current_env['name']}") - - # 获取域中的环境列表 - environments = self.db.get_domain_environments(domain['uuid']) - if environments: - env_names = [env['name'] for env in environments] - context_parts.append(f"\n域包含的区域: {', '.join(env_names)}") - - context_parts.append("\n\n💡 请基于域级别的位置信息回答用户的问题。如需更详细信息,可询问用户具体位置。") - - return '\n'.join(context_parts) - - # 原有的环境级别上下文格式化 - environment = vision_context.get('environment') - objects = vision_context.get('objects', []) - - if not environment: - return "" - - context_parts = ["【智能体视觉感知】"] - context_parts.append(f"\n环境名称: {environment['name']}") - context_parts.append(f"\n整体描述: {environment['overall_description']}") - - # 添加感官细节 - if environment.get('atmosphere'): - context_parts.append(f"氛围: {environment['atmosphere']}") - if environment.get('lighting'): - context_parts.append(f"光照: {environment['lighting']}") - if environment.get('sounds'): - context_parts.append(f"声音: {environment['sounds']}") - if environment.get('smells'): - context_parts.append(f"气味: {environment['smells']}") - - # 添加物体信息 - if objects: - context_parts.append(f"\n可见物体(共{len(objects)}个):") - for obj in objects: - obj_desc = f"\n 🔹 {obj['name']}" - obj_desc += f"\n 描述: {obj['description']}" - if obj.get('position'): - obj_desc += f"\n 位置: {obj['position']}" - if obj.get('properties'): - obj_desc += f"\n 属性: {obj['properties']}" - if obj.get('interaction_hints'): - obj_desc += f"\n 交互: {obj['interaction_hints']}" - context_parts.append(obj_desc) - else: - context_parts.append("\n当前环境中没有可见物体。") - - context_parts.append("\n\n💡 请基于以上视觉感知信息回答用户的问题。") - - return '\n'.join(context_parts) - - def format_vision_prompt(self, vision_context: Dict[str, Any]) -> str: - """ - 将视觉上下文格式化为系统提示词 - - Args: - vision_context: 视觉上下文字典 - - Returns: - 格式化的提示词 - """ - return self._format_vision_context(vision_context) - - def get_vision_summary(self, vision_context: Dict[str, Any]) -> str: - """ - 获取视觉上下文的简要摘要(用于显示) - - Args: - vision_context: 视觉上下文字典 - - Returns: - 简要摘要 - """ - if not vision_context: - return "未获取到视觉信息" - - # 检查是否为域级别的上下文 - if vision_context.get('type') == 'domain': - domain = vision_context['domain'] - current_env = vision_context.get('current_environment') - summary = f"👁️ [视觉感知-域] 域: {domain['name']}" - if current_env: - summary += f" | 位置: {current_env['name']}" - return summary - - # 原有的环境级别摘要 - env = vision_context.get('environment') - obj_count = vision_context.get('object_count', 0) - - if not env: - return "未获取到视觉信息" - - summary = f"👁️ [视觉感知] 环境: {env['name']}" - if obj_count > 0: - summary += f" | 可见物体: {obj_count}个" - - return summary - - def create_default_environment(self) -> str: - """ - 创建默认环境(用于初始化或测试) - - Returns: - 环境UUID - """ - debug_logger.log_module('AgentVisionTool', '创建默认环境') - - env_uuid = self.db.create_environment( - name="小可的房间", - overall_description="这是一个温馨舒适的学生卧室,约15平方米。墙壁刷成淡粉色,地板铺着浅色木地板。房间整洁有序,充满学习的氛围。", - atmosphere="温馨、宁静、充满书香气息", - lighting="柔和的自然光从窗户洒入,桌上的台灯散发着暖黄色的光", - sounds="偶尔能听到窗外鸟鸣和微风拂过树叶的沙沙声", - smells="空气中弥漫着淡淡的书香和薰衣草香薰的味道" - ) - - # 设置为激活环境 - self.db.set_active_environment(env_uuid) - - # 添加一些默认物体 - default_objects = [ - { - "name": "书桌", - "description": "一张简约的白色书桌,约120cm宽,上面摆放着各种学习用品", - "position": "靠窗的位置", - "properties": "材质: 实木, 颜色: 白色, 状态: 整洁", - "interaction_hints": "可以在这里学习、写作业、看书", - "priority": 90 - }, - { - "name": "书架", - "description": "一个四层的白色书架,摆满了各类书籍,尤其是历史类书籍特别多", - "position": "书桌右侧的墙边", - "properties": "材质: 木质, 层数: 4层, 书籍数量: 约100本", - "interaction_hints": "可以挑选书籍阅读,历史类书籍最多", - "priority": 85 - }, - { - "name": "床", - "description": "一张单人床,铺着淡粉色的床单和被套,上面放着几个可爱的抱枕", - "position": "房间左侧靠墙", - "properties": "大小: 单人床(1.2m), 颜色: 粉色系, 状态: 整理好的", - "interaction_hints": "可以休息、睡觉", - "priority": 80 - }, - { - "name": "台灯", - "description": "一盏护眼台灯,设计简洁,可以调节亮度和色温", - "position": "书桌右上角", - "properties": "品牌: 明基, 类型: LED护眼灯, 状态: 关闭", - "interaction_hints": "可以开启用于学习照明", - "priority": 70 - }, - { - "name": "笔记本电脑", - "description": "一台轻薄的笔记本电脑,银色外壳,通常用于查资料和学习", - "position": "书桌中央", - "properties": "品牌: 华为, 颜色: 银色, 状态: 合上的", - "interaction_hints": "可以打开用于学习、查资料", - "priority": 85 - }, - { - "name": "窗户", - "description": "一扇宽大的窗户,透过窗户可以看到外面的树木和天空", - "position": "书桌后方", - "properties": "类型: 推拉窗, 尺寸: 大型, 状态: 半开", - "interaction_hints": "可以打开通风,欣赏外面的景色", - "priority": 75 - }, - { - "name": "挂钟", - "description": "一个圆形的挂钟,简约的设计,静音机芯", - "position": "门的上方墙壁", - "properties": "类型: 石英钟, 颜色: 白色, 特点: 静音", - "interaction_hints": "可以查看时间", - "priority": 60 - } - ] - - for obj_data in default_objects: - self.db.add_environment_object( - environment_uuid=env_uuid, - name=obj_data['name'], - description=obj_data['description'], - position=obj_data.get('position', ''), - properties=obj_data.get('properties', ''), - interaction_hints=obj_data.get('interaction_hints', ''), - priority=obj_data.get('priority', 50) - ) - - debug_logger.log_info('AgentVisionTool', '默认环境创建完成', { - 'env_uuid': (env_uuid[:8] + '...') if len(env_uuid) > 8 else env_uuid, - 'objects_count': len(default_objects) - }) - - print(f"✓ 默认环境创建完成: 小可的房间({len(default_objects)}个物体)") - - return env_uuid - - def detect_environment_switch_intent(self, user_query: str) -> Optional[Dict[str, Any]]: - """ - 检测用户是否有切换环境的意图 - - Args: - user_query: 用户查询 - - Returns: - 如果检测到切换意图,返回包含目标环境等信息的字典,否则返回None - """ - debug_logger.log_module('AgentVisionTool', '检测环境切换意图', { - 'query': user_query - }) - - # 环境切换关键词 - switch_keywords = [ - '去', '走', '移动', '前往', '过去', '进入', '离开', '出去', - '回到', '返回', '切换', '换到', '转移' - ] - - query_lower = user_query.lower() - has_switch_keyword = any(keyword in query_lower for keyword in switch_keywords) - - if not has_switch_keyword: - debug_logger.log_info('AgentVisionTool', '未检测到切换关键词') - return None - - # 获取当前环境 - current_env = self.db.get_active_environment() - if not current_env: - debug_logger.log_info('AgentVisionTool', '没有当前激活的环境') - return None +v3.1.0 兼容层:src.tools.agent_vision - # 获取所有连通的环境 - connected_envs = self.db.get_connected_environments(current_env['uuid']) - if not connected_envs: - debug_logger.log_info('AgentVisionTool', '当前环境没有连通的环境') - return None - - # 尝试匹配环境名称 - matched_env = None - for env in connected_envs: - if env['name'] in user_query: - matched_env = env - break - - if matched_env: - debug_logger.log_info('AgentVisionTool', '检测到环境切换意图', { - 'from': current_env['name'], - 'to': matched_env['name'] - }) - return { - 'intent': 'switch_environment', - 'from_env': current_env, - 'to_env': matched_env, - 'can_switch': True - } - - debug_logger.log_info('AgentVisionTool', '未匹配到目标环境') - return None - - def switch_environment(self, to_env_uuid: str) -> bool: - """ - 切换到指定环境 - - Args: - to_env_uuid: 目标环境UUID - - Returns: - 是否切换成功 - """ - debug_logger.log_module('AgentVisionTool', '执行环境切换', { - 'to_env_uuid': (to_env_uuid[:8] + '...') if len(to_env_uuid) > 8 else to_env_uuid - }) - - current_env = self.db.get_active_environment() - if not current_env: - debug_logger.log_info('AgentVisionTool', '没有当前激活的环境') - return False - - # 检查是否可以切换(是否连通) - if not self.db.can_move_to_environment(current_env['uuid'], to_env_uuid): - debug_logger.log_info('AgentVisionTool', '不能切换到目标环境', { - 'reason': '环境不连通' - }) - return False - - # 执行切换 - success = self.db.set_active_environment(to_env_uuid) - if success: - to_env = self.db.get_environment(to_env_uuid) - debug_logger.log_info('AgentVisionTool', '环境切换成功', { - 'from': current_env['name'], - 'to': to_env['name'] if to_env else 'Unknown' - }) - print(f"✓ 环境已切换: {current_env['name']} → {to_env['name'] if to_env else 'Unknown'}") - else: - debug_logger.log_info('AgentVisionTool', '环境切换失败') - - return success - - def get_available_environments_for_switch(self) -> List[Dict[str, Any]]: - """ - 获取可以切换到的环境列表 - - Returns: - 可切换环境列表 - """ - current_env = self.db.get_active_environment() - if not current_env: - return [] - - return self.db.get_connected_environments(current_env['uuid']) - - # ==================== 环境域相关方法 ==================== - - def get_current_domain(self) -> Optional[Dict[str, Any]]: - """ - 获取当前环境所属的域 - 如果当前环境属于多个域,返回第一个域 - - Returns: - 域信息字典或None - """ - current_env = self.db.get_active_environment() - if not current_env: - debug_logger.log_info('AgentVisionTool', '没有当前激活的环境') - return None - - domains = self.db.get_environment_domains(current_env['uuid']) - if domains: - debug_logger.log_info('AgentVisionTool', '找到当前环境所属的域', { - 'domain_name': domains[0]['name'], - 'domain_count': len(domains) - }) - return domains[0] - - debug_logger.log_info('AgentVisionTool', '当前环境不属于任何域') - return None - - def get_domain_description(self, domain_uuid: str, use_default_env: bool = False) -> str: - """ - 获取域的描述信息 - - Args: - domain_uuid: 域UUID - use_default_env: 是否使用默认环境的详细描述 - - Returns: - 域的描述文本 - """ - domain = self.db.get_domain(domain_uuid) - if not domain: - return "" - - # 获取域中的环境列表 - environments = self.db.get_domain_environments(domain_uuid) - - if use_default_env and domain['default_environment_uuid']: - # 使用默认环境的详细描述 - default_env = self.db.get_environment(domain['default_environment_uuid']) - if default_env: - desc = f"【{domain['name']}】\n" - desc += f"{domain['description']}\n" if domain['description'] else "" - desc += f"当前位置: {default_env['name']}\n" - desc += f"{default_env['overall_description']}" - return desc - - # 使用域级别的概括描述 - desc = f"【{domain['name']}】\n" - desc += f"{domain['description']}\n" if domain['description'] else "" - - if environments: - env_names = [env['name'] for env in environments] - desc += f"包含环境: {', '.join(env_names)}" - - return desc - - def get_vision_context_with_precision(self, user_query: str, - high_precision: bool = False) -> Optional[Dict[str, Any]]: - """ - 根据精度要求获取视觉上下文 - - Args: - user_query: 用户查询 - high_precision: 是否需要高精度(具体环境)描述 - False: 返回域级别的描述 - True: 返回具体环境的详细描述 - - Returns: - 视觉上下文字典,包含环境或域的描述信息 - """ - debug_logger.log_module('AgentVisionTool', '根据精度获取视觉上下文', { - 'query': user_query, - 'high_precision': high_precision - }) - - # 检查是否需要使用视觉 - if not self.should_use_vision(user_query): - debug_logger.log_info('AgentVisionTool', '不需要使用视觉工具') - return None - - # 获取当前激活的环境 - current_env = self.db.get_active_environment() - if not current_env: - debug_logger.log_info('AgentVisionTool', '没有激活的环境') - return None - - # 检查当前环境是否属于某个域 - domains = self.db.get_environment_domains(current_env['uuid']) - - if not high_precision and domains: - # 低精度模式:返回域级别的描述 - domain = domains[0] # 使用第一个域 - domain_desc = self.get_domain_description(domain['uuid'], use_default_env=False) - - vision_context = { - 'type': 'domain', - 'domain': domain, - 'current_environment': current_env, - 'description': domain_desc, - 'query': user_query, - 'timestamp': datetime.now().isoformat() - } - - debug_logger.log_info('AgentVisionTool', '返回域级别视觉上下文', { - 'domain_name': domain['name'] - }) - - return vision_context - else: - # 高精度模式或不属于域:返回具体环境的详细描述 - return self.get_vision_context(user_query) - - def detect_precision_requirement(self, user_query: str) -> bool: - """ - 检测用户查询是否需要高精度的环境信息 - - Args: - user_query: 用户查询 - - Returns: - 是否需要高精度(True=需要具体环境,False=域级别即可) - """ - # 高精度关键词(需要具体环境描述) - high_precision_keywords = [ - '具体', '详细', '什么东西', '有什么', '有哪些', '看到', - '周围', '附近', '房间', '屋子', '物体', '物品' - ] - - query_lower = user_query.lower() - for keyword in high_precision_keywords: - if keyword in query_lower: - debug_logger.log_info('AgentVisionTool', '检测到高精度需求', { - 'keyword': keyword - }) - return True - - debug_logger.log_info('AgentVisionTool', '低精度需求(域级别)') - return False - - def switch_to_domain(self, domain_uuid: str) -> bool: - """ - 切换到指定域(会切换到该域的默认环境) - - Args: - domain_uuid: 目标域UUID - - Returns: - 是否切换成功 - """ - # 安全截取UUID用于日志显示 - uuid_display = domain_uuid[:8] + '...' if len(domain_uuid) > 8 else domain_uuid - debug_logger.log_module('AgentVisionTool', '切换到域', { - 'domain_uuid': uuid_display - }) - - domain = self.db.get_domain(domain_uuid) - if not domain: - debug_logger.log_info('AgentVisionTool', '域不存在') - return False - - # 如果域有默认环境,切换到默认环境 - if domain['default_environment_uuid']: - default_env = self.db.get_environment(domain['default_environment_uuid']) - if default_env: - success = self.db.set_active_environment(domain['default_environment_uuid']) - if success: - debug_logger.log_info('AgentVisionTool', '已切换到域的默认环境', { - 'domain': domain['name'], - 'default_env': default_env['name'] - }) - print(f"✓ 已切换到域: {domain['name']} (默认位置: {default_env['name']})") - return success - else: - debug_logger.log_info('AgentVisionTool', '域的默认环境不存在') - return False - else: - # 如果没有设置默认环境,切换到域中的第一个环境 - environments = self.db.get_domain_environments(domain_uuid) - if environments: - first_env = environments[0] - success = self.db.set_active_environment(first_env['uuid']) - if success: - debug_logger.log_info('AgentVisionTool', '已切换到域的第一个环境', { - 'domain': domain['name'], - 'env': first_env['name'] - }) - print(f"✓ 已切换到域: {domain['name']} (位置: {first_env['name']})") - return success - else: - debug_logger.log_info('AgentVisionTool', '域中没有环境') - return False - - def detect_domain_switch_intent(self, user_query: str) -> Optional[Dict[str, Any]]: - """ - 检测用户是否有切换到域的意图 - - Args: - user_query: 用户查询 - - Returns: - 如果检测到切换意图,返回包含目标域等信息的字典,否则返回None - """ - debug_logger.log_module('AgentVisionTool', '检测域切换意图', { - 'query': user_query - }) - - # 域切换关键词 - switch_keywords = [ - '去', '走', '移动', '前往', '过去', '进入', '离开', '出去', - '回到', '返回', '切换', '换到', '转移' - ] - - query_lower = user_query.lower() - has_switch_keyword = any(keyword in query_lower for keyword in switch_keywords) - - if not has_switch_keyword: - debug_logger.log_info('AgentVisionTool', '未检测到切换关键词') - return None - - # 获取所有域 - all_domains = self.db.get_all_domains() - if not all_domains: - debug_logger.log_info('AgentVisionTool', '没有已定义的域') - return None - - # 尝试匹配域名称 - matched_domain = None - for domain in all_domains: - if domain['name'] in user_query: - matched_domain = domain - break - - if matched_domain: - current_env = self.db.get_active_environment() - debug_logger.log_info('AgentVisionTool', '检测到域切换意图', { - 'from_env': current_env['name'] if current_env else 'None', - 'to_domain': matched_domain['name'] - }) - return { - 'intent': 'switch_domain', - 'from_env': current_env, - 'to_domain': matched_domain, - 'can_switch': True - } - - debug_logger.log_info('AgentVisionTool', '未匹配到目标域') - return None +原实现已迁移至 src.cerebellum.vision.agent_vision。 +此文件保留旧导入路径,确保现有代码不受影响。 +""" +from src.cerebellum.vision.agent_vision import AgentVisionTool -# 测试代码 -if __name__ == '__main__': - print("=" * 60) - print("智能体视觉工具测试") - print("=" * 60) - - # 创建视觉工具实例 - vision_tool = AgentVisionTool() - - # 创建默认环境 - print("\n创建默认环境:") - env_uuid = vision_tool.create_default_environment() - - # 测试视觉工具 - print("\n" + "=" * 60) - print("测试视觉工具") - print("=" * 60) - - test_queries = [ - "周围有什么?", - "我能看到什么?", - "房间里有哪些东西?", - "你在哪?", # 应该触发视觉(需要环境信息) - "你在哪里?", # 应该触发视觉(需要环境信息) - "今天天气怎么样?", # 不应该触发视觉 - "帮我讲个历史故事", # 不应该触发视觉 - ] - - for query in test_queries: - print(f"\n测试查询: {query}") - should_use = vision_tool.should_use_vision(query) - print(f" 是否使用视觉: {'是' if should_use else '否'}") - - if should_use: - vision_context = vision_tool.get_vision_context(query) - if vision_context: - summary = vision_tool.get_vision_summary(vision_context) - print(f" {summary}") - print(f"\n 视觉上下文预览:") - prompt = vision_tool.format_vision_prompt(vision_context) - print(f" {prompt[:300]}...") - - print("\n" + "=" * 60) - print("✓ 测试完成") - print("=" * 60) +__all__ = ["AgentVisionTool"] diff --git a/src/tools/debug_logger.py b/src/tools/debug_logger.py index c614cc8..54e441b 100644 --- a/src/tools/debug_logger.py +++ b/src/tools/debug_logger.py @@ -6,15 +6,25 @@ import os import sys import traceback +import asyncio +import inspect +import threading from datetime import datetime -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Callable, Union import json +from src.tools.unified_logger import get_unified_logger, LogLevel + class DebugLogger: """ Debug日志记录器 记录模块运行、提示词变化、API请求/响应等调试信息 + + Stage D.4: 新增 _subscribers 列表(与 listeners 并存,向后兼容)。 + - subscribe(cb) 注册订阅者;unsubscribe(cb) 取消 + - log_* 方法在通知 listeners 后,遍历 _subscribers 异步触发(asyncio.create_task) + - 同步 subscriber 直接调用;async subscriber 调度到事件循环 """ def __init__(self, debug_mode: bool = False, log_file: str = None): @@ -28,6 +38,12 @@ def __init__(self, debug_mode: bool = False, log_file: str = None): self.debug_mode = debug_mode self.log_file = log_file or 'debug.log' + # Stage v4.1: 接入 UnifiedLogger,统一前后端日志格式与输出 + self._unified = get_unified_logger( + log_file=self.log_file, + debug_mode=self.debug_mode, + ) + # 内存中的日志列表(用于GUI显示) self.logs: List[Dict[str, Any]] = [] @@ -41,9 +57,15 @@ def __init__(self, debug_mode: bool = False, log_file: str = None): 'info': 0 } - # 日志监听器(用于实时更新GUI) + # 日志监听器(用于实时更新GUI)—— 保持向后兼容 self.listeners = [] + # Stage D.4: WebSocket 订阅者(独立于 listeners,避免互相影响) + self._subscribers: List[Callable[[Dict[str, Any]], Any]] = [] + self._subscribers_lock = threading.RLock() + # 主事件循环引用(用于在跨线程上下文里调度 async subscriber) + self._main_loop: Optional[asyncio.AbstractEventLoop] = None + if self.debug_mode: print(f"✓ Debug模式已启用 | 日志文件: {self.log_file}") self._init_log_file() @@ -72,8 +94,98 @@ def remove_listener(self, callback): if callback in self.listeners: self.listeners.remove(callback) + # ===================================================================== + # Stage D.4: 新增 subscribe / unsubscribe / set_main_loop + # ===================================================================== + def subscribe(self, callback: Callable[[Dict[str, Any]], Any]) -> Callable[[], None]: + """ + 注册一个 WebSocket 订阅者(同步或 async 均可)。 + 返回 unsubscribe() 函数。 + """ + if not callable(callback): + raise TypeError("callback must be callable") + with self._subscribers_lock: + self._subscribers.append(callback) + + def _unsub() -> None: + self.unsubscribe(callback) + return _unsub + + def unsubscribe(self, callback: Callable[[Dict[str, Any]], Any]) -> bool: + """取消订阅。""" + with self._subscribers_lock: + try: + self._subscribers.remove(callback) + return True + except ValueError: + return False + + def set_main_loop(self, loop: Optional[asyncio.AbstractEventLoop]) -> None: + """ + 注入主事件循环(Web 启动时调用一次)。 + 之后 log_* 可在跨线程上下文里调度 async subscriber。 + """ + self._main_loop = loop + + def subscriber_count(self) -> int: + """查询订阅者数量。""" + with self._subscribers_lock: + return len(self._subscribers) + + def _notify_subscribers(self, log_entry: Dict[str, Any]) -> None: + """ + 通知所有 _subscribers。 + - 同步 subscriber 直接调用 + - async subscriber 优先调度到主事件循环;无循环时用 asyncio.create_task 兜底 + - 单个 subscriber 抛错不影响其他 + """ + with self._subscribers_lock: + subscribers = list(self._subscribers) + for cb in subscribers: + try: + if inspect.iscoroutinefunction(cb): + self._dispatch_async_subscriber(cb, log_entry) + else: + try: + cb(log_entry) + except Exception as e: + print(f"✗ 通知 subscriber 失败: {e}") + except Exception as e: + print(f"✗ 通知 subscriber 调度失败: {e}") + + def _dispatch_async_subscriber(self, cb: Callable, log_entry: Dict[str, Any]) -> None: + """调度 async subscriber 到事件循环。""" + try: + coro = cb(log_entry) + # 已有运行中的事件循环(Web 主循环 / 子线程 loop) + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + target_loop = self._main_loop or running_loop + if target_loop is not None and target_loop.is_running(): + try: + asyncio.run_coroutine_threadsafe(coro, target_loop) + return + except Exception: + pass + # 兜底:直接尝试 create_task(如果当前有 running loop) + if running_loop is not None: + try: + running_loop.create_task(coro) + return + except Exception: + pass + # 都没有运行中的 loop:用 asyncio.run 跑一下(极少数情况) + try: + asyncio.run(coro) + except Exception as inner_e: + print(f"✗ async subscriber 兜底执行失败: {inner_e}") + except Exception as e: + print(f"✗ 调度 async subscriber 失败: {e}") + def _notify_listeners(self, log_entry: Dict[str, Any]): - """通知所有监听器""" + """通知所有监听器(向后兼容)""" for listener in self.listeners: try: listener(log_entry) @@ -91,6 +203,31 @@ def _write_to_file(self, message: str): except Exception as e: print(f"✗ 写入日志文件失败: {e}") + def _log_to_unified( + self, + level: str, + module_name: str, + message: str, + extra: Optional[Dict[str, Any]] = None, + ) -> None: + """ + 将日志同时转发到 UnifiedLogger。 + + 保持 source='backend',因为 DebugLogger 当前仅用于后端模块。 + 前端日志由独立的 /ws/frontend-logs 直接写入 UnifiedLogger。 + """ + try: + self._unified.log( + level=level, + module=module_name, + message=message, + source="backend", + extra=extra, + ) + except Exception as e: + # 不得反向调用日志,避免循环 + print(f"✗ 转发 UnifiedLogger 失败: {e}") + def log_module(self, module_name: str, action: str, details: str = ""): """ 记录模块运行信息 @@ -121,6 +258,13 @@ def log_module(self, module_name: str, action: str, details: str = ""): print(message) self._write_to_file(message) self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="INFO", + module_name=module_name, + message=f"{action} | {details}" if details else action, + extra={"type": "module", "action": action, "details": details}, + ) def log_prompt(self, module_name: str, prompt_type: str, prompt_content: str, metadata: Dict[str, Any] = None): """ @@ -159,6 +303,18 @@ def log_prompt(self, module_name: str, prompt_type: str, prompt_content: str, me self._write_to_file(message) self._write_to_file(f" 完整内容:\n{prompt_content}\n") self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="DEBUG", + module_name=module_name, + message=f"{prompt_type} | {display_content}", + extra={ + "type": "prompt", + "prompt_type": prompt_type, + "content": prompt_content, + "metadata": metadata, + }, + ) def log_request(self, module_name: str, api_url: str, payload: Dict[str, Any], headers: Dict[str, str] = None): """ @@ -198,6 +354,18 @@ def log_request(self, module_name: str, api_url: str, payload: Dict[str, Any], h self._write_to_file(message) self._write_to_file(f" 完整Payload:\n{json.dumps(payload, ensure_ascii=False, indent=2)}\n") self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="DEBUG", + module_name=module_name, + message=api_url, + extra={ + "type": "request", + "api_url": api_url, + "payload": payload, + "headers": safe_headers, + }, + ) def log_response(self, module_name: str, response_data: Any, status_code: int = 200, elapsed_time: float = 0): """ @@ -235,6 +403,18 @@ def log_response(self, module_name: str, response_data: Any, status_code: int = self._write_to_file(message) self._write_to_file(f" 完整响应:\n{response_str}\n") self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="INFO", + module_name=module_name, + message=f"状态码:{status_code} | 耗时:{elapsed_time:.2f}s | {display_response}", + extra={ + "type": "response", + "status_code": status_code, + "elapsed_time": elapsed_time, + "response": response_data, + }, + ) def log_error(self, module_name: str, error_message: str, exception: Exception = None): """ @@ -294,6 +474,18 @@ def log_error(self, module_name: str, error_message: str, exception: Exception = if traceback_info: self._write_to_file(f" 完整堆栈:\n{traceback_info}") self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="ERROR", + module_name=module_name, + message=error_message, + extra={ + "type": "error", + "exception": str(exception) if exception else None, + "file_info": file_info, + "traceback": traceback_info, + }, + ) def log_info(self, module_name: str, message: str, data: Any = None): """ @@ -325,6 +517,47 @@ def log_info(self, module_name: str, message: str, data: Any = None): print(log_message) self._write_to_file(log_message) self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="INFO", + module_name=module_name, + message=message, + extra={"type": "info", "data": data}, + ) + + def log_warn(self, module_name: str, message: str, data: Any = None): + """ + 记录警告信息(Stage D.4 补充:与 log_info 类似但带 'warn' 类型)。 + """ + if not self.debug_mode: + return + + log_entry = { + 'timestamp': datetime.now().isoformat(), + 'type': 'warn', + 'module': module_name, + 'message': message, + 'data': data, + } + + self.logs.append(log_entry) + self.log_stats.setdefault('warn', 0) + self.log_stats['warn'] += 1 + + log_message = f"[{log_entry['timestamp']}] [WARN] {module_name} | {message}" + if data: + log_message += f"\n 数据: {json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else str(data)}" + + print(log_message) + self._write_to_file(log_message) + self._notify_listeners(log_entry) + self._notify_subscribers(log_entry) + self._log_to_unified( + level="WARN", + module_name=module_name, + message=message, + extra={"type": "warn", "data": data}, + ) def get_logs(self, log_type: str = None, module_name: str = None, limit: int = None) -> List[Dict[str, Any]]: """ @@ -355,17 +588,134 @@ def get_recent_logs(self, count: int = 50) -> List[Dict[str, Any]]: """获取最近的日志""" return self.logs[-count:] + # ===================================================================== + # Stage B.5: 统一筛选接口(向后兼容;不破坏现有签名) + # ===================================================================== + def get_buffer( + self, + module: Optional[str] = None, + level: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + limit: int = 200, + ) -> List[Dict[str, Any]]: + """ + 从内存缓冲读取历史日志(Stage B.5 新增,v4.1 扩展 UnifiedLogger 前端日志)。 + + Args: + module: 模块名筛选(精确匹配,可选) + level: 日志级别筛选(INFO / WARN / ERROR 等,大小写不敏感,可选) + start: 起始时间(ISO 8601 字符串,可选) + end: 结束时间(ISO 8601 字符串,可选) + limit: 返回上限(1-2000,超出按 2000 处理) + + Returns: + 命中筛选条件的日志列表(按时间升序,最多 limit 条) + """ + try: + limit = int(limit) if limit else 200 + except (TypeError, ValueError): + limit = 200 + limit = max(1, min(limit, 2000)) + + module_filter = (module or "").strip() or None + level_filter_raw = (level or "").strip().lower() or None + # type 字段在内部存储;与 type 等价的"级别"对外就是 log type + type_filter = level_filter_raw + + # ISO 8601 时间比较:直接字典序比较(多数情况下 ISO 8601 可直接比) + start_cmp = start.strip() if isinstance(start, str) and start.strip() else None + end_cmp = end.strip() if isinstance(end, str) and end.strip() else None + + result: List[Dict[str, Any]] = [] + seen_keys: set = set() + + def _key(e: Dict[str, Any]) -> str: + return "|".join([ + str(e.get("timestamp", "")), + str(e.get("module", "")), + str(e.get("message", "")), + str(e.get("source", "")), + ]) + + # 1) 旧格式日志(后端模块产生) + for entry in self.logs: + if not isinstance(entry, dict): + continue + # module 过滤 + if module_filter and entry.get('module') != module_filter: + continue + # level / type 过滤 + if type_filter: + entry_type = str(entry.get('type', '')).lower() + if entry_type != type_filter: + continue + # 时间范围过滤 + ts = entry.get('timestamp') + if isinstance(ts, str): + if start_cmp and ts < start_cmp: + continue + if end_cmp and ts > end_cmp: + continue + key = _key(entry) + seen_keys.add(key) + result.append(entry) + + # 2) UnifiedLogger 缓冲(包含前端日志 source='frontend') + try: + unified_logs = self._unified.get_buffer( + module=module, + level=level, + start=start, + end=end, + limit=limit * 2, + ) + for entry in unified_logs: + if not isinstance(entry, dict): + continue + # 旧格式日志已覆盖后端 source,避免重复 + if entry.get("source") == "backend": + key = _key(entry) + if key in seen_keys: + continue + seen_keys.add(_key(entry)) + result.append(entry) + except Exception as e: + print(f"✗ 读取 UnifiedLogger 缓冲失败: {e}") + + # 按时间戳排序并限制数量 + result.sort(key=lambda x: str(x.get("timestamp", ""))) + if len(result) > limit: + result = result[-limit:] + return result + def clear_logs(self): """清空内存中的日志""" self.logs.clear() self.log_stats = {key: 0 for key in self.log_stats} + try: + self._unified.clear_buffer() + except Exception as e: + print(f"✗ 清空 UnifiedLogger 缓冲失败: {e}") print("✓ 日志已清空") def get_statistics(self) -> Dict[str, Any]: - """获取日志统计信息""" + """获取日志统计信息(合并旧 stats 与 UnifiedLogger stats)。""" + unified_stats = {} + try: + unified_stats = self._unified.get_statistics() + except Exception as e: + print(f"✗ 获取 UnifiedLogger 统计失败: {e}") + + by_type = self.log_stats.copy() + for lvl, count in (unified_stats.get("by_level") or {}).items(): + key = str(lvl).lower() + by_type[key] = by_type.get(key, 0) + count + return { - 'total_logs': len(self.logs), - 'by_type': self.log_stats.copy(), + 'total_logs': len(self.logs) + (unified_stats.get("total_logs") or 0), + 'by_type': by_type, + 'by_source': unified_stats.get("by_source", {}), 'debug_mode': self.debug_mode, 'log_file': self.log_file } @@ -481,6 +831,13 @@ def init_debug_logger(debug_mode: bool = False, log_file: str = None): log_file: 日志文件路径 """ global _global_logger + # 同步重置 UnifiedLogger,确保参数一致 + from src.tools.unified_logger import get_unified_logger + get_unified_logger( + log_file=log_file or 'debug.log', + debug_mode=debug_mode, + reset=True, + ) _global_logger = DebugLogger(debug_mode=debug_mode, log_file=log_file) return _global_logger diff --git a/src/tools/expression_style.py b/src/tools/expression_style.py index 0d370c4..03a5fb8 100644 --- a/src/tools/expression_style.py +++ b/src/tools/expression_style.py @@ -1,452 +1,10 @@ """ -个性化表达风格模块 -负责管理智能体的个性化表达和学习用户的表达习惯 -""" - -import os -import re -import json -from typing import List, Dict, Any, Optional -from dotenv import load_dotenv -import requests -from src.core.database_manager import DatabaseManager -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class ExpressionStyleManager: - """ - 个性化表达风格管理器 - 负责: - 1. 管理智能体的个性化表达(如 'wc' => '表示对突发事情的感叹') - 2. 学习和总结用户的表达习惯 - """ - - # 类级别的常量配置 - LEARNING_INTERVAL = 10 # 用户表达习惯学习间隔(每N轮) - MIN_MESSAGES_FOR_LEARNING = 3 # 学习所需的最小用户消息数量 - CONFIDENCE_INCREMENT = 0.1 # 每次确认习惯时的置信度增加值 - - def __init__(self, - db_manager: DatabaseManager = None, - api_key: str = None, - api_url: str = None, - model_name: str = None): - """ - 初始化个性化表达风格管理器 - - Args: - db_manager: 数据库管理器实例(如果为None则创建新实例) - api_key: API密钥 - api_url: API地址 - model_name: 模型名称 - """ - # 使用共享的数据库管理器 - self.db = db_manager or DatabaseManager() - - # API配置(用于学习用户表达习惯) - self.api_key = api_key or os.getenv('SILICONFLOW_API_KEY') - self.api_url = api_url or os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - self.model_name = model_name or os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') - - # 用户表达习惯学习间隔(保留实例属性以保持兼容性) - self.learning_interval = self.LEARNING_INTERVAL - - debug_logger.log_module('ExpressionStyleManager', '个性化表达风格管理器初始化完成') - print(f"✓ 个性化表达风格管理器已初始化") - - # ==================== 智能体表达管理 ==================== - - def add_agent_expression(self, expression: str, meaning: str, category: str = "通用") -> str: - """ - 添加智能体个性化表达 - - Args: - expression: 表达方式(如 'wc'、'hhh'、'orz') - meaning: 含义(如 '表示对突发事情的感叹') - category: 分类(如 '感叹词'、'网络用语'、'表情替代') - - Returns: - 表达UUID - """ - debug_logger.log_info('ExpressionStyleManager', '添加智能体表达', { - 'expression': expression, - 'meaning': meaning, - 'category': category - }) - - expr_uuid = self.db.add_agent_expression(expression, meaning, category) - print(f"✓ 已添加智能体表达: '{expression}' => '{meaning}'") - return expr_uuid - - def get_agent_expressions(self, active_only: bool = True) -> List[Dict[str, Any]]: - """ - 获取所有智能体个性化表达 - - Args: - active_only: 是否只获取激活的表达 - - Returns: - 表达列表 - """ - return self.db.get_all_agent_expressions(active_only) - - def update_agent_expression(self, expr_uuid: str, **kwargs) -> bool: - """ - 更新智能体表达 - - Args: - expr_uuid: 表达UUID - **kwargs: 要更新的字段 - - Returns: - 是否更新成功 - """ - return self.db.update_agent_expression(expr_uuid, **kwargs) - - def delete_agent_expression(self, expr_uuid: str) -> bool: - """ - 删除智能体表达 - - Args: - expr_uuid: 表达UUID - - Returns: - 是否删除成功 - """ - return self.db.delete_agent_expression(expr_uuid) - - def generate_agent_expression_prompt(self) -> Optional[str]: - """ - 生成智能体个性化表达提示词 - - Returns: - 提示词文本,如果没有表达则返回None - """ - expressions = self.get_agent_expressions(active_only=True) - - if not expressions: - return None - - prompt_parts = ["【个性化表达风格】", "在合适的语境下,你可以使用以下个性化表达方式:"] - - for expr in expressions: - prompt_parts.append(f" • '{expr['expression']}' => {expr['meaning']}") - - prompt_parts.append("") - prompt_parts.append("请在回复中自然地融入这些表达,使对话更加生动有趣。") - - return "\n".join(prompt_parts) - - # ==================== 用户表达习惯学习 ==================== - - def learn_user_expressions(self, messages: List[Dict[str, Any]], - current_round: int = 0) -> List[Dict[str, Any]]: - """ - 从对话中学习用户的表达习惯 - - Args: - messages: 最近的对话消息列表(只分析用户消息) - current_round: 当前对话轮次(用于记录学习来源) - - Returns: - 学习到的表达习惯列表 - """ - debug_logger.log_module('ExpressionStyleManager', '开始学习用户表达习惯', { - 'message_count': len(messages), - 'current_round': current_round - }) - - # 只提取用户消息 - user_messages = [msg for msg in messages if msg.get('role') == 'user'] - - if len(user_messages) < self.MIN_MESSAGES_FOR_LEARNING: - debug_logger.log_info('ExpressionStyleManager', '用户消息太少,跳过学习') - return [] - - # 构建用户消息文本 - user_text = "\n".join([msg['content'] for msg in user_messages]) - - # 使用LLM分析用户表达习惯 - learned_habits = self._analyze_user_expressions(user_text, current_round) - - if learned_habits: - debug_logger.log_info('ExpressionStyleManager', '学习到用户表达习惯', { - 'count': len(learned_habits) - }) - - # 保存学习到的习惯 - for habit in learned_habits: - existing = self.db.find_user_expression_habit(habit['expression_pattern']) - if existing: - # 更新现有习惯的频率和置信度 - # 确保置信度在有效范围 [0.0, 1.0] 内 - current_confidence = max(0.0, min(1.0, existing['confidence'])) - new_confidence = min(1.0, current_confidence + self.CONFIDENCE_INCREMENT) - self.db.increment_habit_frequency(existing['uuid']) - self.db.update_user_expression_habit( - existing['uuid'], - confidence=new_confidence, - meaning=habit['meaning'] # 可能有更新的理解 - ) - print(f" ✓ 更新用户习惯: '{habit['expression_pattern']}' (频率+1, 置信度={new_confidence:.2f})") - else: - # 添加新习惯 - self.db.add_user_expression_habit( - expression_pattern=habit['expression_pattern'], - meaning=habit['meaning'], - confidence=habit.get('confidence', 0.7), - learned_from_rounds=str(current_round) - ) - print(f" ✓ 新增用户习惯: '{habit['expression_pattern']}' => '{habit['meaning']}'") - - return learned_habits - - def _analyze_user_expressions(self, user_text: str, current_round: int) -> List[Dict[str, Any]]: - """ - 使用LLM分析用户的表达习惯 - - Args: - user_text: 用户消息文本 - current_round: 当前对话轮次 +v3.1.0 兼容层:src.tools.expression_style - Returns: - 分析出的表达习惯列表 - """ - try: - analysis_prompt = f"""请分析以下用户消息中的表达习惯和特殊用语。 - -用户消息: -{user_text} - -请识别用户使用的: -1. 网络用语/缩写(如 'hhh'、'2333'、'xswl' 等) -2. 口头禅或常用表达 -3. 特殊的表达方式或语气词 -4. 个人化的表达习惯 - -对于每个识别到的表达,请分析其含义和使用场景。 - -返回JSON格式(只返回JSON数组,不要其他文字): -[ - {{ - "expression_pattern": "表达内容", - "meaning": "含义和使用场景", - "confidence": 0.8 - }} -] - -如果没有识别到特殊表达习惯,返回空数组 []""" - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': [ - {'role': 'system', 'content': '你是一个专业的语言分析助手,擅长识别用户的表达习惯和特殊用语。你只返回JSON格式的数据。'}, - {'role': 'user', 'content': analysis_prompt} - ], - 'temperature': 0.0, - 'max_tokens': 1000, - 'stream': False - } - - debug_logger.log_request('ExpressionStyleManager', self.api_url, payload, headers) - - # 使用可配置的超时时间 - api_timeout = int(os.getenv("LLM_API_TIMEOUT", "60")) - response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=api_timeout - ) - - response.raise_for_status() - result = response.json() - - debug_logger.log_response('ExpressionStyleManager', result, response.status_code, 0) - - if 'choices' in result and len(result['choices']) > 0: - content = result['choices'][0]['message']['content'].strip() - - # 使用更健壮的JSON提取逻辑 - json_content = self._extract_json_from_response(content) - - try: - habits = json.loads(json_content) - if isinstance(habits, list): - return habits - else: - debug_logger.log_error('ExpressionStyleManager', '返回的不是列表格式') - return [] - except json.JSONDecodeError as e: - debug_logger.log_error('ExpressionStyleManager', f'JSON解析失败: {e}') - return [] - else: - debug_logger.log_error('ExpressionStyleManager', '未能获取有效的分析结果') - return [] - - except Exception as e: - debug_logger.log_error('ExpressionStyleManager', f'分析用户表达习惯时出错: {e}', e) - print(f"✗ 分析用户表达习惯时出错: {e}") - return [] - - def _extract_json_from_response(self, content: str) -> str: - """ - 从LLM响应中提取JSON内容 - - Args: - content: LLM响应内容 - - Returns: - 提取的JSON字符串 - """ - def is_valid_expression_json(json_str: str) -> bool: - """验证JSON数组是否包含预期的表达习惯结构""" - try: - data = json.loads(json_str) - if not isinstance(data, list): - return False - # 空数组是有效的 - if len(data) == 0: - return True - # 检查第一个元素是否包含预期的键 - if isinstance(data[0], dict): - expected_keys = {'expression_pattern', 'meaning'} - return expected_keys.issubset(set(data[0].keys())) - return False - except (json.JSONDecodeError, KeyError, IndexError): - return False - - # 尝试方法1:直接解析(如果内容本身就是有效JSON) - if is_valid_expression_json(content): - return content - - # 尝试方法2:使用正则表达式提取JSON数组 - json_array_pattern = r'\[[\s\S]*?\]' - matches = re.findall(json_array_pattern, content) - for match in matches: - if is_valid_expression_json(match): - return match - - # 尝试方法3:处理markdown代码块 - if '```' in content: - # 提取所有代码块 - code_blocks = re.findall(r'```(?:json)?\s*([\s\S]*?)```', content) - for block in code_blocks: - block = block.strip() - if is_valid_expression_json(block): - return block - - # 如果都失败了,返回空数组字符串 - return '[]' - - def get_user_expression_habits(self, min_confidence: float = 0.5) -> List[Dict[str, Any]]: - """ - 获取用户表达习惯 - - Args: - min_confidence: 最低置信度 - - Returns: - 习惯列表 - """ - return self.db.get_all_user_expression_habits(min_confidence) - - def generate_user_expression_context(self) -> Optional[str]: - """ - 生成用户表达习惯上下文提示词 - - Returns: - 提示词文本,如果没有可用的表达习惯则返回None - """ - habits = self.get_user_expression_habits(min_confidence=0.6) - - if not habits: - return None - - prompt_parts = ["【用户表达习惯参考】", "根据历史对话,用户有以下表达习惯:"] - - for habit in habits[:10]: # 最多显示10个习惯 - confidence_label = "高" if habit['confidence'] >= 0.8 else "中" - prompt_parts.append(f" • '{habit['expression_pattern']}' => {habit['meaning']} [置信度: {confidence_label}]") - - prompt_parts.append("") - prompt_parts.append("理解用户的这些表达习惯可以帮助你更好地理解用户的意图。") - - return "\n".join(prompt_parts) - - def clear_user_expression_habits(self) -> bool: - """ - 清空所有用户表达习惯 - - Returns: - 是否清空成功 - """ - return self.db.clear_user_expression_habits() - - # ==================== 统计方法 ==================== - - def get_statistics(self) -> Dict[str, Any]: - """ - 获取表达风格统计信息 - - Returns: - 统计信息字典 - """ - agent_expressions = self.db.get_all_agent_expressions(active_only=False) - user_habits = self.db.get_all_user_expression_habits() - - return { - 'agent_expressions': { - 'total': len(agent_expressions), - 'active': sum(1 for e in agent_expressions if e.get('is_active', 1)), - 'total_usage': sum(e.get('usage_count', 0) for e in agent_expressions) - }, - 'user_habits': { - 'total': len(user_habits), - 'high_confidence': sum(1 for h in user_habits if h.get('confidence', 0) >= 0.8), - 'medium_confidence': sum(1 for h in user_habits if 0.5 <= h.get('confidence', 0) < 0.8) - } - } - - -if __name__ == '__main__': - print("=" * 60) - print("个性化表达风格管理器测试") - print("=" * 60) - - manager = ExpressionStyleManager() - - # 测试添加智能体表达 - print("\n测试添加智能体表达:") - manager.add_agent_expression("wc", "表示对突发事情的感叹", "感叹词") - manager.add_agent_expression("hhh", "表示开心、高兴的笑声", "笑声表达") - manager.add_agent_expression("orz", "表示跪拜、佩服或无奈", "表情替代") - - # 显示所有智能体表达 - print("\n当前智能体表达:") - expressions = manager.get_agent_expressions() - for expr in expressions: - print(f" • '{expr['expression']}' => {expr['meaning']} (使用次数: {expr['usage_count']})") - - # 生成提示词 - print("\n生成的智能体表达提示词:") - prompt = manager.generate_agent_expression_prompt() - print(prompt) +原实现已迁移至 src.cerebellum.style.expression_style。 +此文件保留旧导入路径,确保现有代码不受影响。 +""" - # 显示统计 - print("\n统计信息:") - stats = manager.get_statistics() - print(f" 智能体表达总数: {stats['agent_expressions']['total']}") - print(f" 用户习惯总数: {stats['user_habits']['total']}") +from src.cerebellum.style.expression_style import ExpressionStyleManager - print("\n✓ 测试完成") +__all__ = ["ExpressionStyleManager"] diff --git a/src/tools/interrupt_question_tool.py b/src/tools/interrupt_question_tool.py index 1515875..50e0cb1 100644 --- a/src/tools/interrupt_question_tool.py +++ b/src/tools/interrupt_question_tool.py @@ -1,141 +1,10 @@ """ -中断性提问工具 -允许智能体在任务执行过程中向用户提问 -""" - -from typing import Optional, Callable, Dict, Any -from src.tools.debug_logger import get_debug_logger - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - - -class InterruptQuestionTool: - """ - 中断性提问工具类 - 智能体可以使用此工具在任务执行过程中向用户提问 - """ - - def __init__(self): - """初始化中断性提问工具""" - self.question_callback: Optional[Callable] = None - debug_logger.log_module('InterruptQuestionTool', '中断性提问工具初始化完成') - - def set_question_callback(self, callback: Callable[[str], str]): - """ - 设置提问回调函数 - - Args: - callback: 回调函数,接收问题字符串,返回用户回答字符串 - """ - self.question_callback = callback - debug_logger.log_info('InterruptQuestionTool', '提问回调函数已设置') - - def ask_user( - self, - question: str, - context: str = "" - ) -> str: - """ - 向用户提问 - - Args: - question: 要提问的问题 - context: 问题背景上下文 - - Returns: - 用户的回答 - """ - debug_logger.log_module('InterruptQuestionTool', '智能体发起提问', { - 'question': question, - 'has_context': bool(context) - }) +v3.1.0 兼容层:src.tools.interrupt_question_tool - if not self.question_callback: - debug_logger.log_error( - 'InterruptQuestionTool', - '提问回调函数未设置', - None - ) - return "【系统错误】提问功能未配置" - - # 格式化问题 - formatted_question = self._format_question(question, context) - - # 调用回调函数获取用户回答 - try: - answer = self.question_callback(formatted_question) - - debug_logger.log_info('InterruptQuestionTool', '用户已回答', { - 'question_length': len(question), - 'answer_length': len(answer) - }) - - return answer - - except Exception as e: - debug_logger.log_error( - 'InterruptQuestionTool', - f'提问过程出错: {str(e)}', - e - ) - return "【系统错误】提问失败" - - def _format_question(self, question: str, context: str = "") -> str: - """ - 格式化问题 - - Args: - question: 原始问题 - context: 上下文 - - Returns: - 格式化后的问题 - """ - if context: - return f"🤔 【智能体提问】\n\n背景:{context}\n\n问题:{question}" - else: - return f"🤔 【智能体提问】\n\n{question}" - - def create_tool_description(self) -> Dict[str, Any]: - """ - 创建工具描述(用于智能体理解如何使用此工具) - - Returns: - 工具描述字典 - """ - return { - 'name': 'interrupt_question', - 'description': '''向用户提出问题并等待回答。 - -使用场景: -- 任务信息不明确,需要用户补充 -- 需要用户确认某个决策 -- 遇到多个选择,需要用户决定 -- 需要用户提供具体的参数或数据 - -使用方法: -调用 ask_user(question, context) 函数 -- question: 要问的问题(必填) -- context: 问题的背景说明(可选) +原实现已迁移至 src.cerebellum.interrupt.interrupt_question_tool。 +此文件保留旧导入路径,确保现有代码不受影响。 +""" -返回:用户的回答字符串 +from src.cerebellum.interrupt.interrupt_question_tool import InterruptQuestionTool -注意: -- 问题应该清晰明确 -- 避免一次问太多问题 -- 等待用户回答后再继续任务 -''', - 'parameters': { - 'question': { - 'type': 'string', - 'description': '要向用户提出的问题', - 'required': True - }, - 'context': { - 'type': 'string', - 'description': '问题的背景上下文说明', - 'required': False - } - } - } +__all__ = ["InterruptQuestionTool"] diff --git a/src/tools/nps_bridge_tool.py b/src/tools/nps_bridge_tool.py index 38108d2..2912ff9 100644 --- a/src/tools/nps_bridge_tool.py +++ b/src/tools/nps_bridge_tool.py @@ -1,160 +1,10 @@ """ -NPS Bridge Tool - NPS工具桥接 -允许智能体协作系统调用NPS工具获取信息 -""" - -from typing import Dict, Any, Optional -from src.tools.debug_logger import get_debug_logger +v3.1.0 兼容层:src.tools.nps_bridge_tool -# 获取debug日志记录器 -debug_logger = get_debug_logger() +原实现已迁移至 src.cerebellum.nps.bridge_tool。 +此文件保留旧导入路径,确保现有代码不受影响。 +""" +from src.cerebellum.nps.bridge_tool import NPSBridgeTool -class NPSBridgeTool: - """ - NPS工具桥接类 - 为智能体提供调用NPS工具的接口 - """ - - def __init__(self, nps_invoker=None): - """ - 初始化NPS桥接工具 - - Args: - nps_invoker: NPSInvoker实例,用于调用NPS工具 - """ - self.nps_invoker = nps_invoker - debug_logger.log_module('NPSBridgeTool', 'NPS桥接工具初始化完成') - - def call_nps_tool(self, tool_name: str, query: str, **kwargs) -> Dict[str, Any]: - """ - 调用指定的NPS工具 - - Args: - tool_name: 工具名称(如'websearch', 'systime') - query: 查询内容或参数 - **kwargs: 其他参数 - - Returns: - NPS工具执行结果 - """ - if not self.nps_invoker: - debug_logger.log_error('NPSBridgeTool', 'NPS调用器未初始化', None) - return { - 'success': False, - 'error': 'NPS调用器未初始化' - } - - try: - # 构建执行上下文 - context = { - 'query': query, - **kwargs - } - - debug_logger.log_module( - 'NPSBridgeTool', - f'智能体调用NPS工具: {tool_name}', - {'query': query} - ) - - # 通过工具名称调用 - result = self.nps_invoker.invoke_tool_by_name(tool_name, context) - - if result.get('success'): - debug_logger.log_info('NPSBridgeTool', f'NPS工具调用成功: {tool_name}') - else: - debug_logger.log_warning('NPSBridgeTool', f'NPS工具调用失败: {tool_name}') - - return result - - except Exception as e: - debug_logger.log_error('NPSBridgeTool', f'NPS工具调用出错: {tool_name}', e) - return { - 'success': False, - 'error': f'调用NPS工具失败: {str(e)}' - } - - def search_web(self, query: str, num_results: int = 5, **kwargs) -> Dict[str, Any]: - """ - 网络搜索快捷方法 - - Args: - query: 搜索查询 - num_results: 返回结果数量 - **kwargs: 其他参数 - - Returns: - 搜索结果 - """ - return self.call_nps_tool('websearch', query, num_results=num_results, **kwargs) - - def get_system_time(self) -> Dict[str, Any]: - """ - 获取系统时间快捷方法 - - Returns: - 系统时间信息 - """ - return self.call_nps_tool('systime', '') - - def get_available_tools(self) -> Dict[str, Any]: - """ - 获取可用的NPS工具列表 - - Returns: - 工具列表 - """ - if not self.nps_invoker: - return { - 'success': False, - 'error': 'NPS调用器未初始化' - } - - try: - tools = self.nps_invoker.get_all_tools() - tool_list = [ - { - 'tool_id': tool.tool_id, - 'name': tool.name, - 'description': tool.description, - 'enabled': tool.enabled - } - for tool in tools - ] - - return { - 'success': True, - 'tools': tool_list, - 'count': len(tool_list) - } - except Exception as e: - debug_logger.log_error('NPSBridgeTool', '获取NPS工具列表失败', e) - return { - 'success': False, - 'error': str(e) - } - - def format_tool_list_for_agent(self) -> str: - """ - 将工具列表格式化为智能体可读的文本 - - Returns: - 格式化的工具列表描述 - """ - tools_result = self.get_available_tools() - - if not tools_result.get('success'): - return f"获取工具列表失败:{tools_result.get('error', '未知错误')}" - - tools = tools_result.get('tools', []) - if not tools: - return "当前没有可用的NPS工具" - - output = [f"可用的NPS工具共{len(tools)}个:\n"] - for tool in tools: - status = "✅" if tool['enabled'] else "❌" - output.append(f"{status} {tool['name']} ({tool['tool_id']})") - output.append(f" 功能:{tool['description']}\n") - - return '\n'.join(output) +__all__ = ["NPSBridgeTool"] diff --git a/src/tools/nps_langchain_tools.py b/src/tools/nps_langchain_tools.py index 2de0eb1..ec1ca6f 100644 --- a/src/tools/nps_langchain_tools.py +++ b/src/tools/nps_langchain_tools.py @@ -1,126 +1,18 @@ """ -NPS LangChain Tool Wrapper -将NPS工具包装为LangChain Tool,使其可以被智能体调用 -""" - -from typing import Optional, Dict, Any -from langchain.tools import BaseTool -from pydantic import Field -from src.tools.debug_logger import get_debug_logger - -debug_logger = get_debug_logger() - - -class NPSWebSearchTool(BaseTool): - """NPS网络搜索工具 - LangChain兼容""" - - name: str = "nps_web_search" - description: str = """ - 使用SerpAPI进行网络搜索,获取实时信息、新闻、知识等。 - - 适用场景: - - 需要查询最新信息、新闻、动态 - - 搜索特定内容、知识、资料 - - 获取网络上的公开信息 - - 需要验证或补充信息 - - 输入格式:搜索查询文本(如"明日方舟终末地"、"最新AI新闻"等) - 输出格式:格式化的搜索结果,包含标题、摘要、链接等 - """ - - nps_bridge: Any = Field(default=None, exclude=True) - - def _run(self, query: str) -> str: - """执行搜索""" - if not self.nps_bridge: - return "错误:NPS桥接工具未初始化" - - try: - result = self.nps_bridge.search_web(query, num_results=5) - - if not result.get('success'): - return f"搜索失败:{result.get('error', '未知错误')}" - - # 格式化结果 - from src.nps.tool.websearch import format_search_results_for_agent - return format_search_results_for_agent(result.get('result', result)) - - except Exception as e: - debug_logger.log_error('NPSWebSearchTool', '搜索失败', e) - return f"搜索失败:{str(e)}" - - async def _arun(self, query: str) -> str: - """异步执行(降级到同步)""" - return self._run(query) - - -class NPSSystemTimeTool(BaseTool): - """NPS系统时间工具 - LangChain兼容""" - - name: str = "nps_system_time" - description: str = """ - 获取当前系统时间、日期、星期等信息。 - - 适用场景: - - 回答"现在几点"、"今天星期几"等时间相关问题 - - 需要知道当前日期、时间来完成任务 - - 判断时间段(早上/中午/晚上等) - - 输入格式:不需要输入,直接调用即可 - 输出格式:当前时间的详细信息(年月日、时分秒、星期、时段等) - """ - - nps_bridge: Any = Field(default=None, exclude=True) - - def _run(self, query: str = "") -> str: - """获取系统时间""" - if not self.nps_bridge: - return "错误:NPS桥接工具未初始化" - - try: - result = self.nps_bridge.get_system_time() - - if not result.get('success'): - return f"获取时间失败:{result.get('error', '未知错误')}" - - # 提取时间信息 - time_data = result.get('result', {}) - if isinstance(time_data, dict) and 'context' in time_data: - return time_data['context'] - - return str(time_data) - - except Exception as e: - debug_logger.log_error('NPSSystemTimeTool', '获取时间失败', e) - return f"获取时间失败:{str(e)}" - - async def _arun(self, query: str = "") -> str: - """异步执行(降级到同步)""" - return self._run(query) +v3.1.0 兼容层:src.tools.nps_langchain_tools +原实现已迁移至 src.cerebellum.nps.langchain_tools。 +此文件保留旧导入路径,确保现有代码不受影响。 +""" -def create_nps_tools(nps_bridge) -> list: - """ - 创建所有NPS工具的LangChain包装 - - Args: - nps_bridge: NPSBridgeTool实例 - - Returns: - LangChain Tool列表 - """ - tools = [] - - # 网络搜索工具 - web_search_tool = NPSWebSearchTool() - web_search_tool.nps_bridge = nps_bridge - tools.append(web_search_tool) - - # 系统时间工具 - system_time_tool = NPSSystemTimeTool() - system_time_tool.nps_bridge = nps_bridge - tools.append(system_time_tool) - - debug_logger.log_module('NPSLangChainTools', f'创建了{len(tools)}个NPS LangChain工具') - - return tools +from src.cerebellum.nps.langchain_tools import ( + NPSSystemTimeTool, + NPSWebSearchTool, + create_nps_tools, +) + +__all__ = [ + "NPSSystemTimeTool", + "NPSWebSearchTool", + "create_nps_tools", +] diff --git a/src/tools/schedule_intent_tool.py b/src/tools/schedule_intent_tool.py index 4e37773..49272e2 100644 --- a/src/tools/schedule_intent_tool.py +++ b/src/tools/schedule_intent_tool.py @@ -1,286 +1,10 @@ """ -日程意图识别工具 -用于识别用户输入中的日程相关意图和邀约信息 -""" - -import os -import re -import json -import time -from typing import Dict, Any, Optional, Tuple -from datetime import datetime, timedelta -from dotenv import load_dotenv -import requests -from src.tools.debug_logger import get_debug_logger - -load_dotenv() - -# 获取debug日志记录器 -debug_logger = get_debug_logger() - -# 星期映射常量 -WEEKDAY_MAP = { - '一': 0, '二': 1, '三': 2, '四': 3, '五': 4, '六': 5, '日': 6, '天': 6, - '1': 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6 -} - - -class ScheduleIntentTool: - """ - 日程意图识别工具 - 使用LLM识别用户输入中的日程邀约和时间信息 - """ - - def __init__(self): - """初始化日程意图识别工具""" - self.api_key = os.getenv('SILICONFLOW_API_KEY') - self.api_url = os.getenv('SILICONFLOW_API_URL', 'https://api.siliconflow.cn/v1/chat/completions') - self.model_name = os.getenv('MODEL_NAME', 'Qwen/Qwen2.5-7B-Instruct') - self.temperature = 0.3 # 使用较低的温度以获得更确定的输出 - self.max_tokens = 500 - - debug_logger.log_module('ScheduleIntentTool', '日程意图识别工具初始化完成') - - def recognize_intent( - self, - user_input: str, - character_name: str = "智能体", - context: str = "" - ) -> Dict[str, Any]: - """ - 识别用户输入中的日程意图 - - Args: - user_input: 用户输入 - character_name: 智能体名称 - context: 对话上下文 - - Returns: - 识别结果字典,包含: - - has_schedule_intent: 是否包含日程意图 - - schedule_type: 日程类型(appointment/query) - - title: 日程标题 - - description: 日程描述 - - start_time: 开始时间(ISO格式) - - end_time: 结束时间(ISO格式) - - involves_agent: 是否涉及智能体 - - involves_user: 是否涉及用户 - - confidence: 置信度(0-1) - """ - debug_logger.log_module('ScheduleIntentTool', '开始识别日程意图', { - 'input_length': len(user_input) - }) - - # 构建识别提示词 - system_prompt = f"""你是一个日程意图识别专家。请分析用户输入,识别其中是否包含日程相关的意图。 - -智能体名称:{character_name} - -分析要点: -1. 判断是否包含邀约、约定、计划等日程相关内容 -2. 提取时间信息(具体时间或相对时间如"明天"、"下周三") -3. 判断是创建日程还是查询日程 -4. 识别主语(当用户没有明确主语时,根据语境判断) -5. 判断是否涉及智能体和/或用户 - -请以JSON格式输出分析结果: -{{ - "has_schedule_intent": true/false, - "schedule_type": "appointment"/"query"/"none", - "title": "日程标题", - "description": "详细描述", - "time_expression": "提取的时间表达", - "start_time": "ISO格式时间或null", - "end_time": "ISO格式时间或null", - "involves_agent": true/false, - "involves_user": true/false, - "confidence": 0.0-1.0, - "reasoning": "分析理由" -}}""" - - user_prompt = f"""用户输入:"{user_input}" - -{f"对话上下文:{context}" if context else ""} +v3.1.0 兼容层:src.tools.schedule_intent_tool -请分析这段输入,识别日程意图。""" - - try: - # 调用LLM进行意图识别 - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} - ] - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json' - } - - payload = { - 'model': self.model_name, - 'messages': messages, - 'temperature': self.temperature, - 'max_tokens': self.max_tokens, - 'stream': False - } - - debug_logger.log_request('ScheduleIntentTool', self.api_url, payload, headers) - - start_time = time.time() - response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=30 - ) - elapsed_time = time.time() - start_time - - debug_logger.log_response('ScheduleIntentTool', response.status_code, response.text, elapsed_time) - - if response.status_code == 200: - result = response.json() - content = result['choices'][0]['message']['content'].strip() - - # 尝试解析JSON结果 - try: - # 提取JSON部分(可能被包裹在markdown代码块中) - json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL) - if json_match: - content = json_match.group(1) - - intent_result = json.loads(content) - - # 补充处理相对时间表达 - if intent_result.get('has_schedule_intent') and not intent_result.get('start_time'): - time_expr = intent_result.get('time_expression', '') - if time_expr: - start_time, end_time = self._parse_time_expression(time_expr) - if start_time: - intent_result['start_time'] = start_time - intent_result['end_time'] = end_time - - debug_logger.log_info('ScheduleIntentTool', '意图识别成功', intent_result) - return intent_result - - except json.JSONDecodeError as e: - debug_logger.log_error('ScheduleIntentTool', f'解析JSON失败: {content}', e) - return self._get_fallback_result() - else: - debug_logger.log_error('ScheduleIntentTool', f'API调用失败: {response.status_code}') - return self._get_fallback_result() - - except Exception as e: - debug_logger.log_error('ScheduleIntentTool', f'意图识别异常: {str(e)}', e) - return self._get_fallback_result() - - def _parse_time_expression(self, time_expr: str) -> Tuple[Optional[str], Optional[str]]: - """ - 解析时间表达式为ISO格式时间 - - Args: - time_expr: 时间表达式(如"明天下午3点"、"周三上午") - - Returns: - (开始时间, 结束时间) ISO格式或 (None, None) - """ - now = datetime.now() - - # 相对日期关键词 - if "明天" in time_expr or "明日" in time_expr: - target_date = now + timedelta(days=1) - elif "后天" in time_expr: - target_date = now + timedelta(days=2) - elif "大后天" in time_expr: - target_date = now + timedelta(days=3) - elif "下周" in time_expr: - # 找到下周的对应星期 - weekday_match = re.search(r'[周星期]([一二三四五六日天1-7])', time_expr) - if weekday_match: - target_weekday = WEEKDAY_MAP.get(weekday_match.group(1), 0) - days_ahead = target_weekday - now.weekday() + 7 - if days_ahead <= 7: - days_ahead += 7 - target_date = now + timedelta(days=days_ahead) - else: - target_date = now + timedelta(days=7) - elif "今天" in time_expr or "今日" in time_expr: - target_date = now - else: - # 尝试匹配本周的星期 - weekday_match = re.search(r'[周星期]([一二三四五六日天1-7])', time_expr) - if weekday_match: - target_weekday = WEEKDAY_MAP.get(weekday_match.group(1), 0) - days_ahead = target_weekday - now.weekday() - if days_ahead < 0: - days_ahead += 7 - target_date = now + timedelta(days=days_ahead) - else: - return None, None - - # 提取时间 - hour = 14 # 默认下午2点 - minute = 0 - - # 匹配具体时间 - time_match = re.search(r'(\d{1,2})[点时::](\d{1,2})?', time_expr) - if time_match: - hour = int(time_match.group(1)) - minute = int(time_match.group(2)) if time_match.group(2) else 0 - else: - # 根据时段关键词设置时间 - if "早上" in time_expr or "上午" in time_expr or "早晨" in time_expr: - hour = 9 - elif "中午" in time_expr: - hour = 12 - elif "下午" in time_expr: - hour = 14 - elif "晚上" in time_expr or "傍晚" in time_expr: - hour = 18 - elif "夜里" in time_expr or "深夜" in time_expr: - hour = 22 - - # 构建ISO格式时间 - start_datetime = target_date.replace(hour=hour, minute=minute, second=0, microsecond=0) - end_datetime = start_datetime + timedelta(hours=2) # 默认持续2小时 - - return start_datetime.isoformat(), end_datetime.isoformat() - - def _get_fallback_result(self) -> Dict[str, Any]: - """ - 获取降级结果(当LLM不可用时) - - Returns: - 默认的意图识别结果 - """ - return { - 'has_schedule_intent': False, - 'schedule_type': 'none', - 'title': '', - 'description': '', - 'time_expression': '', - 'start_time': None, - 'end_time': None, - 'involves_agent': False, - 'involves_user': False, - 'confidence': 0.0, - 'reasoning': '意图识别服务不可用' - } - - def is_query_schedule(self, user_input: str) -> bool: - """ - 快速判断是否为查询日程的意图 +原实现已迁移至 src.cerebellum.intent.schedule_intent_tool。 +此文件保留旧导入路径,确保现有代码不受影响。 +""" - Args: - user_input: 用户输入 +from src.cerebellum.intent.schedule_intent_tool import ScheduleIntentTool - Returns: - 是否为查询日程意图 - """ - query_keywords = [ - '日程', '安排', '计划', '行程', - '什么时候', '有什么事', '忙不忙', - '空闲', '有空', '在干什么', '在做什么' - ] - - user_input_lower = user_input.lower() - return any(keyword in user_input_lower for keyword in query_keywords) +__all__ = ["ScheduleIntentTool"] diff --git a/src/tools/settings_migration.py b/src/tools/settings_migration.py index 4b18915..dd443c3 100644 --- a/src/tools/settings_migration.py +++ b/src/tools/settings_migration.py @@ -291,7 +291,8 @@ def _write_env_file(self, env_settings: Dict[str, str], overwrite: bool = False) f.write(f"# 导入时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") # 按类别分组写入 - api_keys = ['SILICONFLOW_API_KEY', 'SILICONFLOW_API_URL'] + # v3.1.0: 保留旧 key 列表(仅用于旧 .env 迁移场景),新加 LLM_API_KEY + api_keys = ['LLM_API_KEY', 'SILICONFLOW_API_KEY', 'SILICONFLOW_API_URL', 'LLM_BASE_URL', 'LLM_MODEL_NAME'] model_keys = ['MODEL_NAME', 'TEMPERATURE', 'MAX_TOKENS'] character_keys = [k for k in env_settings.keys() if k.startswith('CHARACTER_')] memory_keys = [k for k in env_settings.keys() if 'MEMORY' in k] diff --git a/src/tools/tooltip_utils.py b/src/tools/tooltip_utils.py deleted file mode 100644 index 283dfcd..0000000 --- a/src/tools/tooltip_utils.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -工具提示(ToolTip)工具类 -提供鼠标悬停显示完整文本的功能 -""" - -import tkinter as tk -from typing import Optional - - -class ToolTip: - """ - 工具提示类 - 在鼠标悬浮时显示完整文本 - """ - def __init__(self, widget, text='', delay=500, wraplength=400): - """ - 初始化工具提示 - - Args: - widget: 要绑定的控件 - text: 提示文本 - delay: 延迟显示时间(毫秒) - wraplength: 文本换行长度 - """ - self.widget = widget - self.text = text - self.delay = delay - self.wraplength = wraplength - self.tooltip_window: Optional[tk.Toplevel] = None - self.show_timer: Optional[str] = None - - # 绑定事件 - self.widget.bind("", self.on_enter) - self.widget.bind("", self.on_leave) - self.widget.bind("", self.on_motion) - - def on_enter(self, event=None): - """鼠标进入控件""" - self.schedule_show() - - def on_leave(self, event=None): - """鼠标离开控件""" - self.cancel_show() - self.hide() - - def on_motion(self, event=None): - """鼠标移动""" - if self.tooltip_window: - self.hide() - self.schedule_show() - - def schedule_show(self): - """调度显示提示""" - self.cancel_show() - if self.text: - self.show_timer = self.widget.after(self.delay, self.show) - - def cancel_show(self): - """取消显示""" - if self.show_timer: - self.widget.after_cancel(self.show_timer) - self.show_timer = None - - def show(self): - """显示工具提示""" - if not self.text or self.tooltip_window: - return - - # 创建顶层窗口 - self.tooltip_window = tw = tk.Toplevel(self.widget) - tw.wm_overrideredirect(True) - - # 计算位置 - x = self.widget.winfo_pointerx() + 10 - y = self.widget.winfo_pointery() + 10 - tw.wm_geometry(f"+{x}+{y}") - - # 创建标签 - # 尝试使用微软雅黑字体,如果不可用则使用系统默认字体 - try: - label_font = ("微软雅黑", 9) - except: - label_font = ("Arial", 9) - - label = tk.Label( - tw, - text=self.text, - justify=tk.LEFT, - background="#ffffe0", - foreground="#000000", - relief=tk.SOLID, - borderwidth=1, - wraplength=self.wraplength, - font=label_font, - padx=5, - pady=3 - ) - label.pack() - - def hide(self): - """隐藏工具提示""" - if self.tooltip_window: - self.tooltip_window.destroy() - self.tooltip_window = None - - def update_text(self, text): - """更新提示文本""" - self.text = text - - -def create_treeview_tooltip(tree, get_tooltip_text_func): - """ - 为Treeview创建鼠标悬停提示 - - Args: - tree: Treeview控件 - get_tooltip_text_func: 获取提示文本的函数,接收(item_id, values, tags)参数 - - Returns: - 绑定的事件ID - """ - tooltip_window = None - last_item = None - show_timer = None - - def hide_tooltip(): - """隐藏tooltip窗口""" - nonlocal tooltip_window - if tooltip_window: - tooltip_window.destroy() - tooltip_window = None - - def show_tooltip(item, tooltip_text, x, y): - """显示tooltip窗口""" - nonlocal tooltip_window - - hide_tooltip() - - # 创建提示窗口 - tooltip_window = tw = tk.Toplevel(tree) - tw.wm_overrideredirect(True) - tw.wm_geometry(f"+{x}+{y}") - - # 创建标签 - # 尝试使用微软雅黑字体,如果不可用则使用系统默认字体 - try: - label_font = ("微软雅黑", 9) - except: - label_font = ("Arial", 9) - - label = tk.Label( - tw, - text=tooltip_text, - justify=tk.LEFT, - background="#ffffe0", - foreground="#000000", - relief=tk.SOLID, - borderwidth=1, - wraplength=400, - font=label_font, - padx=5, - pady=3 - ) - label.pack() - - def on_motion(event): - nonlocal last_item, show_timer - - # 获取鼠标所在的行 - item = tree.identify_row(event.y) - - # 如果移动到不同的行,取消之前的定时器 - if item != last_item: - if show_timer: - tree.after_cancel(show_timer) - show_timer = None - hide_tooltip() - last_item = item - - if not item: - return - - # 获取行信息 - values = tree.item(item, 'values') - tags = tree.item(item, 'tags') - - if not values: - return - - # 获取提示文本 - tooltip_text = get_tooltip_text_func(item, values, tags) - if not tooltip_text: - return - - # 如果还没有定时器,设置一个 - if not show_timer: - x = event.x_root + 10 - y = event.y_root + 10 - show_timer = tree.after( - 800, # 延迟800ms - lambda: show_tooltip(item, tooltip_text, x, y) - ) - - def on_leave(event): - nonlocal show_timer, last_item - if show_timer: - tree.after_cancel(show_timer) - show_timer = None - hide_tooltip() - last_item = None - - # 绑定事件 - tree.bind('', on_motion) - tree.bind('', on_leave) - - return on_motion, on_leave diff --git a/src/tools/unified_logger.py b/src/tools/unified_logger.py new file mode 100644 index 0000000..0d6e559 --- /dev/null +++ b/src/tools/unified_logger.py @@ -0,0 +1,543 @@ +""" +Unified Logger - 统一日志模块 + +标准化日志的格式、级别与输出位置,支持后端与前端日志的统一收集。 + +设计要点: +- 统一字段:timestamp, level, module, message, source, trace_id, extra +- 统一级别:DEBUG, INFO, WARN, ERROR, FATAL +- Handler 机制:文件、内存缓冲、WebSocket 广播、外部转发器(预留) +- 线程安全,支持同步调用 +- 自身错误通过 print 降级,避免循环日志 +""" + +from __future__ import annotations + +import json +import threading +import traceback +from collections import deque +from dataclasses import asdict, dataclass +from datetime import datetime +from enum import Enum +from logging.handlers import RotatingFileHandler +from typing import Any, Callable, Dict, List, Optional, Protocol + + +class LogLevel(str, Enum): + """标准日志级别。""" + + DEBUG = "DEBUG" + INFO = "INFO" + WARN = "WARN" + ERROR = "ERROR" + FATAL = "FATAL" + + +@dataclass +class LogEntry: + """标准日志条目。""" + + timestamp: str + level: str + module: str + message: str + source: str = "backend" + trace_id: Optional[str] = None + extra: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + d = asdict(self) + if d.get("extra") is None: + d.pop("extra", None) + if d.get("trace_id") is None: + d.pop("trace_id", None) + return d + + +class LogHandler(Protocol): + """日志处理器协议。""" + + def emit(self, entry: LogEntry) -> None: + ... + + def close(self) -> None: + ... + + +class FileHandler: + """ + 文件日志处理器。 + + 使用 RotatingFileHandler 按大小滚动,默认单个文件 10MB,保留 5 个备份。 + """ + + def __init__( + self, + log_file: str = "debug.log", + max_bytes: int = 10 * 1024 * 1024, + backup_count: int = 5, + ): + self.log_file = log_file + self.max_bytes = max_bytes + self.backup_count = backup_count + self._lock = threading.Lock() + self._handler: Optional[RotatingFileHandler] = None + self._ensure_handler() + + def _ensure_handler(self) -> None: + if self._handler is not None: + return + try: + self._handler = RotatingFileHandler( + self.log_file, + maxBytes=self.max_bytes, + backupCount=self.backup_count, + encoding="utf-8", + ) + self._handler.setFormatter( + self._formatter() + ) + except Exception as e: + print(f"[UnifiedLogger.FileHandler] 初始化失败: {e}") + + @staticmethod + def _formatter() -> Any: + """返回标准文本格式。""" + from logging import Formatter + return Formatter( + fmt="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + + def emit(self, entry: LogEntry) -> None: + if self._handler is None: + return + try: + # 模拟 logging.LogRecord + from logging import LogRecord, getLogger + logger = getLogger(entry.module) + record = LogRecord( + name=entry.module, + level=self._level_to_logging(entry.level), + pathname="", + lineno=0, + msg=self._format_message(entry), + args=(), + exc_info=None, + ) + record.created = datetime.fromisoformat(entry.timestamp).timestamp() + with self._lock: + self._handler.emit(record) + self._handler.flush() + except Exception as e: + print(f"[UnifiedLogger.FileHandler] 写入失败: {e}") + + def _format_message(self, entry: LogEntry) -> str: + lines = [entry.message] + if entry.source: + lines.append(f" source: {entry.source}") + if entry.trace_id: + lines.append(f" trace_id: {entry.trace_id}") + if entry.extra: + try: + extra_str = json.dumps(entry.extra, ensure_ascii=False, indent=2) + lines.append(f" extra: {extra_str}") + except Exception: + lines.append(f" extra: {str(entry.extra)}") + return "\n".join(lines) + + @staticmethod + def _level_to_logging(level: str) -> int: + from logging import DEBUG, ERROR, FATAL, INFO, WARN + mapping = { + "DEBUG": DEBUG, + "INFO": INFO, + "WARN": WARN, + "ERROR": ERROR, + "FATAL": FATAL, + } + return mapping.get(level.upper(), INFO) + + def close(self) -> None: + if self._handler is not None: + try: + self._handler.close() + except Exception: + pass + self._handler = None + + +class MemoryBufferHandler: + """ + 内存缓冲处理器。 + + 使用双端队列保存最近 N 条日志,供 REST API 查询。 + """ + + def __init__(self, max_size: int = 5000): + self.max_size = max_size + self._buffer: deque = deque(maxlen=max_size) + self._lock = threading.Lock() + + def emit(self, entry: LogEntry) -> None: + with self._lock: + self._buffer.append(entry.to_dict()) + + def get_buffer( + self, + module: Optional[str] = None, + level: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + limit: int = 200, + source: Optional[str] = None, + ) -> List[Dict[str, Any]]: + with self._lock: + items = list(self._buffer) + + lvl = (level or "").strip().upper() or None + mod = (module or "").strip() or None + src = (source or "").strip() or None + out: List[Dict[str, Any]] = [] + + for item in items: + if not isinstance(item, dict): + continue + if lvl and str(item.get("level", "")).upper() != lvl: + continue + if mod and mod.lower() not in str(item.get("module", "")).lower(): + continue + if src and str(item.get("source", "")).lower() != src.lower(): + continue + ts = item.get("timestamp") + if isinstance(ts, str): + if start and ts < start: + continue + if end and ts > end: + continue + out.append(item) + + if len(out) > limit: + out = out[-limit:] + return out + + def clear(self) -> None: + with self._lock: + self._buffer.clear() + + def close(self) -> None: + self.clear() + + +class WebSocketBroadcastHandler: + """ + WebSocket 广播处理器。 + + 将日志广播到 /ws/debug 的订阅者。通过 ConnectionManager 实现。 + 由于 UnifiedLogger 是全局单例,而 ConnectionManager 在 Web 启动后才可用, + 因此采用延迟绑定:启动时调用 set_broadcast_callback() 注入回调。 + """ + + def __init__(self): + self._broadcast: Optional[Callable[[str, Dict[str, Any]], Any]] = None + self._lock = threading.Lock() + + def set_broadcast_callback( + self, callback: Callable[[str, Dict[str, Any]], Any] + ) -> None: + with self._lock: + self._broadcast = callback + + def emit(self, entry: LogEntry) -> None: + with self._lock: + broadcast = self._broadcast + if broadcast is None: + return + try: + broadcast("debug", {"type": "log", "entry": entry.to_dict()}) + except Exception as e: + print(f"[UnifiedLogger.WebSocketBroadcastHandler] 广播失败: {e}") + + def close(self) -> None: + with self._lock: + self._broadcast = None + + +class ExternalForwarderHandler: + """ + 外部日志服务转发器(预留)。 + + 通过环境变量 UNIFIED_LOGGER_FORWARDER_URL 配置外部 HTTP endpoint, + 批量转发日志。当前版本仅保存 URL 与阈值,未来可实现异步发送。 + """ + + def __init__(self, endpoint: Optional[str] = None, batch_size: int = 100): + self.endpoint = endpoint + self.batch_size = batch_size + self._buffer: List[Dict[str, Any]] = [] + self._lock = threading.Lock() + + def emit(self, entry: LogEntry) -> None: + if not self.endpoint: + return + with self._lock: + self._buffer.append(entry.to_dict()) + if len(self._buffer) >= self.batch_size: + self._flush_locked() + + def _flush_locked(self) -> None: + if not self._buffer: + return + batch = self._buffer[:] + self._buffer.clear() + # 异步发送由未来版本实现;当前避免阻塞主流程 + threading.Thread(target=self._send, args=(batch,), daemon=True).start() + + def _send(self, batch: List[Dict[str, Any]]) -> None: + try: + import urllib.request + body = json.dumps({"logs": batch}, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + self.endpoint, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + except Exception as e: + print(f"[UnifiedLogger.ExternalForwarderHandler] 转发失败: {e}") + + def close(self) -> None: + with self._lock: + self._flush_locked() + + +class UnifiedLogger: + """ + 统一日志记录器。 + + 提供标准字段、标准级别、多 Handler 输出的日志服务。 + 支持后端与前端的日志统一收集。 + """ + + def __init__( + self, + log_file: str = "debug.log", + debug_mode: bool = False, + memory_size: int = 5000, + ): + self.debug_mode = debug_mode + self._handlers: List[LogHandler] = [] + self._lock = threading.Lock() + + # 默认 Handler + self._file_handler = FileHandler(log_file=log_file) + self._memory_handler = MemoryBufferHandler(max_size=memory_size) + self._ws_handler = WebSocketBroadcastHandler() + + self.add_handler(self._file_handler) + self.add_handler(self._memory_handler) + self.add_handler(self._ws_handler) + + # 外部转发器:仅当环境变量存在时启用 + import os + forwarder_url = os.getenv("UNIFIED_LOGGER_FORWARDER_URL") + if forwarder_url: + self._forwarder = ExternalForwarderHandler(endpoint=forwarder_url) + self.add_handler(self._forwarder) + else: + self._forwarder = None + + def add_handler(self, handler: LogHandler) -> None: + with self._lock: + self._handlers.append(handler) + + def remove_handler(self, handler: LogHandler) -> None: + with self._lock: + try: + self._handlers.remove(handler) + except ValueError: + pass + + def set_websocket_broadcast( + self, callback: Callable[[str, Dict[str, Any]], Any] + ) -> None: + """注入 WebSocket 广播回调。""" + self._ws_handler.set_broadcast_callback(callback) + + def log( + self, + level: str, + module: str, + message: str, + source: str = "backend", + trace_id: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> None: + """ + 记录一条标准日志。 + + Args: + level: 日志级别 DEBUG/INFO/WARN/ERROR/FATAL + module: 模块名 + message: 日志消息 + source: 来源 backend/frontend + trace_id: 可选追踪 ID + extra: 可选结构化附加数据 + """ + try: + entry = LogEntry( + timestamp=datetime.now().isoformat(), + level=self._normalize_level(level), + module=module or "app", + message=str(message) if message is not None else "", + source=source or "backend", + trace_id=trace_id, + extra=extra, + ) + + # DEBUG 级别在 debug_mode=False 时是否输出? + # 统一日志默认始终记录 INFO 及以上;DEBUG 仅在 debug_mode=True 时记录 + if entry.level == LogLevel.DEBUG.value and not self.debug_mode: + return + + with self._lock: + handlers = list(self._handlers) + + for handler in handlers: + try: + handler.emit(entry) + except Exception as e: + print(f"[UnifiedLogger] Handler 失败 ({type(handler).__name__}): {e}") + except Exception as e: + # 自身错误不得再调用自己,避免死循环 + print(f"[UnifiedLogger] 记录日志失败: {e}") + traceback.print_exc() + + def debug( + self, + module: str, + message: str, + trace_id: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + source: str = "backend", + ) -> None: + self.log(LogLevel.DEBUG.value, module, message, source, trace_id, extra) + + def info( + self, + module: str, + message: str, + trace_id: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + source: str = "backend", + ) -> None: + self.log(LogLevel.INFO.value, module, message, source, trace_id, extra) + + def warn( + self, + module: str, + message: str, + trace_id: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + source: str = "backend", + ) -> None: + self.log(LogLevel.WARN.value, module, message, source, trace_id, extra) + + def error( + self, + module: str, + message: str, + trace_id: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + source: str = "backend", + ) -> None: + self.log(LogLevel.ERROR.value, module, message, source, trace_id, extra) + + def fatal( + self, + module: str, + message: str, + trace_id: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + source: str = "backend", + ) -> None: + self.log(LogLevel.FATAL.value, module, message, source, trace_id, extra) + + def get_buffer( + self, + module: Optional[str] = None, + level: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + limit: int = 200, + source: Optional[str] = None, + ) -> List[Dict[str, Any]]: + return self._memory_handler.get_buffer(module, level, start, end, limit, source) + + def clear_buffer(self) -> None: + self._memory_handler.clear() + + def get_statistics(self) -> Dict[str, Any]: + buffer = self.get_buffer(limit=10000) + by_level: Dict[str, int] = {} + by_source: Dict[str, int] = {} + for item in buffer: + lvl = str(item.get("level", "UNKNOWN")).upper() + src = str(item.get("source", "backend")).upper() + by_level[lvl] = by_level.get(lvl, 0) + 1 + by_source[src] = by_source.get(src, 0) + 1 + return { + "total_logs": len(buffer), + "by_level": by_level, + "by_source": by_source, + "debug_mode": self.debug_mode, + "log_file": getattr(self._file_handler, "log_file", "debug.log"), + } + + @staticmethod + def _normalize_level(level: str) -> str: + mapping = { + "debug": "DEBUG", + "info": "INFO", + "warn": "WARN", + "warning": "WARN", + "error": "ERROR", + "fatal": "FATAL", + "critical": "FATAL", + } + return mapping.get(str(level).lower(), str(level).upper()) + + def close(self) -> None: + with self._lock: + handlers = list(self._handlers) + self._handlers.clear() + for handler in handlers: + try: + handler.close() + except Exception: + pass + + +# ===================================================================== +# 全局单例 +# ===================================================================== +_unified_logger_instance: Optional[UnifiedLogger] = None +_unified_logger_lock = threading.Lock() + + +def get_unified_logger( + log_file: str = "debug.log", + debug_mode: bool = False, + reset: bool = False, +) -> UnifiedLogger: + """获取全局 UnifiedLogger 单例。""" + global _unified_logger_instance + with _unified_logger_lock: + if _unified_logger_instance is None or reset: + _unified_logger_instance = UnifiedLogger( + log_file=log_file, + debug_mode=debug_mode, + ) + return _unified_logger_instance diff --git a/src/web/backend/__init__.py b/src/web/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/web/backend/api/__init__.py b/src/web/backend/api/__init__.py new file mode 100644 index 0000000..7eab96c --- /dev/null +++ b/src/web/backend/api/__init__.py @@ -0,0 +1,91 @@ +""" +REST API routers package +聚合 debug / knowledge / schedule 等 router,统一挂载到 FastAPI app。 + +Stage B.5 / C.1 / C.2: 三个核心 CRUD 模块聚合入口。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from fastapi import APIRouter + +# 子模块 routers(用 try/except 保护,避免单个模块失败影响整体导入) +_routers = [] +_loaded = {} + +try: + from src.web.backend.api.debug import router as _debug_router + _routers.append(_debug_router) + _loaded['debug'] = True +except Exception as e: # noqa: BLE001 + _loaded['debug'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('api', f'failed to import debug router: {e}') + except Exception: + pass + +# Stage C.1: knowledge router 不在此聚合(main.py 用 prefix='/api/knowledge' 单独挂载) +# 如果在这里 include,会在没有 prefix 的情况下注册 ""、"/search"、"/{uuid}" 等相对路径, +# 与 /api/knowledge/* 冲突。注释保留以示缘由。 +# try: +# from src.web.backend.api.knowledge import router as _knowledge_router +# _routers.append(_knowledge_router) +# _loaded['knowledge'] = True +# except Exception as e: # noqa: BLE001 +# _loaded['knowledge'] = False +# try: +# from src.tools.debug_logger import get_debug_logger +# get_debug_logger().log_warn('api', f'failed to import knowledge router: {e}') +# except Exception: +# pass + +try: + from src.web.backend.api.schedule import router as _schedule_router + _routers.append(_schedule_router) + _loaded['schedule'] = True +except Exception as e: # noqa: BLE001 + _loaded['schedule'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('api', f'failed to import schedule router: {e}') + except Exception: + pass + +# 额外聚合(与 main.py 中的 include_router 行为一致;不重复挂载则 no-op) +for _name in ('creative', 'database', 'emotion', 'memory'): + try: + mod = __import__(f'src.web.backend.api.{_name}', fromlist=['router']) + _routers.append(mod.router) + _loaded[_name] = True + except Exception as e: # noqa: BLE001 + _loaded[_name] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('api', f'failed to import {_name} router: {e}') + except Exception: + pass + +# 统一 router(保留空 router 以便 main.py 无脑 include_router) +router = APIRouter() +for r in _routers: + try: + router.include_router(r) + except Exception: + pass + + +def get_loaded_status() -> dict: + """返回各子模块加载状态(调试/健康检查用)。""" + return dict(_loaded) + + +__all__ = ["router", "get_loaded_status"] diff --git a/src/web/backend/api/chat_sessions.py b/src/web/backend/api/chat_sessions.py new file mode 100644 index 0000000..f0368b4 --- /dev/null +++ b/src/web/backend/api/chat_sessions.py @@ -0,0 +1,292 @@ +""" +Chat Sessions REST API - v3.1.0 +================================ + +端点(统一前缀 /api/chat/sessions): +- GET / 列出会话(按 updated_at desc) +- POST / 创建新会话 +- GET /{session_id}/messages 拉取会话消息 +- PATCH /{session_id} 重命名 +- DELETE /{session_id} 级联删除 + +设计要点: +1. 数据库访问完全走 ``db_manager.get_chat_session_repository()``。 +2. 永不返回 5xx:所有路由 try/except + 4xx 包装。 +3. 写操作后不主动 touch:add_message / update_title 内部已维护元信息。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import BaseModel, Field + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.core.database_manager import DatabaseManager +except Exception: # noqa: BLE001 + DatabaseManager = None # type: ignore + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def log_warn(self, *args, **kwargs): pass + def log_error(self, *args, **kwargs): pass + return _Stub() + + +router = APIRouter(prefix="/api/chat/sessions", tags=["chat-sessions"]) +debug_logger = get_debug_logger() + +# 进程内单例 db_manager +_db: Optional[DatabaseManager] = None # type: ignore + + +def _get_db() -> Any: + global _db + if _db is None: + if DatabaseManager is None: + raise HTTPException(status_code=503, detail="DatabaseManager unavailable") + _db = DatabaseManager() + return _db + + +def _get_repo(): + return _get_db().get_chat_session_repository() + + +# ---------------------------------------------------------------------- +# Pydantic schemas +# ---------------------------------------------------------------------- +class CreateSessionRequest(BaseModel): + user_id: str = Field(..., min_length=1, max_length=128, description="归属用户 ID") + title: Optional[str] = Field(default="新会话", max_length=200) + + +class RenameSessionRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + + +class SessionResponse(BaseModel): + id: int + user_id: str + title: str + created_at: float + updated_at: float + message_count: int = 0 + + +class MessageResponse(BaseModel): + id: int + session_id: int + role: str + content: str + emotion_json: Optional[str] = None + conn_id: Optional[str] = None + created_at: float + + +class MessageListResponse(BaseModel): + items: List[MessageResponse] + total: int + + +# ---------------------------------------------------------------------- +# GET /api/chat/sessions +# ---------------------------------------------------------------------- +@router.get("", response_model=None) +async def list_sessions( + user_id: str = Query(default="default", min_length=1, max_length=128), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> Dict[str, Any]: + """ + 列出用户的会话(按 updated_at 倒序)。 + """ + try: + repo = _get_repo() + items = repo.list(user_id=user_id, limit=limit, offset=offset) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.chat_sessions', f'list 出错: {exc}', exc) + except Exception: + pass + return {"items": [], "total": 0, "error": str(exc)} + + return { + "items": [dict(it) for it in (items or [])], + "total": len(items or []), + } + + +# ---------------------------------------------------------------------- +# POST /api/chat/sessions +# ---------------------------------------------------------------------- +@router.post("", response_model=SessionResponse) +async def create_session(req: CreateSessionRequest) -> SessionResponse: + """ + 创建新会话。 + """ + try: + repo = _get_repo() + new_id = repo.create(user_id=req.user_id, title=req.title or "新会话") + row = repo.get(new_id) or {} + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.chat_sessions', f'create 出错: {exc}', exc) + except Exception: + pass + raise HTTPException(status_code=400, detail=f"create failed: {exc}") + + return SessionResponse( + id=int(row.get("id", 0) or 0), + user_id=str(row.get("user_id", req.user_id)), + title=str(row.get("title", req.title or "新会话")), + created_at=float(row.get("created_at", 0.0) or 0.0), + updated_at=float(row.get("updated_at", 0.0) or 0.0), + message_count=int(row.get("message_count", 0) or 0), + ) + + +# ---------------------------------------------------------------------- +# GET /api/chat/sessions/current +# ---------------------------------------------------------------------- +@router.get("/current", response_model=Optional[SessionResponse]) +async def get_current_session( + user_id: str = Query(default="default", min_length=1, max_length=128), +) -> Optional[SessionResponse]: + """ + 取指定 user_id 的最近一个活跃 session(按 updated_at 倒序的第一条)。 + """ + try: + repo = _get_repo() + items = repo.list(user_id=user_id, limit=1, offset=0) + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.chat_sessions', f'current 出错: {exc}', exc) + except Exception: + pass + return None + if not items: + return None + row = items[0] + return SessionResponse( + id=int(row.get("id", 0) or 0), + user_id=str(row.get("user_id", user_id)), + title=str(row.get("title", "新会话")), + created_at=float(row.get("created_at", 0.0) or 0.0), + updated_at=float(row.get("updated_at", 0.0) or 0.0), + message_count=int(row.get("message_count", 0) or 0), + ) + + +# ---------------------------------------------------------------------- +# GET /api/chat/sessions/{session_id}/messages +# ---------------------------------------------------------------------- +@router.get("/{session_id}/messages", response_model=MessageListResponse) +async def list_messages( + session_id: int, + limit: int = Query(default=200, ge=1, le=1000), +) -> MessageListResponse: + """ + 拉取会话消息(按 id 升序)。 + """ + try: + repo = _get_repo() + if repo.get(session_id) is None: + raise HTTPException(status_code=404, detail="session not found") + items = repo.list_messages(session_id=session_id, limit=limit) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.chat_sessions', f'list_messages 出错: {exc}', exc) + except Exception: + pass + return MessageListResponse(items=[], total=0) + + return MessageListResponse( + items=[MessageResponse( + id=int(it.get("id", 0) or 0), + session_id=int(it.get("session_id", session_id) or 0), + role=str(it.get("role", "")), + content=str(it.get("content", "")), + emotion_json=it.get("emotion_json"), + conn_id=it.get("conn_id"), + created_at=float(it.get("created_at", 0.0) or 0.0), + ) for it in (items or [])], + total=len(items or []), + ) + + +# ---------------------------------------------------------------------- +# PATCH /api/chat/sessions/{session_id} +# ---------------------------------------------------------------------- +@router.patch("/{session_id}", response_model=SessionResponse) +async def rename_session(session_id: int, req: RenameSessionRequest) -> SessionResponse: + """ + 重命名会话。 + """ + try: + repo = _get_repo() + if repo.get(session_id) is None: + raise HTTPException(status_code=404, detail="session not found") + ok = repo.update_title(session_id=session_id, title=req.title) + if not ok: + raise HTTPException(status_code=400, detail="update failed") + row = repo.get(session_id) or {} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.chat_sessions', f'rename 出错: {exc}', exc) + except Exception: + pass + raise HTTPException(status_code=400, detail=f"rename failed: {exc}") + + return SessionResponse( + id=int(row.get("id", session_id) or 0), + user_id=str(row.get("user_id", "")), + title=str(row.get("title", req.title)), + created_at=float(row.get("created_at", 0.0) or 0.0), + updated_at=float(row.get("updated_at", 0.0) or 0.0), + message_count=int(row.get("message_count", 0) or 0), + ) + + +# ---------------------------------------------------------------------- +# DELETE /api/chat/sessions/{session_id} +# ---------------------------------------------------------------------- +@router.delete("/{session_id}", status_code=204) +async def delete_session(session_id: int) -> Response: + """ + 删除会话(依赖 ON DELETE CASCADE 删 messages)。 + """ + try: + repo = _get_repo() + if repo.get(session_id) is None: + raise HTTPException(status_code=404, detail="session not found") + repo.delete(session_id=session_id) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.chat_sessions', f'delete 出错: {exc}', exc) + except Exception: + pass + raise HTTPException(status_code=400, detail=f"delete failed: {exc}") + return Response(status_code=204) + + +__all__ = ["router"] diff --git a/src/web/backend/api/creative.py b/src/web/backend/api/creative.py new file mode 100644 index 0000000..db3b944 --- /dev/null +++ b/src/web/backend/api/creative.py @@ -0,0 +1,617 @@ +""" +Creative REST API router - Stage C.6 +长期创作项目的 REST API 路由。 + +端点: +- GET /api/creative/projects 列出创作项目 +- GET /api/creative/projects/{uuid} 项目详情(含 Story Bible) +- POST /api/creative/projects 立项 +- POST /api/creative/projects/{uuid}/advance 续写(默认异步) +- DELETE /api/creative/projects/{uuid} 删除项目 + +设计要点: +1. 全部走 ``src.web.backend.services.database_service``; + 不直接 import DatabaseManager。 +2. 所有端点 try/except 保护:单次失败 → 4xx,绝不抛 5xx。 +3. 续写默认走异步模式:立即返回 task_id,并通过 + ``src.core.event_manager.get_event_manager().publish('creative_progress', ...)`` + 把进度推给前端(EventService 已在 main.py 启动时订阅通配,事件会 + 自动转发到 /ws/events)。 +4. 同步模式(``advance_async=False``):直接更新 last_advanced_at + 字段,不调用 LLM,避免阻塞 HTTP 请求。 +""" + +from __future__ import annotations + +import json +import sys +import uuid as _uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from fastapi import APIRouter, HTTPException, Query, status # noqa: E402 + +# Schemas +from src.web.backend.schemas.creative import ( # noqa: E402 + AdvanceRequest, + AdvanceResponse, + CreativeProject, + CreativeProjectCreate, + CreativeProjectListResponse, + StoryBible, +) + +# Service layer +from src.web.backend.services.database_service import database_service # noqa: E402 + +# 调试日志(try/except 保护:debug_logger 不可用时降级为 print) +try: + from src.tools.debug_logger import get_debug_logger as _get_debug_logger + _debug_logger = _get_debug_logger() +except Exception: # noqa: BLE001 + class _StubLogger: + def log_info(self, *a, **k): pass + def log_warn(self, *a, **k): pass + def log_error(self, *a, **k): pass + _debug_logger = _StubLogger() + + +# EventManager 句柄(懒加载;publish 失败不影响主流程) +_event_manager = None + + +def _get_event_manager(): + """懒加载 EventManager 单例。""" + global _event_manager + if _event_manager is not None: + return _event_manager + try: + from src.core.event_manager import get_event_manager + _event_manager = get_event_manager() + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_error('creative_api', + f'获取 EventManager 失败: {e}', e) + except Exception: + pass + _event_manager = None + return _event_manager + + +router = APIRouter(prefix="/api/creative", tags=["creative"]) + + +# ===================================================================== +# 工具:行 → CreativeProject 转换 +# ===================================================================== + + +def _safe_json_loads(raw: Any, default: Any) -> Any: + """安全解析 JSON 字符串,失败时返回 default。""" + if raw is None or raw == "": + return default + if isinstance(raw, (list, dict)): + return raw + try: + return json.loads(raw) + except Exception: + return default + + +def _row_to_creative_project(row: Dict[str, Any], + include_story_bible: bool = False) -> Dict[str, Any]: + """ + 把数据库行 dict 转成 CreativeProject 兼容 dict。 + + 关键 JSON 字段(outline_json / characters_json / draft_chunks_json) + 会反序列化为 list。 + """ + if not isinstance(row, dict): + return {} + + project = dict(row) # copy + + # JSON 字段反序列化 + for json_k, plain_k in ( + ('outline_json', 'outline'), + ('characters_json', 'characters'), + ('draft_chunks_json', 'draft_chunks'), + ): + if json_k in project and plain_k not in project: + project[plain_k] = _safe_json_loads(project.get(json_k), []) + + # 兼容字段:word_count(前端使用) + try: + project['word_count'] = int(project.get('current_chars') or 0) + except (TypeError, ValueError): + project['word_count'] = 0 + + # chapter_count:从 draft_chunks 派生 + try: + chunks = project.get('draft_chunks') or [] + if not isinstance(chunks, list): + chunks = [] + project['chapter_count'] = len(chunks) + except Exception: + project['chapter_count'] = 0 + + # 默认值兜底 + project.setdefault('uuid', '') + project.setdefault('title', '未命名') + project.setdefault('status', 'drafting') + project.setdefault('current_chars', 0) + project.setdefault('target_chars', 5000) + project.setdefault('work_type', '短篇') + project.setdefault('premise', '') + project.setdefault('tone', '') + project.setdefault('point_of_view', '第一人称') + project.setdefault('inspiration_source', '') + project.setdefault('outline', []) + project.setdefault('characters', []) + project.setdefault('draft_chunks', []) + project.setdefault('created_at', '') + project.setdefault('updated_at', '') + project.setdefault('next_advance_at', None) + project.setdefault('last_advanced_at', None) + + # Story Bible:仅在详情场景下填充 + if include_story_bible: + try: + bible_row = database_service.get_story_bible(project.get('uuid', '')) + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_warn( + 'creative_api', + f'get_story_bible 失败: {e}', + ) + except Exception: + pass + bible_row = None + bible_data: Dict[str, Any] = {} + if isinstance(bible_row, dict): + bible_data = { + 'mainline_direction': bible_row.get('mainline_direction', ''), + 'active_themes': bible_row.get('active_themes') or [], + 'unresolved_threads': bible_row.get('unresolved_threads') or [], + 'resolved_threads': bible_row.get('resolved_threads') or [], + 'important_facts': bible_row.get('important_facts') or [], + 'next_direction': bible_row.get('next_direction', ''), + 'recent_keywords': bible_row.get('recent_keywords') or [], + 'characters': project.get('characters') or [], + 'worldview': '', + 'chapters': project.get('draft_chunks') or [], + } + else: + bible_data = { + 'mainline_direction': '', + 'active_themes': [], + 'unresolved_threads': [], + 'resolved_threads': [], + 'important_facts': [], + 'next_direction': '', + 'recent_keywords': [], + 'characters': project.get('characters') or [], + 'worldview': '', + 'chapters': project.get('draft_chunks') or [], + } + try: + project['story_bible'] = StoryBible(**bible_data) + except Exception: + project['story_bible'] = None + else: + project['story_bible'] = None + + return project + + +def _publish_creative_progress(project_uuid: str, + status_name: str, + message: str, + extra: Optional[Dict[str, Any]] = None) -> bool: + """ + 向 EventManager 推送 creative_progress 事件。 + 返回是否成功调度(不阻塞主流程)。 + """ + em = _get_event_manager() + if em is None: + return False + payload: Dict[str, Any] = { + 'project_uuid': project_uuid, + 'status': status_name, + 'message': message, + 'timestamp': datetime.now().isoformat(), + } + if extra: + try: + payload.update(dict(extra)) + except Exception: + pass + try: + em.publish('creative_progress', payload) + return True + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_warn( + 'creative_api', f'publish creative_progress 失败: {e}') + except Exception: + pass + return False + + +# ===================================================================== +# 路由 +# ===================================================================== + + +@router.get( + "/projects", + response_model=CreativeProjectListResponse, + summary="列出创作项目", + description="按 status 过滤列出 creative_projects 记录。", +) +async def list_projects( + status: Optional[str] = Query( + None, + description="按 status 过滤(如 active / paused / completed / drafting / finished)", + ), + limit: int = Query(50, ge=1, le=200, description="返回上限(1-200)"), +) -> CreativeProjectListResponse: + """ + GET /api/creative/projects?status=...&limit=... + + 始终返回 200;DB 不可用 / 表缺失时返回空列表。 + """ + try: + rows = database_service.list_creative_projects( + status=status, limit=limit, + ) + projects: List[Dict[str, Any]] = [] + for row in rows or []: + try: + projects.append(_row_to_creative_project(row)) + except Exception: + continue + return CreativeProjectListResponse( + projects=[CreativeProject(**p) for p in projects], # type: ignore[arg-type] + total=len(projects), + ) + except HTTPException: + raise + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_error( + 'creative_api', f'list_projects 失败: {e}', e) + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"list projects failed: {e}", + ) + + +@router.get( + "/projects/{project_uuid}", + response_model=CreativeProject, + summary="项目详情", + description="按 uuid 读取项目详情,并附 Story Bible。", + responses={404: {"description": "项目不存在"}}, +) +async def get_project(project_uuid: str) -> CreativeProject: + """ + GET /api/creative/projects/{uuid} + + 404 当项目不存在;500 当 DB 异常。 + """ + if not project_uuid or not isinstance(project_uuid, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="project_uuid is required", + ) + try: + row = database_service.get_creative_project(project_uuid) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"project not found: {project_uuid}", + ) + project_dict = _row_to_creative_project(row, include_story_bible=True) + return CreativeProject(**project_dict) # type: ignore[arg-type] + except HTTPException: + raise + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_error( + 'creative_api', f'get_project({project_uuid}) 失败: {e}', e) + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"get project failed: {e}", + ) + + +@router.post( + "/projects", + response_model=CreativeProject, + status_code=status.HTTP_201_CREATED, + summary="立项", + description="创建一条 creative_projects 记录。", + responses={400: {"description": "请求参数不合法"}}, +) +async def create_project(payload: CreativeProjectCreate) -> CreativeProject: + """ + POST /api/creative/projects + + body: CreativeProjectCreate + returns: 201 + CreativeProject + """ + if not payload or not payload.title: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="title is required", + ) + try: + new_uuid = database_service.create_creative_project( + { + 'title': payload.title, + 'work_type': payload.work_type or '短篇', + 'premise': payload.premise or '', + 'tone': payload.tone or '', + 'point_of_view': payload.point_of_view or '第一人称', + 'target_chars': int(payload.target_chars or 5000), + 'current_chars': 0, + 'status': 'active', # 前端默认语义:active = drafting + 'inspiration_source': payload.inspiration_source or '', + 'outline': payload.outline or [], + 'characters': payload.characters or [], + 'draft_chunks': [], + } + ) + if not new_uuid: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="failed to create project (db unavailable or write failed)", + ) + # 重新读取完整记录 + row = database_service.get_creative_project(new_uuid) or { + 'uuid': new_uuid, + 'title': payload.title, + 'work_type': payload.work_type or '短篇', + 'premise': payload.premise or '', + 'tone': payload.tone or '', + 'point_of_view': payload.point_of_view or '第一人称', + 'target_chars': int(payload.target_chars or 5000), + 'current_chars': 0, + 'status': 'active', + 'inspiration_source': payload.inspiration_source or '', + 'outline_json': json.dumps(payload.outline or [], ensure_ascii=False), + 'characters_json': json.dumps(payload.characters or [], ensure_ascii=False), + 'draft_chunks_json': json.dumps([], ensure_ascii=False), + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat(), + } + project_dict = _row_to_creative_project(row, include_story_bible=True) + try: + _debug_logger.log_info( + 'creative_api', 'create_project OK', + {'uuid': new_uuid, 'title': payload.title}, + ) + except Exception: + pass + # 触发 creative_progress 事件(queued) + _publish_creative_progress( + new_uuid, status_name='created', + message=f'项目《{payload.title}》已创建', + extra={'title': payload.title, 'last_advanced_at': None}, + ) + return CreativeProject(**project_dict) # type: ignore[arg-type] + except HTTPException: + raise + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_error( + 'creative_api', f'create_project 失败: {e}', e) + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"create project failed: {e}", + ) + + +@router.post( + "/projects/{project_uuid}/advance", + response_model=AdvanceResponse, + summary="续写", + description=( + "触发一次续写。默认走异步模式:立即返回 task_id," + "通过 'creative_progress' 事件推送进度。" + "将 ``advance_async`` 置为 False 走同步模式:仅更新 last_advanced_at。" + ), + responses={404: {"description": "项目不存在"}}, +) +async def advance_project( + project_uuid: str, + payload: Optional[AdvanceRequest] = None, +) -> AdvanceResponse: + """ + POST /api/creative/projects/{uuid}/advance + + 异步模式(默认): + - 立即返回 202 + task_id + - 后台通过 EventManager.publish('creative_progress', {...}) 推送进度 + - 前端通过 /ws/events 接收事件 + 同步模式(advance_async=False): + - 立即更新 last_advanced_at 字段 + - 返回 200 + AdvanceResponse(status=completed, last_advanced_at=now) + """ + if not project_uuid or not isinstance(project_uuid, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="project_uuid is required", + ) + # 兼容 body 缺省(GET-like 调用) + body = payload or AdvanceRequest() + try: + row = database_service.get_creative_project(project_uuid) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"project not found: {project_uuid}", + ) + current_status = str(row.get('status') or 'drafting') + now_iso = datetime.now().isoformat() + + if not body.advance_async: + # 同步模式:仅更新时间戳;不阻塞主流程 + updated = database_service.update_creative_project( + project_uuid, + { + 'last_advanced_at': now_iso, + 'next_advance_at': now_iso, + }, + ) + if not updated: + # 行不存在或更新失败:依然视为成功(接口幂等) + pass + _publish_creative_progress( + project_uuid, status_name='advanced', + message=f'项目已续写(同步模式)', + extra={'last_advanced_at': now_iso, + 'project_status': current_status}, + ) + return AdvanceResponse( + project_uuid=project_uuid, + task_id="", + status="completed", + last_advanced_at=now_iso, + project_status=current_status, + message="advance completed (sync mode)", + ) + + # 异步模式:生成 task_id,立即返回;通过 EventManager 推送进度 + task_id = str(_uuid.uuid4()) + _publish_creative_progress( + project_uuid, status_name='queued', + message='续写任务已入队', + extra={'task_id': task_id, 'user': body.user, + 'project_status': current_status, + 'last_advanced_at': now_iso}, + ) + # 同步推进:尝试更新 last_advanced_at 作为占位。 + # 真正的 LLM 生成留给后台 Worker;这里仅记录"已入队"。 + try: + database_service.update_creative_project( + project_uuid, + { + 'last_advanced_at': now_iso, + 'next_advance_at': now_iso, + }, + ) + except Exception: + pass + + try: + _debug_logger.log_info( + 'creative_api', 'advance queued', + {'uuid': project_uuid, 'task_id': task_id, + 'user': body.user, 'hint': (body.hint or '')[:80]}, + ) + except Exception: + pass + + return AdvanceResponse( + project_uuid=project_uuid, + task_id=task_id, + status="queued", + last_advanced_at=now_iso, + project_status=current_status, + message=( + "advance task queued; progress will be pushed via " + "'creative_progress' events" + ), + ) + except HTTPException: + raise + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_error( + 'creative_api', f'advance_project({project_uuid}) 失败: {e}', e) + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"advance project failed: {e}", + ) + + +@router.delete( + "/projects/{project_uuid}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除项目", + description="按 uuid 删除 creative_projects 记录(级联删除 Story Bible / Memory Pool)。", + responses={ + 204: {"description": "删除成功"}, + 404: {"description": "项目不存在"}, + }, +) +async def delete_project(project_uuid: str) -> None: + """ + DELETE /api/creative/projects/{uuid} + """ + if not project_uuid or not isinstance(project_uuid, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="project_uuid is required", + ) + try: + # 删除前先校验存在(避免静默成功) + row = database_service.get_creative_project(project_uuid) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"project not found: {project_uuid}", + ) + ok = database_service.delete_record('creative_projects', project_uuid) + if not ok: + # 行级联删除会清掉 bible / pool;但 delete_record 返回 False + # 也可能因为 uuid 不在白名单等异常 → 视为 500 + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="delete failed (db unavailable or write blocked)", + ) + try: + _debug_logger.log_info( + 'creative_api', 'delete_project OK', + {'uuid': project_uuid, 'title': row.get('title')}, + ) + except Exception: + pass + _publish_creative_progress( + project_uuid, status_name='deleted', + message='项目已删除', + extra={'title': row.get('title')}, + ) + # 204 No Content + return None + except HTTPException: + raise + except Exception as e: # noqa: BLE001 + try: + _debug_logger.log_error( + 'creative_api', f'delete_project({project_uuid}) 失败: {e}', e) + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"delete project failed: {e}", + ) + + +__all__ = ["router"] diff --git a/src/web/backend/api/database.py b/src/web/backend/api/database.py new file mode 100644 index 0000000..7c33cb4 --- /dev/null +++ b/src/web/backend/api/database.py @@ -0,0 +1,245 @@ +""" +Database API Router +``/api/database`` 端点 — 业务表查询 / 计数 / 受控删除。 + +Stage C.5: +- 列出白名单中实际存在的业务表 +- 分页查询 + 记录总数 +- 按 uuid 删除(带 ``X-Confirm: true`` 头二次确认) + +设计要点: +1. 复用 ``database_service``:所有 SQL 拼接已由 ``_validate_table`` 防御。 +2. 失败路径 4xx:按硬约束,所有异常路径均返回 4xx(无 5xx)。 +3. 错误响应统一为 ``{"error": "..."}`` 格式(不依赖 FastAPI 默认 detail)。 +4. DELETE 二次确认:必须传 ``X-Confirm: true`` 头;缺失 → 400。 +5. ``app.include_router(database.router)`` 由 main.py 完成。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Optional + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from src.web.backend.services.database_service import ( # noqa: E402 + database_service, +) +from src.web.backend.schemas.database import ( # noqa: E402 + CountResponse, + DeleteResponse, + TableDataResponse, + TablesListResponse, +) + + +router = APIRouter(prefix="/api/database", tags=["database"]) + + +# ---------------------------------------------------------------------- +# 内部:错误响应统一格式 +# ---------------------------------------------------------------------- +def _error_response(status_code: int, message: str) -> JSONResponse: + """ + 统一错误响应格式:``{"error": "..."}``。 + 状态码必须在 4xx 范围内。 + """ + if status_code < 400 or status_code >= 500: + status_code = 400 + return JSONResponse( + status_code=status_code, + content={"error": message}, + ) + + +# ---------------------------------------------------------------------- +# 内部:表名白名单校验包装 +# ---------------------------------------------------------------------- +def _safe_validate_table(table: str) -> str: + """ + 包装 ``database_service._validate_table``: + - 失败时抛 ``ValueError``;调用方负责转成 4xx 响应。 + """ + try: + return database_service._validate_table(table) + except (ValueError, TypeError) as exc: + raise ValueError(f"Table {table!r} not allowed: {exc}") from exc + + +# ---------------------------------------------------------------------- +# 内部:单表 COUNT(*) +# ---------------------------------------------------------------------- +def _count_table_rows(safe_table: str) -> int: + """ + 对已经通过白名单校验的表名执行 ``SELECT COUNT(*)``。 + 任何异常路径均返回 0(不抛出)。 + """ + try: + db = database_service.get_db() + if db is None: + return 0 + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute(f"SELECT COUNT(*) FROM {safe_table}") + row = cur.fetchone() + if row is None: + return 0 + try: + if isinstance(row, dict): + return int(list(row.values())[0]) + return int(row[0]) + except (KeyError, IndexError, TypeError, ValueError): + return 0 + except Exception: # noqa: BLE001 + return 0 + + +# ---------------------------------------------------------------------- +# 1) GET /api/database/tables +# ---------------------------------------------------------------------- +@router.get( + "/tables", + response_model=TablesListResponse, + summary="List whitelisted business tables", +) +async def list_tables() -> Any: + """ + 返回白名单中实际存在的业务表名列表。 + + 响应: + - ``tables``: 表名列表(按字典序) + - ``total``: 表数量 + """ + try: + table_names = database_service.get_tables() or [] + except Exception as exc: # noqa: BLE001 + # 按硬约束:失败路径返回 4xx + return _error_response(400, f"DatabaseService 不可用: {exc}") + + return TablesListResponse(tables=list(table_names), total=len(table_names)) + + +# ---------------------------------------------------------------------- +# 2) GET /api/database/{table}?limit=100&offset=0 +# ---------------------------------------------------------------------- +@router.get( + "/{table}", + response_model=TableDataResponse, + summary="Query rows of a whitelisted table (paginated)", +) +async def query_table( + table: str, + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +) -> Any: + """ + 分页查询指定表的数据。 + + - ``limit``: 1-1000(默认 100) + - ``offset``: >=0(默认 0) + - 表名必须在白名单内;不在白名单 → 400 + - 响应字段:``table`` / ``rows`` / ``total``(本次返回行数) + """ + try: + _safe_validate_table(table) + except ValueError as exc: + return _error_response(400, str(exc)) + + try: + rows = database_service.query_table(table, limit=limit, offset=offset) or [] + except ValueError as exc: + return _error_response(400, str(exc)) + except Exception as exc: # noqa: BLE001 + return _error_response(400, f"查询失败: {exc}") + + return TableDataResponse( + table=table, + rows=list(rows), + total=len(rows), + ) + + +# ---------------------------------------------------------------------- +# 3) GET /api/database/{table}/count +# ---------------------------------------------------------------------- +@router.get( + "/{table}/count", + response_model=CountResponse, + summary="Count total rows of a whitelisted table", +) +async def count_table(table: str) -> Any: + """ + 返回指定表的总记录数(``SELECT COUNT(*) FROM table``)。 + + - 表名必须在白名单内;不在白名单 → 400 + - 响应字段:``table`` / ``total`` + """ + try: + safe_table = _safe_validate_table(table) + except ValueError as exc: + return _error_response(400, str(exc)) + + total = _count_table_rows(safe_table) + return CountResponse(table=table, total=int(total)) + + +# ---------------------------------------------------------------------- +# 4) DELETE /api/database/{table}/{uuid} +# ---------------------------------------------------------------------- +@router.delete( + "/{table}/{uuid}", + response_model=DeleteResponse, + summary="Delete a single row by uuid (requires X-Confirm header)", +) +async def delete_record( + table: str, + uuid: str, + x_confirm: Optional[str] = Header( + None, + alias="X-Confirm", + description="必须传 X-Confirm: true 才执行删除(防误删)", + ), +) -> Any: + """ + 按 uuid 删除指定表的一条记录。 + + - 必须传 ``X-Confirm: true`` 头;缺失或非 true → 400 + - 表名必须在白名单内;不在白名单 → 400 + - uuid 为空 → 400 + - 数据库层未匹配到记录(rowcount == 0)→ 404 + - 响应字段:``table`` / ``uuid`` / ``deleted`` + """ + # 二次确认:必须传 X-Confirm: true + if x_confirm is None or str(x_confirm).strip().lower() != "true": + return _error_response(400, "需要 X-Confirm: true 头确认") + + try: + _safe_validate_table(table) + except ValueError as exc: + return _error_response(400, str(exc)) + + if not uuid or not isinstance(uuid, str): + return _error_response(400, "uuid 不能为空") + + try: + ok = bool(database_service.delete_record(table, uuid)) + except ValueError as exc: + return _error_response(400, str(exc)) + except Exception as exc: # noqa: BLE001 + return _error_response(400, f"删除失败: {exc}") + + if not ok: + # 数据库层未匹配到记录 + return _error_response(404, f"未在 {table} 中找到 uuid={uuid} 的记录") + + return DeleteResponse(table=table, uuid=uuid, deleted=True) + + +__all__ = ["router"] diff --git a/src/web/backend/api/debug.py b/src/web/backend/api/debug.py new file mode 100644 index 0000000..a29194a --- /dev/null +++ b/src/web/backend/api/debug.py @@ -0,0 +1,384 @@ +""" +Debug REST API Router +- /api/debug/logs: 列表查询(带筛选) +- /api/debug/logs/export: 导出(TXT / JSON) +- /api/debug/logs/clear: 清空内存日志(Stage B.5 扩展) +- /api/debug/stats: 日志统计 + +Stage B.5: 与 /ws/debug 互补(WS 实时流,REST 历史筛选/导出)。 + +实现要点: +- 调用 DebugLogger.get_buffer() 读取历史日志;不破坏现有方法签名 +- 失败路径返回空结构(不抛 5xx),与 ws 实时流不冲突 +- 导出用 StreamingResponse 返回 text/plain 或 application/json +""" + +from __future__ import annotations + +import io +import json +import sys +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse, StreamingResponse + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def get_buffer(self, *a, **kw): return [] + def get_logs(self, *a, **kw): return [] + def get_statistics(self): return {'total_logs': 0, 'by_type': {}, 'debug_mode': False, 'log_file': ''} + def clear_logs(self): pass + return _Stub() + +# 兼容:当 DebugLogger 没有 get_buffer 时回退到 get_logs(不破坏现有接口) +def _read_buffer( + module: Optional[str], + level: Optional[str], + start: Optional[str], + end: Optional[str], + limit: int, +) -> List[Dict[str, Any]]: + logger = get_debug_logger() + try: + if hasattr(logger, "get_buffer"): + buf = logger.get_buffer( + module=module, + level=level, + start=start, + end=end, + limit=limit, + ) + return list(buf or []) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[debug_api] get_buffer 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + # 降级:使用 get_logs + 内存端过滤 + try: + raw = logger.get_logs(module_name=module, limit=max(limit, 1000)) or [] + except Exception: + return [] + lvl = (level or "").strip().lower() or None + out: List[Dict[str, Any]] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + if lvl and str(entry.get("type", "")).lower() != lvl: + continue + ts = entry.get("timestamp") + if isinstance(ts, str): + if start and ts < start: + continue + if end and ts > end: + continue + out.append(entry) + if len(out) > limit: + out = out[-limit:] + return out + + +def _normalize_entry(entry: Dict[str, Any]) -> Dict[str, Any]: + """把 DebugLogger 内部字段映射到 DebugLogEntry 标准结构。""" + if not isinstance(entry, dict): + return { + "timestamp": "", + "module": "", + "level": "INFO", + "message": "", + "extra": None, + } + level = str(entry.get("type") or entry.get("level") or "INFO").upper() + module = str(entry.get("module") or "") + timestamp = str(entry.get("timestamp") or "") + + # 聚合 message 字段(不同 log_* 入口字段名不一样) + message = entry.get("message") + if not message: + # 兼容 log_module / log_request / log_response / log_prompt / log_error + if "action" in entry: + message = str(entry.get("action") or "") + details = entry.get("details") + if details: + message = f"{message} | {details}" if message else str(details) + elif "api_url" in entry: + message = f"{entry.get('api_url', '')}" + elif "content" in entry: + message = str(entry.get("content") or "") + else: + message = "" + + # 额外字段(避免结构化信息丢失) + extra: Optional[Dict[str, Any]] = None + for k in ( + "action", + "details", + "api_url", + "payload", + "headers", + "status_code", + "elapsed_time", + "response", + "exception", + "file_info", + "traceback", + "data", + "prompt_type", + "metadata", + ): + if k in entry and entry[k] is not None: + extra = extra or {} + try: + json.dumps(entry[k]) + extra[k] = entry[k] + except (TypeError, ValueError): + extra[k] = str(entry[k]) + + return { + "timestamp": timestamp, + "module": module, + "level": level, + "message": str(message or ""), + "extra": extra, + } + + +router = APIRouter(prefix="/api/debug", tags=["debug"]) + + +@router.get("/logs") +async def list_debug_logs( + module: Optional[str] = Query(default=None, description="按模块名精确匹配"), + level: Optional[str] = Query(default=None, description="按日志级别筛选(INFO/WARN/ERROR/MODULE/...)"), + start: Optional[str] = Query(default=None, description="起始时间(ISO 8601)"), + end: Optional[str] = Query(default=None, description="结束时间(ISO 8601)"), + limit: int = Query(default=200, ge=1, le=2000, description="返回条数上限"), +) -> dict: + """ + 从 DebugLogger 内存缓冲读取历史日志。 + 返回 {logs: [...], total: int}。 + 失败路径:返回空列表(不抛 5xx)。 + """ + try: + raw = _read_buffer(module, level, start, end, limit) + logs = [_normalize_entry(e) for e in raw] + return {"logs": logs, "total": len(logs)} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[debug_api] list_debug_logs 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return {"logs": [], "total": 0} + + +@router.get("/logs/export") +async def export_debug_logs( + format: str = Query(default="txt", pattern="^(txt|json)$"), + module: Optional[str] = Query(default=None), + level: Optional[str] = Query(default=None), + start: Optional[str] = Query(default=None), + end: Optional[str] = Query(default=None), + limit: int = Query(default=2000, ge=1, le=10000), +) -> StreamingResponse: + """ + 导出筛选后的日志为 TXT 或 JSON。 + """ + try: + raw = _read_buffer(module, level, start, end, limit) + normalized = [_normalize_entry(e) for e in raw] + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"导出失败: {exc}") from exc + + if format == "json": + try: + body = json.dumps( + {"logs": normalized, "total": len(normalized)}, + ensure_ascii=False, + indent=2, + ) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=500, detail=f"JSON 序列化失败: {exc}") from exc + return StreamingResponse( + io.BytesIO(body.encode("utf-8")), + media_type="application/json; charset=utf-8", + headers={ + "Content-Disposition": 'attachment; filename="debug-logs.json"', + }, + ) + + # TXT(默认) + lines: List[str] = [] + for entry in normalized: + ts = entry.get("timestamp") or "-" + lvl = entry.get("level") or "INFO" + mod = entry.get("module") or "-" + msg = (entry.get("message") or "").replace("\r", " ").replace("\n", " ") + lines.append(f"[{ts}] [{lvl}] [{mod}] {msg}") + body = "\n".join(lines) + "\n" + return StreamingResponse( + io.BytesIO(body.encode("utf-8")), + media_type="text/plain; charset=utf-8", + headers={ + "Content-Disposition": 'attachment; filename="debug-logs.txt"', + }, + ) + + +@router.post("/logs/clear") +async def clear_debug_logs() -> dict: + """ + 清空 DebugLogger 内存日志(Stage B.5 扩展)。 + """ + try: + logger = get_debug_logger() + if hasattr(logger, "clear_logs"): + logger.clear_logs() + return {"status": "ok", "message": "日志已清空"} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"清空失败: {exc}") from exc + + +@router.get("/stats") +async def get_debug_stats() -> dict: + """ + 日志统计(按 type 聚合)。 + 失败路径:返回零值结构。 + """ + try: + logger = get_debug_logger() + if hasattr(logger, "get_statistics"): + stats = logger.get_statistics() or {} + return { + "total_logs": int(stats.get("total_logs", 0) or 0), + "by_type": dict(stats.get("by_type", {}) or {}), + "debug_mode": bool(stats.get("debug_mode", False)), + "log_file": str(stats.get("log_file", "") or ""), + } + # 降级 + raw = _read_buffer(None, None, None, None, 10000) + by_type: Dict[str, int] = {} + for e in raw: + t = str((e or {}).get("type", "UNKNOWN")).upper() + by_type[t] = by_type.get(t, 0) + 1 + return {"total_logs": len(raw), "by_type": by_type, "debug_mode": False, "log_file": ""} + except Exception as exc: # noqa: BLE001 + return { + "total_logs": 0, + "by_type": {}, + "debug_mode": False, + "log_file": "", + "error": str(exc), + } + + +@router.post("/logs/ingest") +async def ingest_frontend_logs(request: Request) -> JSONResponse: + """ + REST 兜底端点:批量接收前端日志并写入 UnifiedLogger。 + + 请求体: + [{"level":"ERROR","module":"frontend","message":"...", ...}, ...] + + 返回: + {"accepted": N, "rejected": M} + + 异常返回 200 + error 字段,避免前端上报失败时连锁报错。 + """ + try: + body = await request.json() + except Exception as exc: # noqa: BLE001 + return JSONResponse( + status_code=200, + content={"accepted": 0, "rejected": 0, "error": f"invalid json: {exc}"}, + ) + + if not isinstance(body, list): + return JSONResponse( + status_code=200, + content={"accepted": 0, "rejected": 0, "error": "request body must be a list"}, + ) + + # 批量上限 + if len(body) > 200: + body = body[:200] + + accepted = 0 + rejected = 0 + max_message_length = 8192 + + try: + from src.tools.unified_logger import get_unified_logger + unified = get_unified_logger() + except Exception as exc: # noqa: BLE001 + return JSONResponse( + status_code=200, + content={"accepted": 0, "rejected": 0, "error": f"logger unavailable: {exc}"}, + ) + + level_mapping = { + "debug": "DEBUG", + "info": "INFO", + "log": "INFO", + "warn": "WARN", + "warning": "WARN", + "error": "ERROR", + "fatal": "FATAL", + "critical": "FATAL", + } + + for raw in body: + if not isinstance(raw, dict): + rejected += 1 + continue + + level = level_mapping.get(str(raw.get("level")).lower(), str(raw.get("level") or "INFO").upper()) + module = str(raw.get("module") or "frontend") + message = str(raw.get("message") or "") + + if not module or not message: + rejected += 1 + continue + + if len(message) > max_message_length: + message = f"{message[:max_message_length]}...[truncated]" + + extra = raw.get("extra") + if not isinstance(extra, dict): + extra = {} + for key in ("url", "userAgent", "sessionId", "rawLevel"): + val = raw.get(key) + if val is not None: + extra[key] = val + + try: + unified.log( + level=level, + module=module, + message=message, + source="frontend", + trace_id=raw.get("trace_id") or raw.get("sessionId"), + extra=extra, + ) + accepted += 1 + except Exception: # noqa: BLE001 + rejected += 1 + + return JSONResponse(status_code=200, content={"accepted": accepted, "rejected": rejected}) + + +__all__ = ["router"] diff --git a/src/web/backend/api/emotion.py b/src/web/backend/api/emotion.py new file mode 100644 index 0000000..aa3e224 --- /dev/null +++ b/src/web/backend/api/emotion.py @@ -0,0 +1,263 @@ +""" +Emotion API Router +``/api/emotion`` 端点 — Plutchik 8 维情感雷达数据源。 + +Stage B.3: +- GET /api/emotion/latest?user_id=default + 返回 EmotionResponse 形状的 JSON: + { + "cumulative": {8 维累加}, + "plutchik": {8 维当下}, + "last_message": "...", + "timestamp": "ISO 8601", + "user_id": "default", + "historical_max": Optional[8 维] + } +- 失败 / 无数据:按硬约束降级为 200 OK + 全 0 dict + 空字符串 + (不让前端雷达图崩溃)。 +- POST /api/emotion/refresh?user_id=default + 主动失效缓存(用于聊天流式结束后强制刷新)。 + +设计要点: +1. 单点失败不返回 5xx:包装在 try/except,异常路径同样 200 OK + 空 payload。 +2. 复用 emotion_service 单例:避免重复加载 EmotionRelationshipAnalyzer。 +3. Pydantic v2 schema(EmotionResponse)做响应序列化。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Query + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.web.backend.services.emotion_service import emotion_service +except Exception: # noqa: BLE001 + emotion_service = None # type: ignore + +try: + from src.web.backend.schemas.emotion import EmotionResponse +except Exception: # noqa: BLE001 + EmotionResponse = None # type: ignore + +try: + from src.web.backend.services.neo_bridge import neo_request +except Exception: # noqa: BLE001 + neo_request = None # type: ignore + + +router = APIRouter(prefix="/api/emotion", tags=["emotion"]) + + +# ---------------------------------------------------------------------- +# 内部:构造兜底 payload +# ---------------------------------------------------------------------- +def _empty_response(user_id: str = "default") -> Dict[str, Any]: + return { + "cumulative": {k: 0.0 for k in ( + 'joy', 'trust', 'fear', 'surprise', + 'sadness', 'disgust', 'anger', 'anticipation', + )}, + "plutchik": {k: 0.0 for k in ( + 'joy', 'trust', 'fear', 'surprise', + 'sadness', 'disgust', 'anger', 'anticipation', + )}, + "last_message": "", + "timestamp": "", + "user_id": user_id or "default", + "historical_max": None, + } + + +# ---------------------------------------------------------------------- +# 内部:尝试通过 v4 Amygdala 获取情感数据并转换为 v3 格式 +# ---------------------------------------------------------------------- +def _convert_v4_emotion_to_v3(v4_data: Dict[str, Any], user_id: str) -> Dict[str, Any]: + """ + 将 AmygdalaModule.emotion_latest 的返回结构转换为 emotion_service 兼容格式。 + + v4 返回字段:timestamp, additive_scores(0-100), plutchik(0-1), dominant + v3 期望字段:cumulative(0-1), plutchik(0-1), last_message, timestamp, user_id, historical_max + """ + empty = _empty_response(user_id) + if not isinstance(v4_data, dict): + return empty + + plutchik = v4_data.get("plutchik") or {} + if not isinstance(plutchik, dict): + plutchik = {} + + additive = v4_data.get("additive_scores") or {} + if not isinstance(additive, dict): + additive = {} + + # 将 additive_scores (0-100) 归一化为 cumulative (0-1) + cumulative = {} + for k in empty["cumulative"]: + try: + cumulative[k] = round(float(additive.get(k, 0.0)) / 100.0, 4) + except (TypeError, ValueError): + cumulative[k] = 0.0 + + # 确保 plutchik 8 维完整且为 0-1 浮点数 + normalized_plutchik = {} + for k in empty["plutchik"]: + try: + normalized_plutchik[k] = round(float(plutchik.get(k, 0.0)), 4) + except (TypeError, ValueError): + normalized_plutchik[k] = 0.0 + + return { + "cumulative": cumulative, + "plutchik": normalized_plutchik, + "last_message": "", # v4 emotion_latest 不返回 last_message + "timestamp": str(v4_data.get("timestamp") or ""), + "user_id": user_id, + "historical_max": None, + } + + +async def _try_v4_latest_emotion(user_id: str) -> Optional[Dict[str, Any]]: + """ + 通过 neo_request 调用 limbic.amygdala / emotion_latest。 + 成功且返回非空数据时转换为 v3 格式;否则返回 None 让调用方回退。 + """ + if neo_request is None: + return None + + try: + response = await neo_request( + target="limbic.amygdala", + channel="emotion_latest", + payload={"user_id": user_id}, + ) + except Exception: # noqa: BLE001 + return None + + if response is None or response.is_error(): + return None + + v4_data = response.payload + if not isinstance(v4_data, dict) or not v4_data: + return None + + return _convert_v4_emotion_to_v3(v4_data, user_id) + + +# ---------------------------------------------------------------------- +# 1) GET /api/emotion/latest +# ---------------------------------------------------------------------- +@router.get("/latest") +async def get_latest_emotion( + user_id: str = Query( + "default", + description="用户标识(当前所有用户共用情感数据;仅作缓存 key 透传)", + ), +) -> Dict[str, Any]: + """ + 获取最新情感分析结果。 + + 优先尝试 v4 Amygdala;失败或无数据时回退到 emotion_service。 + 失败 / 无数据 → 200 OK + 全 0 payload(前端雷达不崩)。 + """ + uid = (user_id or "default").strip() or "default" + fallback = _empty_response(uid) + + # Phase 6: 优先走 v4 神经系统 + try: + v4_payload = await _try_v4_latest_emotion(uid) + if isinstance(v4_payload, dict) and v4_payload: + if EmotionResponse is not None: + try: + model = EmotionResponse(**v4_payload) + return model.model_dump() + except Exception: + return v4_payload + return v4_payload + except Exception: # noqa: BLE001 + pass + + if emotion_service is None: + return fallback + + try: + payload = emotion_service.get_latest_emotion(uid) + except Exception: # noqa: BLE001 + # 降级:不让前端崩 + return fallback + + if not isinstance(payload, dict): + return fallback + + # 强制覆盖 user_id 透传 + payload["user_id"] = uid + + # 用 Pydantic schema 校验 / 规范化(可序列化) + if EmotionResponse is not None: + try: + model = EmotionResponse(**payload) + return model.model_dump() + except Exception: + # schema 校验失败:仍返回 raw payload(兜底) + return payload + + return payload + + +# ---------------------------------------------------------------------- +# 2) POST /api/emotion/refresh +# ---------------------------------------------------------------------- +@router.post("/refresh") +async def refresh_emotion_cache( + user_id: str = Query( + "default", + description="用户标识;省略则清空所有用户缓存", + ), +) -> Dict[str, Any]: + """ + 主动失效指定用户的情感缓存。 + 下次 GET /api/emotion/latest 会重新构造 payload。 + """ + if emotion_service is None: + return {"cleared": 0, "user_id": user_id or "default"} + try: + emotion_service.cache_clear(user_id if user_id else None) + except Exception: # noqa: BLE001 + return {"cleared": 0, "user_id": user_id or "default"} + return {"cleared": 1, "user_id": user_id or "default"} + + +# ---------------------------------------------------------------------- +# 3) GET /api/emotion/stats +# ---------------------------------------------------------------------- +@router.get("/stats") +async def get_emotion_stats() -> Dict[str, Any]: + """ + 返回情感服务状态(analyzer 是否加载、缓存大小等)。 + """ + if emotion_service is None: + return { + "analyzer_loaded": False, + "wheel_loaded": False, + "cache_size": 0, + "version": "1.0.0", + } + try: + return emotion_service.get_stats() + except Exception: # noqa: BLE001 + return { + "analyzer_loaded": False, + "wheel_loaded": False, + "cache_size": 0, + "version": "1.0.0", + } + + +__all__ = ["router"] diff --git a/src/web/backend/api/event.py b/src/web/backend/api/event.py new file mode 100644 index 0000000..27c41ea --- /dev/null +++ b/src/web/backend/api/event.py @@ -0,0 +1,544 @@ +""" +Event REST API router - Stage C.3 / 验收 P0 3.2 +跨端事件的 REST API 路由(events 表 CRUD)。 + +端点: + GET /api/events 列表(分页 + 筛选) + POST /api/events/{event_id}/read 标记已读 + POST /api/events/{event_id}/archive 归档 + +设计要点: +1. 复用 ``src.core.event_manager.EventManager`` 单例(经 + ``get_event_manager()`` 获取),不走 database_service 通用表查询; + events 表结构复杂、含 metadata JSON 字段,专用 manager 更安全。 +2. 复用 ``src.web.backend.schemas.event.EventDTO`` / ``EventListResponse``; + 字段不匹配部分在本文内通过新建 ``EventDTOExtended``(不改动 EventDTO)补齐。 +3. 列表分页:``limit`` (1-200, 默认 50) + ``offset`` (>=0, 默认 0)。 +4. 筛选:``type`` (event_type) + ``status``。 +5. 读 / 归档直接更新 events 表的 metadata 字段(追加 read_at / archived_at + 时间戳),同时把 status 同步为 ``completed`` / ``cancelled``,确保与 + 现有 EventStatus 枚举兼容。 +6. 所有路由 try/except 包裹,失败 → 4xx + {"error": ...},绝不抛 5xx。 +7. EventManager 不可用时返回空列表(graceful degradation)。 +""" + +from __future__ import annotations + +import json +import sys +import warnings +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from fastapi import APIRouter, HTTPException, Path, Query, status # noqa: E402 +from pydantic import BaseModel, Field # noqa: E402 + +# 复用 EventDTO / EventListResponse(字段对不上时本文内新增,不改 EventDTO) +from src.web.backend.schemas.event import ( # noqa: E402 + EventDTO, + EventListResponse, +) + +# 调试日志(try/except 保护) +try: + from src.tools.debug_logger import get_debug_logger as _get_debug_logger + _debug_logger = _get_debug_logger() +except Exception: # noqa: BLE001 + class _StubLogger: + def log_info(self, *a, **k): pass + def log_warn(self, *a, **k): pass + def log_error(self, *a, **k): pass + _debug_logger = _StubLogger() + + +# EventManager 句柄(懒加载;不直接 import EventManager 类以便降级) +_event_manager: Optional[Any] = None +_EVENT_MANAGER_IMPORT_OK: bool = False +_EVENT_MANAGER_IMPORT_ERROR: Optional[BaseException] = None + +try: + from src.core.event_manager import ( # noqa: E402 + EventManager, + EventStatus, + get_event_manager, + ) + _EVENT_MANAGER_IMPORT_OK = True +except Exception as _exc: # noqa: BLE001 + EventManager = None # type: ignore + EventStatus = None # type: ignore + get_event_manager = None # type: ignore + _EVENT_MANAGER_IMPORT_ERROR = _exc + + +def _get_event_manager() -> Optional[Any]: + """ + 懒加载 EventManager 单例(进程内复用 get_event_manager())。 + 失败时返回 None(路由层兜底)。 + """ + global _event_manager + if _event_manager is not None: + return _event_manager + if not _EVENT_MANAGER_IMPORT_OK or get_event_manager is None: + return None + try: + _event_manager = get_event_manager() + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error( + 'event_api', + f'获取 EventManager 失败: {exc}', + exc, + ) + _event_manager = None + return _event_manager + + +# --------------------------------------------------------------------- +# Pydantic v2 扩展(不复用/不改动 EventDTO;只在本文内新增) +# --------------------------------------------------------------------- +class EventDTOExtended(BaseModel): + """ + 事件 DTO 扩展版:在 EventDTO 基础上补充 title / description / + priority / updated_at / completed_at / read_at / archived_at, + 供前端 EventTable 渲染。 + + Attributes: + uuid: 事件 ID(与 EventDTO.uuid 一致) + type: 事件类型 + status: 事件状态 + payload: 事件 payload(来自 metadata 字段) + created_at: ISO 8601 时间戳 + title: 事件标题 + description: 事件描述 + priority: 事件优先级(int) + updated_at: 最近更新时间 + completed_at: 完成时间(可能为空) + read_at: 标记已读时间(可能为空) + archived_at: 归档时间(可能为空) + """ + uuid: str + type: str + status: str + payload: Dict + created_at: str + title: str = "" + description: str = "" + priority: int = 2 + updated_at: Optional[str] = None + completed_at: Optional[str] = None + read_at: Optional[str] = None + archived_at: Optional[str] = None + + +# --------------------------------------------------------------------- +# 工具:events 表行 → EventDTO / EventDTOExtended 转换 +# --------------------------------------------------------------------- +def _parse_metadata(raw: Any) -> Dict[str, Any]: + """把 metadata 字段(可能是 dict / str)安全解析成 dict。""" + if raw is None or raw == "": + return {} + if isinstance(raw, dict): + return dict(raw) + if isinstance(raw, str): + try: + return dict(json.loads(raw) or {}) + except Exception: # noqa: BLE001 + return {} + return {} + + +def _row_to_extended_dto(row: Dict[str, Any]) -> EventDTOExtended: + """ + 把数据库行(可能是 sqlite3.Row / dict)映射为 EventDTOExtended。 + + 行字段(events 表): + event_id, title, description, event_type, priority, status, + created_at, updated_at, completed_at, metadata + """ + if not isinstance(row, dict): + try: + row = dict(row) + except Exception: + row = {} + meta = _parse_metadata(row.get('metadata')) + return EventDTOExtended( + uuid=str(row.get('event_id') or row.get('uuid') or ''), + type=str(row.get('event_type') or row.get('type') or 'event'), + status=str(row.get('status') or 'pending'), + payload=meta, + created_at=str(row.get('created_at') or ''), + title=str(row.get('title') or ''), + description=str(row.get('description') or ''), + priority=int(row.get('priority') or 2) if str(row.get('priority') or '').strip() else 2, + updated_at=row.get('updated_at'), + completed_at=row.get('completed_at'), + read_at=meta.get('read_at'), + archived_at=meta.get('archived_at'), + ) + + +def _row_to_dto(row: Dict[str, Any]) -> EventDTO: + """ + 把数据库行映射为 EventDTO(用于 EventListResponse)。 + """ + if not isinstance(row, dict): + try: + row = dict(row) + except Exception: + row = {} + meta = _parse_metadata(row.get('metadata')) + return EventDTO( + uuid=str(row.get('event_id') or row.get('uuid') or ''), + type=str(row.get('event_type') or row.get('type') or 'event'), + status=str(row.get('status') or 'pending'), + payload=meta, + created_at=str(row.get('created_at') or ''), + ) + + +def _fail(message: str, code: int = status.HTTP_400_BAD_REQUEST) -> Dict[str, Any]: + """统一兜底响应。""" + return {"error": message, "status_code": code} + + +def _execute_sql(em: Any, sql: str, params: tuple = ()) -> bool: + """ + 通过 EventManager 的 db 句柄直接执行 UPDATE/INSERT SQL。 + 返回是否影响至少一行。 + """ + if em is None or not hasattr(em, 'db') or em.db is None: + return False + try: + with em.db.get_connection() as conn: + cur = conn.execute(sql, params) + affected = cur.rowcount + return bool(affected and affected > 0) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('event_api', f'_execute_sql 失败: {exc}', exc) + return False + + +# --------------------------------------------------------------------- +# 路由 +# --------------------------------------------------------------------- +router = APIRouter(prefix="/api/events", tags=["events"]) + + +# ===================================================================== +# GET /api/events?limit=&offset=&type=&status= +# ===================================================================== +@router.get("") +async def list_events( + limit: int = Query(50, ge=1, le=200, description="返回条数上限"), + offset: int = Query(0, ge=0, description="分页偏移"), + type: Optional[str] = Query( + None, + description="事件类型筛选(如 notification / task)", + ), + status_filter: Optional[str] = Query( + None, + alias="status", + description="事件状态筛选(如 pending / completed / cancelled)", + ), +) -> Dict[str, Any]: + """ + 分页 + 筛选事件列表。 + + Returns: + { + "events": [EventDTO...], + "total": int, + "limit": int, + "offset": int, + "items": [EventDTOExtended...], # 扩展字段供前端 EventTable + "warning": str | None + } + """ + em = _get_event_manager() + if em is None: + return { + "events": [], + "items": [], + "total": 0, + "limit": limit, + "offset": offset, + "warning": "EventManager 不可用:导入或初始化失败", + } + + try: + # EventManager.get_all_events(status, event_type, limit) 不支持 offset; + # 我们走 raw SQL 拿到 offset 段。 + # 先确认 events 表存在;不可用就降级空列表。 + try: + with em.db.get_connection() as conn: + cur = conn.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name='events'", + ) + exists = cur.fetchone() + except Exception: # noqa: BLE001 + exists = None + if not exists: + return { + "events": [], + "items": [], + "total": 0, + "limit": limit, + "offset": offset, + "warning": "events 表未在数据库中创建,当前返回空列表", + } + + # 拼 SQL + where_parts: List[str] = [] + params: List[Any] = [] + if type: + where_parts.append("event_type = ?") + params.append(str(type)) + if status_filter: + where_parts.append("status = ?") + params.append(str(status_filter)) + where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" + + # 取 total + try: + with em.db.get_connection() as conn: + cur = conn.execute( + f"SELECT COUNT(*) FROM events {where_sql}", + tuple(params), + ) + total_row = cur.fetchone() + total = int(total_row[0]) if total_row else 0 + except Exception: # noqa: BLE001 + total = 0 + + # 取 rows + try: + with em.db.get_connection() as conn: + cur = conn.execute( + f"SELECT * FROM events {where_sql} " + f"ORDER BY datetime(created_at) DESC LIMIT ? OFFSET ?", + tuple(params + [limit, offset]), + ) + raw_rows = cur.fetchall() or [] + except Exception as exc: # noqa: BLE001 + _debug_logger.log_warn('event_api', f'查询 events 失败: {exc}') + raw_rows = [] + + # 转 dict 列表 + rows: List[Dict[str, Any]] = [] + for r in raw_rows: + try: + if isinstance(r, dict): + rows.append(dict(r)) + else: + rows.append({k: r[k] for k in r.keys()}) + except Exception: + try: + rows.append(dict(r)) + except Exception: + continue + + return { + "events": [_row_to_dto(r) for r in rows], + "items": [_row_to_extended_dto(r) for r in rows], + "total": total, + "limit": limit, + "offset": offset, + "warning": None, + } + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('event_api', f'list_events 失败: {exc}', exc) + warnings.warn( + f"[event_api] list_events 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return { + "events": [], + "items": [], + "total": 0, + "limit": limit, + "offset": offset, + "error": str(exc), + } + + +# ===================================================================== +# POST /api/events/{event_id}/read +# ===================================================================== +@router.post("/{event_id}/read") +async def mark_event_read( + event_id: str = Path(..., min_length=1, description="事件 ID"), +) -> Dict[str, Any]: + """ + 标记事件已读。 + - 复用 EventManager.update_event_status(status=COMPLETED, log_message=...); + - 同时把 read_at 写入 metadata 字段。 + """ + em = _get_event_manager() + if em is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="EventManager 不可用", + ) + + if not event_id or not event_id.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="event_id 必填", + ) + + try: + # 确认事件存在 + existing = em.get_event(event_id) if hasattr(em, 'get_event') else None + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"未找到事件: {event_id}", + ) + + # 1) 写 read_at 到 metadata + try: + meta_raw = None + with em.db.get_connection() as conn: + cur = conn.execute( + "SELECT metadata FROM events WHERE event_id = ?", + (event_id,), + ) + row = cur.fetchone() + meta_raw = row[0] if row else None + except Exception: # noqa: BLE001 + meta_raw = None + + meta = _parse_metadata(meta_raw) or {} + now_iso = datetime.now().isoformat() + meta['read_at'] = now_iso + + try: + with em.db.get_connection() as conn: + conn.execute( + "UPDATE events SET metadata = ? WHERE event_id = ?", + (json.dumps(meta, ensure_ascii=False), event_id), + ) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_warn('event_api', f'写 read_at 到 metadata 失败: {exc}') + + # 2) 状态切到 completed(不强制:失败也不抛 5xx) + status_ok = False + if hasattr(em, 'update_event_status') and EventStatus is not None: + try: + status_ok = em.update_event_status( + event_id, + EventStatus.COMPLETED, + log_message='marked as read', + ) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_warn('event_api', f'update_event_status 失败: {exc}') + status_ok = False + + return { + "event_id": event_id, + "marked": True, + "read_at": now_iso, + "status": "completed", + "status_updated": status_ok, + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('event_api', f'mark_event_read 失败: {exc}', exc) + return _fail(f"标记已读失败: {exc}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# POST /api/events/{event_id}/archive +# ===================================================================== +@router.post("/{event_id}/archive") +async def archive_event( + event_id: str = Path(..., min_length=1, description="事件 ID"), +) -> Dict[str, Any]: + """ + 归档事件。 + - 复用 EventManager.update_event_status(status=CANCELLED, log_message=...); + - 同时把 archived_at 写入 metadata 字段。 + """ + em = _get_event_manager() + if em is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="EventManager 不可用", + ) + + if not event_id or not event_id.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="event_id 必填", + ) + + try: + existing = em.get_event(event_id) if hasattr(em, 'get_event') else None + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"未找到事件: {event_id}", + ) + + # 1) 写 archived_at 到 metadata + try: + meta_raw = None + with em.db.get_connection() as conn: + cur = conn.execute( + "SELECT metadata FROM events WHERE event_id = ?", + (event_id,), + ) + row = cur.fetchone() + meta_raw = row[0] if row else None + except Exception: # noqa: BLE001 + meta_raw = None + + meta = _parse_metadata(meta_raw) or {} + now_iso = datetime.now().isoformat() + meta['archived_at'] = now_iso + + try: + with em.db.get_connection() as conn: + conn.execute( + "UPDATE events SET metadata = ? WHERE event_id = ?", + (json.dumps(meta, ensure_ascii=False), event_id), + ) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_warn('event_api', f'写 archived_at 到 metadata 失败: {exc}') + + # 2) 状态切到 cancelled + status_ok = False + if hasattr(em, 'update_event_status') and EventStatus is not None: + try: + status_ok = em.update_event_status( + event_id, + EventStatus.CANCELLED, + log_message='archived', + ) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_warn('event_api', f'update_event_status 失败: {exc}') + status_ok = False + + return { + "event_id": event_id, + "archived": True, + "archived_at": now_iso, + "status": "cancelled", + "status_updated": status_ok, + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('event_api', f'archive_event 失败: {exc}', exc) + return _fail(f"归档失败: {exc}", code=status.HTTP_400_BAD_REQUEST) + + +__all__ = ["router"] diff --git a/src/web/backend/api/knowledge.py b/src/web/backend/api/knowledge.py new file mode 100644 index 0000000..5e2070b --- /dev/null +++ b/src/web/backend/api/knowledge.py @@ -0,0 +1,282 @@ +""" +Knowledge REST API router. +知识库 REST API 路由(Stage C.1)。 + +端点: + GET /api/knowledge/search 关键词搜索(?q=...&limit=20) + GET /api/knowledge 列出所有实体(?limit=50) + POST /api/knowledge 新增实体(body 走 KnowledgeCreate 校验) + PUT /api/knowledge/{uuid} 更新实体(body 走 KnowledgeUpdate 校验) + DELETE /api/knowledge/{uuid} 删除实体 + +设计要点: +1. 所有路由都用 try/except 包裹,失败返回 4xx + {"error": str(e)}, + 不冒 5xx(Stage C.1 验证标准)。 +2. 复用现有 DatabaseManager:search_knowledge / get_all_entities / + add_entity / update_entity / delete_entity。 +3. Pydantic v2 schema 校验(KnowledgeCreate / KnowledgeUpdate)在 + FastAPI 入口处自动完成;非法 body 直接 422。 +4. 路径参数 uuid 透传给 db 层;不存在时返回 404(不抛 500)。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from fastapi import APIRouter, Body, HTTPException, Path, Query, status +from fastapi.responses import JSONResponse + +# Pydantic v2 schema(强类型契约) +from src.web.backend.schemas.knowledge import ( + KnowledgeCreate, + KnowledgeUpdate, +) + +# DatabaseManager(懒加载 + try/except 保护) +try: + from src.core.database_manager import DatabaseManager # type: ignore + _DB_IMPORT_OK = True + _DB_IMPORT_ERROR: Optional[BaseException] = None +except Exception as _exc: # noqa: BLE001 + DatabaseManager = None # type: ignore + _DB_IMPORT_OK = False + _DB_IMPORT_ERROR = _exc + + +router = APIRouter() + + +# --------------------------------------------------------------------- +# DatabaseManager 单例(懒加载) +# --------------------------------------------------------------------- +_db_singleton: Optional[Any] = None + + +def _get_db() -> Optional[Any]: + """ + 懒加载 DatabaseManager 单例。 + 失败时返回 None,由路由层兜底为 503。 + """ + global _db_singleton + if _db_singleton is not None: + return _db_singleton + if not _DB_IMPORT_OK or DatabaseManager is None: + return None + try: + _db_singleton = DatabaseManager() + except Exception: # noqa: BLE001 + _db_singleton = None + return _db_singleton + + +# --------------------------------------------------------------------- +# 工具:把 ValueError / 不存在 uuid 等转成 4xx,绝不 5xx +# --------------------------------------------------------------------- +def _fail(message: str, code: int = status.HTTP_400_BAD_REQUEST) -> JSONResponse: + return JSONResponse(status_code=code, content={"error": message}) + + +# ===================================================================== +# GET /api/knowledge/search?q=...&limit=20 +# ===================================================================== +@router.get("/search") +async def search_knowledge( + q: str = Query(..., min_length=1, description="搜索关键词"), + limit: int = Query(20, ge=1, le=200, description="返回条数上限"), +) -> Dict[str, Any]: + """ + 关键词搜索知识库实体(Stage C.1)。 + + Args: + q: 搜索关键词(必填) + limit: 返回上限(默认 20,1-200) + + Returns: + {"results": [...], "total": int} + """ + try: + db = _get_db() + if db is None: + return _fail( + "DatabaseManager 不可用:导入或初始化失败", + code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + results: List[Dict[str, Any]] = db.search_knowledge(query=q, limit=limit) + # 过滤掉非 dict 的脏数据,避免 JSON 序列化失败 + clean: List[Dict[str, Any]] = [r for r in results if isinstance(r, dict)] + return {"results": clean, "total": len(clean)} + except ValueError as e: # noqa: BLE001 + return _fail(f"参数错误: {e}", code=status.HTTP_400_BAD_REQUEST) + except Exception as e: # noqa: BLE001 + return _fail(f"搜索失败: {e}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# GET /api/knowledge?limit=50 +# ===================================================================== +@router.get("") +async def list_knowledge( + limit: int = Query(50, ge=1, le=500, description="返回条数上限"), +) -> Dict[str, Any]: + """ + 列出所有知识库实体(兜底接口,Stage C.1)。 + + 优先复用 DatabaseManager.get_all_entities(limit);如果 db 不可用 + 或返回非列表,兜底为空列表。 + + Returns: + {"results": [...], "total": int} + """ + try: + db = _get_db() + if db is None: + return _fail( + "DatabaseManager 不可用:导入或初始化失败", + code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + rows: List[Dict[str, Any]] = db.get_all_entities(limit=limit) or [] + clean: List[Dict[str, Any]] = [r for r in rows if isinstance(r, dict)] + return {"results": clean, "total": len(clean)} + except ValueError as e: # noqa: BLE001 + return _fail(f"参数错误: {e}", code=status.HTTP_400_BAD_REQUEST) + except Exception as e: # noqa: BLE001 + return _fail(f"列出知识库失败: {e}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# POST /api/knowledge +# ===================================================================== +@router.post("") +async def create_knowledge( + payload: KnowledgeCreate = Body(...), +) -> Dict[str, Any]: + """ + 新增知识库实体(Stage C.1)。 + + Body 通过 KnowledgeCreate (Pydantic v2) 校验。 + + Returns: + {"uuid": str, "entity_name": str, "created": true} + """ + try: + db = _get_db() + if db is None: + return _fail( + "DatabaseManager 不可用:导入或初始化失败", + code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + new_uuid: str = db.add_entity( + entity_name=payload.entity_name, + category=payload.category, + description=payload.description, + related_info=payload.related_info, + ) + if not new_uuid: + return _fail("新增实体失败:未返回 uuid", code=status.HTTP_400_BAD_REQUEST) + + return { + "uuid": new_uuid, + "entity_name": payload.entity_name, + "created": True, + } + except ValueError as e: # noqa: BLE001 + return _fail(f"参数错误: {e}", code=status.HTTP_400_BAD_REQUEST) + except Exception as e: # noqa: BLE001 + return _fail(f"新增实体失败: {e}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# PUT /api/knowledge/{uuid} +# ===================================================================== +@router.put("/{uuid}") +async def update_knowledge( + uuid: str = Path(..., min_length=1, description="实体 uuid"), + payload: KnowledgeUpdate = Body(...), +) -> Dict[str, Any]: + """ + 更新知识库实体(Stage C.1)。 + + Body 通过 KnowledgeUpdate (Pydantic v2) 校验。 + + Returns: + {"uuid": str, "updated": bool} + """ + try: + db = _get_db() + if db is None: + return _fail( + "DatabaseManager 不可用:导入或初始化失败", + code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + # 至少要传一个可更新字段 + update_data = payload.model_dump(exclude_unset=True) + if not update_data: + return _fail("请求体为空:至少要传一个字段", code=status.HTTP_400_BAD_REQUEST) + + ok: bool = db.update_entity( + entity_uuid=uuid, + entity_name=payload.entity_name, + category=payload.category, + description=payload.description, + related_info=payload.related_info, + ) + if not ok: + return _fail( + f"未找到实体或无字段更新: {uuid}", + code=status.HTTP_404_NOT_FOUND, + ) + + return {"uuid": uuid, "updated": True} + except ValueError as e: # noqa: BLE001 + return _fail(f"参数错误: {e}", code=status.HTTP_400_BAD_REQUEST) + except Exception as e: # noqa: BLE001 + return _fail(f"更新实体失败: {e}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# DELETE /api/knowledge/{uuid} +# ===================================================================== +@router.delete("/{uuid}") +async def delete_knowledge( + uuid: str = Path(..., min_length=1, description="实体 uuid"), +) -> Dict[str, Any]: + """ + 删除知识库实体(Stage C.1)。 + + Returns: + {"uuid": str, "deleted": bool} + """ + try: + db = _get_db() + if db is None: + return _fail( + "DatabaseManager 不可用:导入或初始化失败", + code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + ok: bool = db.delete_entity(uuid) + if not ok: + return _fail( + f"未找到实体或删除失败: {uuid}", + code=status.HTTP_404_NOT_FOUND, + ) + + return {"uuid": uuid, "deleted": True} + except ValueError as e: # noqa: BLE001 + return _fail(f"参数错误: {e}", code=status.HTTP_400_BAD_REQUEST) + except Exception as e: # noqa: BLE001 + return _fail(f"删除实体失败: {e}", code=status.HTTP_400_BAD_REQUEST) + + +__all__ = ["router"] diff --git a/src/web/backend/api/llm.py b/src/web/backend/api/llm.py new file mode 100644 index 0000000..6fc4de5 --- /dev/null +++ b/src/web/backend/api/llm.py @@ -0,0 +1,191 @@ +""" +LLM REST API - v3.1.0 +====================== + +端点: +- GET /api/llm/config 返回当前生效 provider / base_url / model / 是否有效 +- POST /api/llm/test 用现配置发一次 ping 调用,验证连通性 + +设计要点: +1. 复用 ``llm_providers.get_provider_summary()`` + ``get_model_config().is_valid()``, + 避免在 Web 层硬编码供应商逻辑。 +2. ``/api/llm/test`` 用 ``LangChainLLM`` 实跑一次调用,latency 用 + ``time.time()`` 简单计时;失败时返回 ``{ok: false, error}``, + 永远不向消费者抛 5xx(验收硬约束)。 +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.core import llm_providers +except Exception: # noqa: BLE001 + llm_providers = None # type: ignore + +try: + from src.core.model_config import get_model_config, ModelType +except Exception: # noqa: BLE001 + get_model_config = None # type: ignore + ModelType = None # type: ignore + +try: + from src.core.langchain_llm import LangChainLLM +except Exception: # noqa: BLE001 + LangChainLLM = None # type: ignore + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def log_warn(self, *args, **kwargs): pass + def log_error(self, *args, **kwargs): pass + return _Stub() + + +router = APIRouter(prefix="/api/llm", tags=["llm"]) +debug_logger = get_debug_logger() + + +# ---------------------------------------------------------------------- +# 内部 helper +# ---------------------------------------------------------------------- +def _safe_get_summary() -> Dict[str, Any]: + """ + 取得 provider summary,失败时返默认值。 + """ + if llm_providers is None: + return { + "provider": "unknown", + "base_url": "", + "model_name": "", + "api_key_set": False, + } + try: + return llm_providers.get_provider_summary() + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('api.llm', f'get_provider_summary 失败: {exc}') + except Exception: + pass + return { + "provider": llm_providers.resolve_provider() if llm_providers else "unknown", + "base_url": "", + "model_name": "", + "api_key_set": bool(llm_providers.resolve_api_key()) if llm_providers else False, + } + + +def _get_timeout() -> int: + if get_model_config is None: + return 30 + try: + cfg = get_model_config() + return int(getattr(cfg, "vision_llm_timeout", 30) or 30) + except Exception: + return 30 + + +# ---------------------------------------------------------------------- +# GET /api/llm/config +# ---------------------------------------------------------------------- +@router.get("/config") +async def get_llm_config() -> Dict[str, Any]: + """ + 返回当前生效的 LLM 配置概览。 + """ + summary = _safe_get_summary() + valid = False + temperature = 0.8 + max_tokens = 2000 + timeout = _get_timeout() + + if get_model_config is not None: + try: + cfg = get_model_config() + valid = bool(cfg.is_valid()) + main = cfg.get_model_config(ModelType.MAIN) if ModelType is not None else {} + temperature = float(main.get("temperature", 0.8) or 0.8) + max_tokens = int(main.get("max_tokens", 2000) or 2000) + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('api.llm', f'get_model_config 失败: {exc}') + except Exception: + pass + + return { + "provider": summary.get("provider", "unknown"), + "base_url": summary.get("base_url", ""), + "model_name": summary.get("model_name", ""), + "api_key_set": bool(summary.get("api_key_set", False)), + "valid": valid, + "temperature": temperature, + "max_tokens": max_tokens, + "timeout": timeout, + } + + +# ---------------------------------------------------------------------- +# POST /api/llm/test +# ---------------------------------------------------------------------- +class LLMTestRequest(BaseModel): + prompt: str = Field(default="ping", min_length=1, max_length=2000) + + +@router.post("/test") +async def test_llm_connection(req: Optional[LLMTestRequest] = None) -> Dict[str, Any]: + """ + 调 LangChainLLM 跑一次 ping 调用,返回 {ok, latency_ms, sample}。 + """ + prompt = (req.prompt if req and req.prompt else "ping").strip() or "ping" + + if get_model_config is None or LangChainLLM is None or ModelType is None: + return {"ok": False, "error": "LLM components unavailable", "latency_ms": 0} + + try: + cfg = get_model_config() + if not cfg.is_valid(): + return { + "ok": False, + "error": "API key not configured (set LLM_API_KEY or legacy SILICONFLOW_API_KEY)", + "latency_ms": 0, + } + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": f"config error: {exc}", "latency_ms": 0} + + start = time.time() + try: + llm = LangChainLLM(ModelType.MAIN) + content = llm.chat([{"role": "user", "content": prompt}]) or "" + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('api.llm', f'LLM chat 失败: {exc}', exc) + except Exception: + pass + return { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + "latency_ms": int((time.time() - start) * 1000), + } + + latency_ms = int((time.time() - start) * 1000) + return { + "ok": True, + "latency_ms": latency_ms, + "sample_response": (content or "")[:200], + } + + +__all__ = ["router"] diff --git a/src/web/backend/api/memory.py b/src/web/backend/api/memory.py new file mode 100644 index 0000000..ac1ad17 --- /dev/null +++ b/src/web/backend/api/memory.py @@ -0,0 +1,483 @@ +""" +Memory API router +话题时间线后端路由 - Stage B.4 + +提供两个端点(合并到本文件,按 spec 可选拆分为 memory_context.py): + +- ``GET /api/memory/timeline?days=7`` + 从 ``open_loops`` 表查询最近 N 天的未完话题,构造 + ``TimelineResponse({nodes, links, days, total})``。 +- ``GET /api/memory/loop/{uuid}/context`` + 根据 open_loops.uuid 返回相关对话上下文(来自 ``short_term_memory`` + 关联消息 / open_loop 的 context 字段)。 + +设计要点: +1. 数据源用 ``database_service`` 单例(白名单防御 + 失败降级空 list)。 +2. 所有 SQL 路径 ``try/except`` 保护,失败返回空响应。 +3. ``days`` / ``limit`` 边界保护,避免外部参数异常拖死 DB。 +4. 不修改 LongTermMemoryManager / DatabaseManager 既有签名。 +5. ``app.include_router(memory.router)`` 由 main.py 完成。 +""" + +from __future__ import annotations + +import json +import sys +import warnings +from datetime import datetime, timedelta +from pathlib import Path as PathLib # noqa: F401 # 重命名避免与 fastapi.Path 冲突 +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Path, Query, status + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = PathLib(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from src.web.backend.schemas.memory import ( # noqa: E402 + LoopContextItem, + LoopContextResponse, + TimelineLink, + TimelineNode, + TimelineResponse, +) +from src.web.backend.services.database_service import ( # noqa: E402 + database_service, +) + + +router = APIRouter(prefix="/api/memory", tags=["memory"]) + + +# ---------------------------------------------------------------------- +# 内部:日期 / 边界保护 +# ---------------------------------------------------------------------- +_MIN_DAYS = 1 +_MAX_DAYS = 365 +_DEFAULT_DAYS = 7 +_DEFAULT_LIMIT = 200 +_MAX_LIMIT = 1000 + + +def _coerce_days(raw: Any) -> int: + """把 Query 传入的 days 规整到 [1, 365],失败回退 7。""" + try: + v = int(raw) + except (TypeError, ValueError): + return _DEFAULT_DAYS + if v < _MIN_DAYS: + return _MIN_DAYS + if v > _MAX_DAYS: + return _MAX_DAYS + return v + + +def _coerce_limit(raw: Any) -> int: + """把 limit 规整到 [1, 1000],失败回退 200。""" + try: + v = int(raw) + except (TypeError, ValueError): + return _DEFAULT_LIMIT + if v < 1: + return 1 + if v > _MAX_LIMIT: + return _MAX_LIMIT + return v + + +def _iso_to_date(iso: Optional[str]) -> str: + """把 ISO 8601 字符串截成 YYYY-MM-DD;失败返回空串。""" + if not iso or not isinstance(iso, str): + return "" + s = iso.strip() + if len(s) >= 10 and s[4:5] == "-" and s[7:8] == "-": + return s[:10] + return "" + + +def _parse_keywords(raw: Any) -> List[str]: + """ + 把 ``related_keywords_json`` / list / str 规整为 List[str]。 + 失败时返回 []。 + """ + if raw is None: + return [] + if isinstance(raw, list): + return [str(x) for x in raw if x] + if isinstance(raw, str): + s = raw.strip() + if not s: + return [] + # 尝试 json.loads(库内以 json 形式存储) + if s.startswith("[") or s.startswith("{"): + try: + parsed = json.loads(s) + if isinstance(parsed, list): + return [str(x) for x in parsed if x] + except Exception: # noqa: BLE001 + pass + # 退化:逗号 / 空格切分 + parts = [p.strip() for p in s.replace(",", " ").split() if p.strip()] + return parts + return [] + + +def _normalize_status(raw: Any) -> str: + """ + 把 open_loops.status 规整为 ``open`` / ``closed`` 之一。 + 数据库里常用 'open' / 'resolved' / 'closed'。前端 TimelineCanvas + 看到 'open'/'closed' 即可按需上色。 + """ + s = str(raw or "").strip().lower() + if not s: + return "open" + if s in ("resolved", "closed", "done", "finished"): + return "closed" + if s in ("open", "pending", "active"): + return "open" + return s + + +def _normalize_node(row: Dict[str, Any]) -> Optional[TimelineNode]: + """ + 把 open_loops 的一行 dict 转换为 TimelineNode。 + 字段缺失 / 解析失败 → 返回 None(让上层跳过)。 + """ + try: + uuid_val = str(row.get("uuid") or "").strip() + if not uuid_val: + return None + topic_val = str(row.get("topic") or "未命名话题") + date_val = _iso_to_date(row.get("raised_at") or row.get("created_at")) + if not date_val: + # 兜底:取 created_at 或今天 + date_val = _iso_to_date(row.get("created_at")) or datetime.now().strftime("%Y-%m-%d") + status_val = _normalize_status(row.get("status")) + try: + mention_count = int(row.get("mention_count") or 0) + except (TypeError, ValueError): + mention_count = 0 + keywords = _parse_keywords(row.get("related_keywords_json") or row.get("related_keywords")) + return TimelineNode( + uuid=uuid_val, + topic=topic_val, + date=date_val, + status=status_val, + category="其他", + mention_count=mention_count, + keywords=keywords, + ) + except Exception: # noqa: BLE001 + return None + + +# ---------------------------------------------------------------------- +# 1) GET /api/memory/timeline?days=7 +# ---------------------------------------------------------------------- +@router.get("/timeline", response_model=TimelineResponse) +async def get_timeline( + days: int = Query( + _DEFAULT_DAYS, + ge=_MIN_DAYS, + le=_MAX_DAYS, + description="查询最近 N 天的未完话题(默认 7,最大 365)", + ), + limit: int = Query( + _DEFAULT_LIMIT, + ge=1, + le=_MAX_LIMIT, + description="返回节点数上限(默认 200,最大 1000)", + ), +) -> TimelineResponse: + """ + 话题时间线后端。 + + 响应:``TimelineResponse({nodes, links, days, total})`` + - ``nodes`` 每个节点来自 ``open_loops`` 一行; + - ``links`` 由共享 related_keywords 推导(>= 2 个节点共享至少一个关键词时建链)。 + + 失败降级:DB 不可用 / 表不存在 / 解析失败 → 返回 ``{nodes:[], links:[]}``。 + """ + safe_days = _coerce_days(days) + safe_limit = _coerce_limit(limit) + + # 截止时间 + try: + cutoff_iso = (datetime.now() - timedelta(days=safe_days)).isoformat() + except Exception: # noqa: BLE001 + cutoff_iso = datetime.now().isoformat() + + # 1) 拉 open_loops + raw_rows: List[Dict[str, Any]] = [] + try: + raw_rows = database_service.query_table("open_loops", limit=safe_limit, offset=0) or [] + except ValueError as exc: + # 理论上不会发生(白名单内),但万一也兜底 + try: + warnings.warn( + f"[memory] query_table('open_loops') 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + except Exception: + pass + raw_rows = [] + except Exception as exc: # noqa: BLE001 + try: + warnings.warn( + f"[memory] query_table('open_loops') 异常: {exc}", + RuntimeWarning, + stacklevel=2, + ) + except Exception: + pass + raw_rows = [] + + # 2) 规整为节点 + 时间过滤 + nodes: List[TimelineNode] = [] + for row in raw_rows: + node = _normalize_node(row) + if node is None: + continue + # 时间窗口过滤:raised_at < cutoff 则丢弃 + try: + raised_iso = str(row.get("raised_at") or row.get("created_at") or "") + if raised_iso and raised_iso < cutoff_iso: + continue + except Exception: + pass + nodes.append(node) + + # 3) 构造 links:共享至少一个 keyword 的两个节点建链 + links: List[TimelineLink] = [] + try: + n = len(nodes) + kw_index: Dict[str, List[str]] = {} + for nd in nodes: + for kw in nd.keywords: + if not kw: + continue + kw_index.setdefault(kw, []).append(nd.uuid) + seen: set = set() + for kw, uuids in kw_index.items(): + if len(uuids) < 2: + continue + for i in range(len(uuids)): + for j in range(i + 1, len(uuids)): + a, b = uuids[i], uuids[j] + key = (a, b) if a < b else (b, a) + if key in seen: + continue + seen.add(key) + links.append(TimelineLink(source=key[0], target=key[1], relation="keyword")) + except Exception: # noqa: BLE001 + links = [] + + return TimelineResponse( + nodes=nodes, + links=links, + days=safe_days, + total=len(nodes), + ) + + +# ---------------------------------------------------------------------- +# 2) GET /api/memory/loop/{uuid}/context +# ---------------------------------------------------------------------- +def _query_short_term_by_keywords( + keywords: List[str], + limit: int = 50, +) -> List[Dict[str, Any]]: + """ + 辅助:在 short_term_memory 中按关键词粗匹配(LIKE)。 + 注意:这是简单实现,不做 join / ranking。 + 失败 → []。 + """ + if not keywords: + return [] + db = database_service.get_db() + if db is None: + return [] + safe_limit = _coerce_limit(limit) + rows: List[Dict[str, Any]] = [] + try: + with db.get_connection() as conn: + cur = conn.cursor() + for kw in keywords: + if not kw: + continue + pattern = f"%{kw}%" + try: + cur.execute( + "SELECT id, role, content, timestamp FROM short_term_memory " + "WHERE content LIKE ? ORDER BY id DESC LIMIT ?", + (pattern, safe_limit), + ) + except Exception: + continue + fetched = cur.fetchall() or [] + for r in fetched: + try: + if isinstance(r, dict): + rows.append(dict(r)) + else: + rows.append({k: r[k] for k in r.keys()}) + except Exception: + try: + rows.append(dict(r)) + except Exception: + continue + except Exception: # noqa: BLE001 + return [] + # 去重(按 id) + dedup: Dict[Any, Dict[str, Any]] = {} + for r in rows: + rid = r.get("id") + if rid is None: + continue + if rid not in dedup: + dedup[rid] = r + merged = list(dedup.values()) + try: + merged.sort(key=lambda x: x.get("id") or 0, reverse=True) + except Exception: + pass + return merged[:safe_limit] + + +def _find_open_loop_by_uuid(uuid_val: str) -> Optional[Dict[str, Any]]: + """ + 在 open_loops 中按 uuid 查找一行。 + 找不到 / 失败 → None。 + """ + if not uuid_val or not isinstance(uuid_val, str): + return None + db = database_service.get_db() + if db is None: + return None + try: + with db.get_connection() as conn: + cur = conn.cursor() + try: + cur.execute( + "SELECT uuid, topic, status, raised_at, resolved_at, " + "related_keywords_json, context, mention_count, " + "created_at, updated_at " + "FROM open_loops WHERE uuid = ?", + (uuid_val,), + ) + except Exception: + # 兜底:SELECT * + cur.execute( + "SELECT * FROM open_loops WHERE uuid = ?", + (uuid_val,), + ) + row = cur.fetchone() + if row is None: + return None + try: + if isinstance(row, dict): + return dict(row) + return {k: row[k] for k in row.keys()} + except Exception: + try: + return dict(row) + except Exception: + return None + except Exception: # noqa: BLE001 + return None + + +@router.get("/loop/{uuid}/context", response_model=LoopContextResponse) +async def get_loop_context( + uuid: str = Path(..., min_length=1, description="话题 UUID(open_loops.uuid)"), + limit: int = Query( + 50, + ge=1, + le=200, + description="返回的上下文消息条数上限(默认 50)", + ), +) -> LoopContextResponse: + """ + 话题上下文(按 open_loops.uuid 关联 short_term_memory)。 + + 响应:``LoopContextResponse({uuid, topic, status, items, total})``。 + 失败 / 未找到 → items=[]。 + """ + safe_uuid = (uuid or "").strip() + if not safe_uuid: + # 空 uuid 视为 4xx 输入 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="uuid 不能为空", + ) + + safe_limit = _coerce_limit(limit) + if safe_limit > 200: + safe_limit = 200 + + # 1) 找 open_loop + loop_row: Optional[Dict[str, Any]] = None + try: + loop_row = _find_open_loop_by_uuid(safe_uuid) + except Exception: # noqa: BLE001 + loop_row = None + + if loop_row is None: + # 未找到(也可能 DB 不可用) → 返回空上下文 + return LoopContextResponse(uuid=safe_uuid, items=[], total=0) + + topic_text = str(loop_row.get("topic") or "") + status_text = _normalize_status(loop_row.get("status")) + keywords = _parse_keywords( + loop_row.get("related_keywords_json") or loop_row.get("related_keywords") + ) + + items: List[LoopContextItem] = [] + + # 2) 先放 open_loop.context(兜底 / 锚点) + fallback_context = str(loop_row.get("context") or "").strip() + if fallback_context: + items.append( + LoopContextItem( + role="system", + content=fallback_context, + timestamp=_iso_to_date(loop_row.get("raised_at")) or None, + source="open_loop_context", + ) + ) + + # 3) 再按关键词找 short_term_memory 关联消息 + if keywords: + try: + matched = _query_short_term_by_keywords(keywords, limit=safe_limit) or [] + except Exception: # noqa: BLE001 + matched = [] + for m in matched: + try: + items.append( + LoopContextItem( + role=str(m.get("role") or "user"), + content=str(m.get("content") or ""), + timestamp=(str(m.get("timestamp")) if m.get("timestamp") else None), + source="short_term_memory", + ) + ) + except Exception: # noqa: BLE001 + continue + + # 4) 截断 + if len(items) > safe_limit: + items = items[:safe_limit] + + return LoopContextResponse( + uuid=safe_uuid, + topic=topic_text, + status=status_text, + items=items, + total=len(items), + ) + + +__all__ = ["router"] diff --git a/src/web/backend/api/nps.py b/src/web/backend/api/nps.py new file mode 100644 index 0000000..127c06c --- /dev/null +++ b/src/web/backend/api/nps.py @@ -0,0 +1,395 @@ +""" +NPS REST API router - Stage C.4 / 验收 P0 3.2 +NPS 插件(Neo Plugin System)的 REST API 路由。 + +端点: + GET /api/nps 列出所有 NPS 工具(含 enabled / disabled) + POST /api/nps/register 手动注册一个新的 NPS 工具 + POST /api/nps/invoke 调用指定 NPS 工具并返回结果 + +设计要点: +1. 复用 ``src.nps.nps_registry.NPSRegistry`` 与 + ``src.nps.nps_invoker.NPSInvoker``,Web 层仅做参数透传与序列化。 +2. 进程内懒加载单例:NPSRegistry / NPSInvoker 各保持一份; + NPSInvoker 构造时自动 scan_and_register,扫描失败不抛 5xx。 +3. 所有路由 try/except 包裹,失败返回 4xx + {"error": str(e)}, + 绝不冒 5xx(验收硬约束)。 +4. Pydantic v2 schema(NPSRegisterRequest / NPSInvokeRequest)做 + body 校验,非法 body 由 FastAPI 自动返回 422。 +5. 数据库 / 配置目录不可用时返回空列表 / 默认响应(graceful degradation)。 +""" + +from __future__ import annotations + +import sys +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from fastapi import APIRouter, Body, HTTPException, status # noqa: E402 +from pydantic import BaseModel, Field # noqa: E402 + +# 调试日志(try/except 保护:debug_logger 不可用时降级为 stub) +try: + from src.tools.debug_logger import get_debug_logger as _get_debug_logger + _debug_logger = _get_debug_logger() +except Exception: # noqa: BLE001 + class _StubLogger: + def log_info(self, *a, **k): pass + def log_warn(self, *a, **k): pass + def log_error(self, *a, **k): pass + _debug_logger = _StubLogger() + + +# --------------------------------------------------------------------- +# NPSRegistry / NPSInvoker 懒加载(try/except 保护) +# --------------------------------------------------------------------- +_registry: Optional[Any] = None +_invoker: Optional[Any] = None +_NPS_IMPORT_OK: bool = False +_NPS_IMPORT_ERROR: Optional[BaseException] = None + +try: + from src.nps.nps_registry import NPSRegistry, NPSTool # noqa: E402 + from src.nps.nps_invoker import NPSInvoker # noqa: E402 + _NPS_IMPORT_OK = True +except Exception as _exc: # noqa: BLE001 + NPSRegistry = None # type: ignore + NPSTool = None # type: ignore + NPSInvoker = None # type: ignore + _NPS_IMPORT_ERROR = _exc + + +def _get_registry() -> Optional[Any]: + """ + 懒加载 NPSRegistry 单例。 + - 失败时返回 None(路由层兜底为 503 / 空列表)。 + """ + global _registry + if _registry is not None: + return _registry + if not _NPS_IMPORT_OK or NPSRegistry is None: + return None + try: + _registry = NPSRegistry() + # 尝试自动扫描;失败不抛 + try: + _registry.scan_and_register() + except Exception as scan_exc: # noqa: BLE001 + _debug_logger.log_warn( + 'nps_api', + f'自动扫描 NPS 工具失败(不影响路由): {scan_exc}', + ) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('nps_api', f'创建 NPSRegistry 失败: {exc}', exc) + _registry = None + return _registry + + +def _get_invoker() -> Optional[Any]: + """ + 懒加载 NPSInvoker 单例。 + - 失败时返回 None(路由层兜底)。 + """ + global _invoker + if _invoker is not None: + return _invoker + if not _NPS_IMPORT_OK or NPSInvoker is None: + return None + try: + _invoker = NPSInvoker(registry=_get_registry()) + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('nps_api', f'创建 NPSInvoker 失败: {exc}', exc) + _invoker = None + return _invoker + + +# --------------------------------------------------------------------- +# Pydantic v2 Schemas(强类型契约) +# --------------------------------------------------------------------- +class NPSRegisterRequest(BaseModel): + """ + 手动注册 NPS 工具的请求体。 + Attributes: + tool_id: 工具唯一标识符(必填) + name: 工具名称(必填) + description: 工具功能描述(必填) + keywords: 触发关键词列表(可选) + enabled: 是否启用(默认 True) + version: 版本号(默认 "1.0.0") + author: 作者(默认 "Unknown") + """ + tool_id: str = Field(..., min_length=1, max_length=128, description="工具唯一标识符") + name: str = Field(..., min_length=1, max_length=256, description="工具名称") + description: str = Field("", description="工具功能描述(用于LLM判断相关性)") + keywords: List[str] = Field(default_factory=list, description="触发关键词列表") + enabled: bool = Field(True, description="是否启用") + version: str = Field("1.0.0", description="版本号") + author: str = Field("Unknown", description="作者") + + +class NPSInvokeRequest(BaseModel): + """ + 调用 NPS 工具的请求体。 + Attributes: + tool_name: 工具名称或 tool_id(必填) + context: 执行上下文 dict(默认空) + use_llm: 是否使用 LLM 判断相关性(保留字段;此处按名称直接调用) + """ + tool_name: str = Field(..., min_length=1, description="工具名称或 tool_id") + context: Dict[str, Any] = Field(default_factory=dict, description="执行上下文") + use_llm: bool = Field(False, description="是否使用 LLM(直接调用时无效)") + + +# --------------------------------------------------------------------- +# 工具:NPSTool → dict 序列化 +# --------------------------------------------------------------------- +def _tool_to_dict(tool: Any) -> Dict[str, Any]: + """ + 把 NPSTool 序列化为前端兼容的 dict。 + """ + if tool is None: + return {} + try: + if hasattr(tool, 'to_dict') and callable(getattr(tool, 'to_dict')): + return dict(tool.to_dict()) + except Exception: # noqa: BLE001 + pass + # fallback:手动拼装 + return { + 'tool_id': getattr(tool, 'tool_id', ''), + 'name': getattr(tool, 'name', ''), + 'description': getattr(tool, 'description', ''), + 'keywords': list(getattr(tool, 'keywords', []) or []), + 'version': getattr(tool, 'version', '1.0.0'), + 'author': getattr(tool, 'author', 'Unknown'), + 'enabled': bool(getattr(tool, 'enabled', True)), + } + + +def _fail(message: str, code: int = status.HTTP_400_BAD_REQUEST) -> Dict[str, Any]: + """统一兜底响应。""" + return {"error": message, "status_code": code} + + +# --------------------------------------------------------------------- +# 路由 +# --------------------------------------------------------------------- +router = APIRouter(prefix="/api/nps", tags=["nps"]) + + +# ===================================================================== +# GET /api/nps +# ===================================================================== +@router.get("") +async def list_nps() -> Dict[str, Any]: + """ + 列出所有已注册的 NPS 工具。 + + 包含 enabled / disabled 全部工具。 + registry 不可用时返回空列表(graceful degradation)。 + + Returns: + {"items": [...], "total": int, "enabled": int, "disabled": int} + """ + try: + registry = _get_registry() + if registry is None: + return { + "items": [], + "total": 0, + "enabled": 0, + "disabled": 0, + "warning": "NPSRegistry 不可用:导入或初始化失败", + } + + tools = registry.get_all_tools() or [] + items: List[Dict[str, Any]] = [] + enabled_count = 0 + for t in tools: + d = _tool_to_dict(t) + if d: + items.append(d) + if d.get('enabled', True): + enabled_count += 1 + return { + "items": items, + "total": len(items), + "enabled": enabled_count, + "disabled": max(0, len(items) - enabled_count), + } + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('nps_api', f'list_nps 失败: {exc}', exc) + warnings.warn( + f"[nps_api] list_nps 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return _fail(f"列出 NPS 工具失败: {exc}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# POST /api/nps/register +# ===================================================================== +@router.post("/register") +async def register_nps(payload: NPSRegisterRequest = Body(...)) -> Dict[str, Any]: + """ + 手动注册一个新的 NPS 工具。 + + 复用 NPSRegistry.register_tool;tool_id 已存在时返回 409。 + 由于 NPSTool 需要 execute_func,本接口仅做"元数据记录": + - 把工具元信息写入 NPSConfigManager(configs/nps_plugins/)。 + - 若 registry 中已存在该 tool_id,返回 409。 + - 若不存在,把元信息持久化为可被下次启动读取的条目。 + + Returns: + {"tool_id": str, "name": str, "registered": true, "persisted": bool} + """ + try: + registry = _get_registry() + if registry is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NPSRegistry 不可用:导入或初始化失败", + ) + + # 检查是否已存在 + try: + existing = registry.get_tool(payload.tool_id) + except Exception: # noqa: BLE001 + existing = None + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"工具已存在: {payload.tool_id}", + ) + + # 把元数据持久化到 NPSConfigManager(graceful:失败不抛 5xx) + persisted = False + try: + from src.nps.nps_config_manager import NPSConfigManager # type: ignore + cm = NPSConfigManager() + cm.set_plugin_config( + payload.tool_id, + { + 'name': payload.name, + 'description': payload.description, + 'keywords': list(payload.keywords or []), + 'enabled': bool(payload.enabled), + 'version': payload.version, + 'author': payload.author, + }, + ) + persisted = True + except Exception as persist_exc: # noqa: BLE001 + _debug_logger.log_warn( + 'nps_api', + f'持久化 NPS 配置失败(仅元信息): {persist_exc}', + ) + persisted = False + + # 同步把"占位"工具注册到当前进程 registry(无 execute_func,仅元数据) + if NPSTool is not None: + try: + placeholder = NPSTool( + tool_id=payload.tool_id, + name=payload.name, + description=payload.description, + keywords=list(payload.keywords or []), + execute_func=lambda *a, **k: { + 'success': False, + 'error': '此工具未实现 execute_func(仅元数据已注册)', + }, + version=payload.version, + author=payload.author, + enabled=bool(payload.enabled), + ) + registry.register_tool(placeholder) + except Exception as reg_exc: # noqa: BLE001 + _debug_logger.log_warn( + 'nps_api', + f'把工具注册到当前进程 registry 失败: {reg_exc}', + ) + + return { + "tool_id": payload.tool_id, + "name": payload.name, + "registered": True, + "persisted": persisted, + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('nps_api', f'register_nps 失败: {exc}', exc) + return _fail(f"注册 NPS 工具失败: {exc}", code=status.HTTP_400_BAD_REQUEST) + + +# ===================================================================== +# POST /api/nps/invoke +# ===================================================================== +@router.post("/invoke") +async def invoke_nps(payload: NPSInvokeRequest = Body(...)) -> Dict[str, Any]: + """ + 调用指定 NPS 工具。 + + 复用 NPSInvoker.invoke_tool_by_name。 + 工具不存在 / 未启用 → 返回 4xx + 友好错误。 + + Returns: + {"success": bool, "tool_id": str, "tool_name": str, + "result": Any | None, "error": str | None} + """ + try: + invoker = _get_invoker() + if invoker is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NPSInvoker 不可用:导入或初始化失败", + ) + + # 直接按名称调用(不走 LLM 判相关性) + out = invoker.invoke_tool_by_name( + tool_name=payload.tool_name, + context=payload.context or {}, + ) + # out 形如 {"success": bool, "tool_id": ..., "tool_name": ..., + # "result": ..., "error": ...} + if not isinstance(out, dict): + return _fail( + f"调用结果非 dict: {type(out).__name__}", + code=status.HTTP_400_BAD_REQUEST, + ) + + if not out.get('success', False): + # 工具不存在 / 未启用 / 执行失败 + return { + "success": False, + "tool_id": out.get('tool_id', payload.tool_name), + "tool_name": out.get('tool_name', payload.tool_name), + "result": None, + "error": out.get('error', '调用失败'), + "status_code": status.HTTP_404_NOT_FOUND + if '未找到' in (out.get('error') or '') + else status.HTTP_400_BAD_REQUEST, + } + + return { + "success": True, + "tool_id": out.get('tool_id', payload.tool_name), + "tool_name": out.get('tool_name', payload.tool_name), + "result": out.get('result'), + "error": None, + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + _debug_logger.log_error('nps_api', f'invoke_nps 失败: {exc}', exc) + return _fail(f"调用 NPS 工具失败: {exc}", code=status.HTTP_400_BAD_REQUEST) + + +__all__ = ["router"] diff --git a/src/web/backend/api/schedule.py b/src/web/backend/api/schedule.py new file mode 100644 index 0000000..fb1ee6d --- /dev/null +++ b/src/web/backend/api/schedule.py @@ -0,0 +1,339 @@ +""" +Schedule REST API Router +- /api/schedule/ (GET): 按日期查询日程 +- /api/schedule/ (POST): 新建日程 +- /api/schedule/{schedule_id} (PUT): 更新日程 +- /api/schedule/{schedule_id} (DELETE): 删除日程 +- /api/schedule/{schedule_id}/confirm (POST): 协作日程确认(双方确认后才生效) + +Stage C.2: +- 复用 database_service 提供的 schedule_* 表查询/写入接口 +- 失败路径:HTTPException(status_code=4xx),不抛 5xx +- 协作确认:调用 database_service.confirm_schedule_item +""" + +from __future__ import annotations + +import sys +import warnings +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.web.backend.services.database_service import database_service + from src.web.backend.schemas.schedule import ( + ScheduleCreate, + ScheduleUpdate, + ) +except Exception as exc: # noqa: BLE001 + database_service = None # type: ignore + _IMPORT_ERROR = exc + + +# 协作确认请求体(Stage C.2 扩展) +class ScheduleConfirmRequest(BaseModel): + user_id: str = "default" + note: Optional[str] = None + + +# ---------------------------------------------------------------------- +# 工具 +# ---------------------------------------------------------------------- +def _row_to_dto(row: Dict[str, Any]) -> Dict[str, Any]: + """把数据库行映射为前端 ScheduleDTO 兼容结构。""" + if not isinstance(row, dict): + try: + row = dict(row) + except Exception: + return {} + return { + "id": row.get("id"), + "uuid": str(row.get("id") or row.get("uuid") or ""), + "title": str(row.get("title") or ""), + "description": row.get("description"), + "start_time": str(row.get("start_time") or ""), + "end_time": row.get("end_time"), + "priority": str(row.get("priority") or "normal"), + "status": str(row.get("status") or "pending"), + "schedule_type": str(row.get("schedule_type") or "personal"), + } + + +def _parse_iso_date(value: Optional[str], field: str) -> str: + """YYYY-MM-DD 校验(失败 400)。""" + import re + if not value or not isinstance(value, str): + raise HTTPException(status_code=400, detail=f"{field} 必填") + if not re.match(r"^\d{4}-\d{2}-\d{2}$", value): + raise HTTPException( + status_code=400, + detail=f"{field} 格式必须为 YYYY-MM-DD, 收到: {value!r}", + ) + return value + + +# ---------------------------------------------------------------------- +# 路由 +# ---------------------------------------------------------------------- +router = APIRouter(prefix="/api/schedule", tags=["schedule"]) + + +@router.get("") +async def list_schedule( + date: Optional[str] = Query( + default=None, + description="单日查询 YYYY-MM-DD", + ), + from_date: Optional[str] = Query( + default=None, + alias="from", + description="区间起始 YYYY-MM-DD(与 to 一起使用)", + ), + to_date: Optional[str] = Query( + default=None, + alias="to", + description="区间结束 YYYY-MM-DD(与 from 一起使用)", + ), + user_id: str = Query(default="default"), + limit: int = Query(default=100, ge=1, le=500), +) -> dict: + """ + 按日期查询日程。 + - 兼容 date=YYYY-MM-DD 单日查询 + - 兼容 from / to 区间查询 + - 失败路径:返回空列表(表不存在时也不抛 5xx) + """ + if database_service is None: + return {"items": [], "total": 0, "date": date, "range": None} + + try: + if date: + _parse_iso_date(date, "date") + rows = database_service.get_schedule_by_date(date) or [] + return { + "items": [_row_to_dto(r) for r in rows[:limit]], + "total": min(len(rows), limit), + "date": date, + "range": None, + } + + if from_date or to_date: + if not (from_date and to_date): + raise HTTPException( + status_code=400, + detail="from 与 to 必须成对出现", + ) + _parse_iso_date(from_date, "from") + _parse_iso_date(to_date, "to") + if from_date > to_date: + raise HTTPException( + status_code=400, + detail=f"from ({from_date}) 不能晚于 to ({to_date})", + ) + rows = database_service.get_schedule_by_range(from_date, to_date) or [] + return { + "items": [_row_to_dto(r) for r in rows[:limit]], + "total": min(len(rows), limit), + "date": None, + "range": {"from": from_date, "to": to_date}, + } + + # 未指定过滤:直接读 schedules 表前 limit 条 + try: + tables = database_service.get_tables() or [] + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"DatabaseService 不可用: {exc}") from exc + if "schedules" not in tables and "schedule_entries" not in tables: + return { + "items": [], + "total": 0, + "date": None, + "range": None, + "warning": "schedule_* 表未在数据库中创建, 当前返回空列表", + } + table = "schedules" if "schedules" in tables else "schedule_entries" + rows = database_service.query_table(table, limit=limit, offset=0) or [] + return { + "items": [_row_to_dto(r) for r in rows], + "total": len(rows), + "date": None, + "range": None, + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[schedule_api] list_schedule 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return {"items": [], "total": 0, "date": date, "range": None, "error": str(exc)} + + +@router.post("") +async def create_schedule(payload: ScheduleCreate) -> dict: + """ + 新建日程。 + - 复用 database_service.add_schedule_item 走白名单 INSERT 路径 + - 失败路径:400/503(不抛 5xx) + """ + if database_service is None: + raise HTTPException(status_code=503, detail="database_service 不可用") + + # 校验:表是否存在 + try: + tables = database_service.get_tables() or [] + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"DatabaseService 不可用: {exc}") from exc + if "schedules" not in tables and "schedule_entries" not in tables: + raise HTTPException( + status_code=400, + detail="schedule_* 表未在数据库中创建, 无法新增日程", + ) + + item: Dict[str, Any] = { + "title": payload.title, + "description": payload.description, + "start_time": payload.start_time, + "end_time": payload.end_time, + "priority": payload.priority or "normal", + "status": "pending", + "schedule_type": payload.schedule_type or "personal", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } + try: + new_id = database_service.add_schedule_item(item) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"新增日程失败: {exc}") from exc + if not new_id: + raise HTTPException(status_code=400, detail="新增日程失败: 数据库写入异常") + + return { + "id": int(new_id), + "uuid": str(new_id), + "item": _row_to_dto({**item, "id": int(new_id)}), + "message": "ok", + } + + +@router.put("/{schedule_id}") +async def update_schedule(schedule_id: int, payload: ScheduleUpdate) -> dict: + """ + 更新日程(部分字段)。 + - 复用 database_service.update_schedule_item + """ + if database_service is None: + raise HTTPException(status_code=503, detail="database_service 不可用") + + if schedule_id <= 0: + raise HTTPException(status_code=400, detail="schedule_id 必须为正整数") + + if payload.id and payload.id != schedule_id: + raise HTTPException( + status_code=400, + detail=f"URL id ({schedule_id}) 与 body.id ({payload.id}) 不一致", + ) + + updates: Dict[str, Any] = {} + for field in ( + "title", "description", "start_time", "end_time", + "priority", "status", + ): + value = getattr(payload, field, None) + if value is not None: + updates[field] = value + if not updates: + raise HTTPException(status_code=400, detail="没有可更新的字段") + updates["updated_at"] = datetime.now().isoformat() + + try: + ok = database_service.update_schedule_item(schedule_id, updates) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"更新失败: {exc}") from exc + if not ok: + raise HTTPException( + status_code=404, + detail=f"未找到 id={schedule_id} 的日程, 或写入失败", + ) + + return { + "id": schedule_id, + "updated_fields": list(updates.keys()), + "message": "ok", + } + + +@router.delete("/{schedule_id}") +async def delete_schedule(schedule_id: int) -> dict: + """ + 删除日程。 + """ + if database_service is None: + raise HTTPException(status_code=503, detail="database_service 不可用") + + if schedule_id <= 0: + raise HTTPException(status_code=400, detail="schedule_id 必须为正整数") + + try: + ok = database_service.delete_schedule_item(schedule_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"删除失败: {exc}") from exc + if not ok: + raise HTTPException( + status_code=404, + detail=f"未找到 id={schedule_id} 的日程, 或删除失败", + ) + + return { + "id": schedule_id, + "deleted": True, + "message": "ok", + } + + +@router.post("/{schedule_id}/confirm") +async def confirm_schedule( + schedule_id: int, + confirmed: bool = Query(default=True, description="true=确认, false=取消确认"), + payload: Optional[ScheduleConfirmRequest] = None, +) -> dict: + """ + 协作日程确认(双方确认后才生效)。 + - 复用 database_service.confirm_schedule_item 写入 confirmed 字段 + - 失败路径:404/400(不抛 5xx) + """ + if database_service is None: + raise HTTPException(status_code=503, detail="database_service 不可用") + + if schedule_id <= 0: + raise HTTPException(status_code=400, detail="schedule_id 必须为正整数") + + try: + ok = database_service.confirm_schedule_item(schedule_id, bool(confirmed)) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"确认失败: {exc}") from exc + if not ok: + raise HTTPException( + status_code=404, + detail=f"未找到 id={schedule_id} 的日程, 或更新失败", + ) + + return { + "id": schedule_id, + "confirmed": bool(confirmed), + "status": "confirmed" if confirmed else "pending", + "message": "ok", + } + + +__all__ = ["router"] diff --git a/src/web/backend/main.py b/src/web/backend/main.py new file mode 100644 index 0000000..aa145fb --- /dev/null +++ b/src/web/backend/main.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Neo Agent Web Backend - FastAPI Entry Point +Neo Agent Web 后端 - FastAPI 入口 + +Stage A.1: 基础框架与健康检查 +Stage D.3: EventService 启动 + WebSocket 路由挂载 +""" + +import sys +import os +from pathlib import Path +from datetime import datetime, timezone + +# Python 路径处理:确保从项目根目录(Neo_Agent/)能正确导入 src.* 模块 +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +# Phase 6: v4.0 神经系统桥接(导入失败不破坏 v3) +try: + from src.nervous_system.app import NeoApp + from src.nervous_system.router.packet import Packet, PacketType + from src.web.backend.services.neo_bridge import set_neo_app +except Exception: # noqa: BLE001 + NeoApp = None # type: ignore + Packet = None # type: ignore + PacketType = None # type: ignore + set_neo_app = None # type: ignore + +# FastAPI 应用实例 +app = FastAPI( + title="Neo Agent Web", + version="3.0.0", + description="Neo Agent Web GUI 后端服务", +) + +# CORS 中间件配置(开发态允许本地 Vite/后端端口) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://localhost:8000", + "http://127.0.0.1:5173", + "http://127.0.0.1:8000", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ===================================================================== +# Phase 6: v4.0 NeoApp 全局实例(失败不影响 v3) +# ===================================================================== +_neo_app: "NeoApp | None" = None + + +def get_neo_app() -> "NeoApp | None": + """返回当前进程内的全局 NeoApp 实例。""" + return _neo_app + + +@app.get("/api/health") +async def health_check() -> dict: + """ + 健康检查端点 + Health check endpoint. + + 返回服务状态、版本号、当前时间戳(ISO 8601, UTC)以及 v4 模块状态。 + """ + result = { + "status": "ok", + "version": "3.0.0", + "timestamp": datetime.now(timezone.utc).isoformat(), + "v4_status": "ok", + "v4_modules": [], + } + + neo = get_neo_app() + if neo is None: + result["v4_status"] = "degraded" + return result + + try: + result["v4_modules"] = list(neo.router._modules.keys()) + except Exception as exc: # noqa: BLE001 + result["v4_status"] = "degraded" + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'读取 v4 模块列表失败: {exc}', exc) + except Exception: + pass + + return result + + +@app.get("/api/chat") +async def chat_placeholder() -> dict: + """ + 占位聊天端点(Stage A.3 替换为真实实现) + """ + return {"message": "chat endpoint placeholder"} + + +# ===================================================================== +# Phase 6: v4.0 通用网关路由 +# ===================================================================== +@app.post("/api/v4/gateway/{target}/{channel}") +async def v4_gateway(target: str, channel: str, request: Request) -> JSONResponse: + """ + 通过 CentralRouter 调用任意 v4 模块。 + + 请求体作为 payload 透传给目标模块;返回体统一为 + {"data": ..., "trace_id": ...} 或 {"error": ..., "code": ..., "trace_id": ...}。 + """ + neo = get_neo_app() + if neo is None or Packet is None or PacketType is None: + return JSONResponse( + status_code=503, + content={ + "error": "v4 nervous system not available", + "code": "NEOAPP_NOT_READY", + "trace_id": "", + }, + ) + + body: dict = {} + try: + body = await request.json() + except Exception: # noqa: BLE001 + body = {} + + try: + packet = Packet( + source="web.backend", + target=target, + packet_type=PacketType.REQUEST, + channel=channel, + payload=body, + metadata={"user_id": request.headers.get("X-User-Id", "default")}, + ) + response = await neo.router.route(packet) + except Exception as exc: # noqa: BLE001 + return JSONResponse( + status_code=500, + content={ + "error": str(exc), + "code": "GATEWAY_EXCEPTION", + "trace_id": getattr(exc, "trace_id", ""), + }, + ) + + if response.is_error(): + return JSONResponse( + status_code=500, + content={ + "error": response.payload.get("error", "unknown error"), + "code": response.payload.get("code", "INTERNAL_ERROR"), + "trace_id": response.trace_id, + }, + ) + + return JSONResponse( + status_code=200, + content={"data": response.payload, "trace_id": response.trace_id}, + ) + + +# ===================================================================== +# Stage D.3: EventService 单例 + WebSocket 路由 +# ===================================================================== +# 启动时创建单例 EventService 并订阅 EventManager; +# 之后 ws/* 路由通过 EventService 把事件广播到 /ws/events / /ws/debug 等。 +_event_service = None +_event_service_started = False +_debug_broadcaster_started = False + + +def _get_event_service(): + """懒加载 EventService 单例。""" + global _event_service + if _event_service is None: + try: + from src.web.backend.services.event_service import get_event_service + _event_service = get_event_service() + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'创建 EventService 失败: {e}', e) + except Exception: + pass + _event_service = None + return _event_service + + +@app.on_event("startup") +async def _on_startup() -> None: + """应用启动钩子:启动 EventService + 挂载 ws 路由 + 启动 DebugBroadcaster。""" + global _event_service_started, _debug_broadcaster_started + es = _get_event_service() + if es is not None and not _event_service_started: + try: + es.start() + _event_service_started = True + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'EventService.start 失败: {e}', e) + except Exception: + pass + + # Stage C.1: 挂载 Knowledge REST API + try: + from src.web.backend.api.knowledge import router as knowledge_router + app.include_router(knowledge_router, prefix="/api/knowledge") + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 knowledge 路由失败: {e}', e) + except Exception: + pass + + # 挂载 ws 路由(每个进程只挂一次) + try: + from src.web.backend.ws import router as ws_router, get_loaded_status + # 挂载到 /ws/* 子路径(chat/debug/events 的 router 已包含完整路径) + app.include_router(ws_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 ws 路由失败: {e}', e) + except Exception: + pass + + # Stage C.2: 挂载 /api/schedule 路由 + try: + from src.web.backend.api.schedule import router as schedule_router + app.include_router(schedule_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 schedule 路由失败: {e}', e) + except Exception: + pass + + # 挂载 REST API 路由(debug / knowledge / schedule) + try: + from src.web.backend.api import router as api_router + # 聚合 router 自身为空;子模块 router 内部已定义 prefix='/api/...' + app.include_router(api_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 api 路由失败: {e}', e) + except Exception: + pass + + # Stage C.5 / C.6: 挂载 REST 路由 + try: + from src.web.backend.api.database import router as database_router + app.include_router(database_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 database 路由失败: {e}', e) + except Exception: + pass + + try: + from src.web.backend.api.creative import router as creative_router + app.include_router(creative_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 creative 路由失败: {e}', e) + except Exception: + pass + + # Stage B.3 + B.4: 情感雷达 + 话题时间线路由 + try: + from src.web.backend.api.emotion import router as emotion_router + app.include_router(emotion_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 emotion 路由失败: {e}', e) + except Exception: + pass + + try: + from src.web.backend.api.memory import router as memory_router + app.include_router(memory_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 memory 路由失败: {e}', e) + except Exception: + pass + + # Stage C.3 / C.4 / 验收 P0 3.2: 挂载 NPS + Events REST 路由 + try: + from src.web.backend.api.nps import router as nps_router + app.include_router(nps_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 nps 路由失败: {e}', e) + except Exception: + pass + + try: + from src.web.backend.api.event import router as event_router + app.include_router(event_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 event 路由失败: {e}', e) + except Exception: + pass + + # v3.1.0: 挂载 LLM 路由(GET /api/llm/config, POST /api/llm/test) + # 注:router 自身已带 prefix="/api/llm",main.py 不再叠加 + try: + from src.web.backend.api.llm import router as llm_router + app.include_router(llm_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 llm 路由失败: {e}', e) + except Exception: + pass + + # v3.1.0: 挂载 chat_sessions 路由(GET/POST/PATCH/DELETE /api/chat/sessions) + # 注:router 自身已带 prefix="/api/chat/sessions",main.py 不再叠加 + try: + from src.web.backend.api.chat_sessions import router as chat_sessions_router + app.include_router(chat_sessions_router) + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'挂载 chat_sessions 路由失败: {e}', e) + except Exception: + pass + + # Phase 6: 初始化 v4.0 NeoApp(失败不影响 v3) + if NeoApp is not None and set_neo_app is not None: + global _neo_app + try: + _neo_app = NeoApp() + set_neo_app(_neo_app) + await _neo_app.initialize() + except Exception as e: # noqa: BLE001 + _neo_app = None + try: + set_neo_app(None) + except Exception: + pass + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'NeoApp 初始化失败: {e}', e) + except Exception: + pass + + # Stage D.4: 启动 DebugBroadcaster,把 DebugLogger 推送到 /ws/debug + if not _debug_broadcaster_started: + try: + import asyncio + loop = asyncio.get_event_loop() + from src.web.backend.ws.manager import get_connection_manager + from src.web.backend.ws.debug_broadcaster import get_debug_broadcaster + broadcaster = get_debug_broadcaster() + broadcaster.attach( + manager=get_connection_manager(), + event_service=es, + loop=loop, + ) + _debug_broadcaster_started = True + except Exception as e: # noqa: BLE001 + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_error('main', f'DebugBroadcaster 启动失败: {e}', e) + except Exception: + pass + + +@app.on_event("shutdown") +async def _on_shutdown() -> None: + """应用关闭钩子。""" + global _event_service_started, _debug_broadcaster_started, _neo_app + # Phase 6: 关闭 v4.0 NeoApp(失败忽略) + if _neo_app is not None: + try: + await _neo_app.shutdown() + except Exception: + pass + _neo_app = None + try: + set_neo_app(None) + except Exception: + pass + if _event_service is not None and _event_service_started: + try: + _event_service.stop() + except Exception: + pass + _event_service_started = False + # 关闭 DebugBroadcaster + try: + from src.web.backend.ws.debug_broadcaster import get_debug_broadcaster + get_debug_broadcaster().detach() + except Exception: + pass + _debug_broadcaster_started = False + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/src/web/backend/schemas/__init__.py b/src/web/backend/schemas/__init__.py new file mode 100644 index 0000000..fb3e2c8 --- /dev/null +++ b/src/web/backend/schemas/__init__.py @@ -0,0 +1,90 @@ +""" +Schemas package - 统一导出所有 Pydantic v2 模型。 +Schemas package - re-export all Pydantic v2 models. + +Stage A.3: 供 /api/* 路由按需导入: + from src.web.backend.schemas import ChatRequest, ChatResponse + +Stage B.3: 新增 EmotionResponse(情感雷达响应)。 +""" + +from __future__ import annotations + +from .chat import ChatChunk, ChatRequest, ChatResponse +from .creative import ( + AdvanceRequest, + AdvanceResponse, + Chapter, + Character, + CharacterRelation, + CreativeAdvanceRequest, + CreativeAdvanceResponse, + CreativeProject, + CreativeProjectCreate, + CreativeProjectDetail, + CreativeProjectListResponse, + CreativeProjectSummary, + StoryBible, +) +from .database import ( + CountResponse, + DeleteResponse, + TableDataResponse, + TablesListResponse, +) +from .emotion import EmotionLatestResponse, EmotionResponse, PLUTCHIK_KEYS +from .event import EventDTO, EventListResponse +from .knowledge import ( + KnowledgeDefinition, + KnowledgeEntity, + KnowledgeSearchRequest, + KnowledgeSearchResponse, +) +from .memory import ( + LoopContextItem, + LoopContextResponse, + TimelineLink, + TimelineNode, + TimelineResponse, +) +from .schedule import ScheduleCreate, ScheduleDTO, ScheduleUpdate + +__all__ = [ + "ChatRequest", + "ChatChunk", + "ChatResponse", + "CountResponse", + "CreativeProject", + "CreativeProjectCreate", + "CreativeProjectListResponse", + "CreativeProjectSummary", + "CreativeProjectDetail", + "CreativeAdvanceRequest", + "CreativeAdvanceResponse", + "AdvanceRequest", + "AdvanceResponse", + "StoryBible", + "Character", + "CharacterRelation", + "Chapter", + "DeleteResponse", + "EmotionResponse", + "EmotionLatestResponse", + "PLUTCHIK_KEYS", + "EventDTO", + "EventListResponse", + "KnowledgeEntity", + "KnowledgeDefinition", + "KnowledgeSearchRequest", + "KnowledgeSearchResponse", + "TableDataResponse", + "TablesListResponse", + "TimelineNode", + "TimelineLink", + "TimelineResponse", + "LoopContextItem", + "LoopContextResponse", + "ScheduleDTO", + "ScheduleCreate", + "ScheduleUpdate", +] diff --git a/src/web/backend/schemas/chat.py b/src/web/backend/schemas/chat.py new file mode 100644 index 0000000..c9c3fe4 --- /dev/null +++ b/src/web/backend/schemas/chat.py @@ -0,0 +1,58 @@ +""" +Chat schemas - Pydantic v2 models for chat endpoints. +聊天相关的 Pydantic v2 模型。 + +Stage A.3: 为 /api/chat 提供请求/响应/流式 chunk 的契约。 +""" + +from __future__ import annotations + +from typing import Dict, Optional + +from pydantic import BaseModel, Field + + +class ChatRequest(BaseModel): + """ + 聊天请求体。 + Attributes: + user_input: 用户输入文本(必填,1-10000 字符) + context: 可选上下文(不直接传给 agent,保留供未来扩展) + stream: 是否走流式响应(Stage B.2 才真正使用) + """ + user_input: str = Field(..., min_length=1, max_length=10000) + context: Optional[Dict] = None + stream: bool = False + + +class ChatChunk(BaseModel): + """ + 流式响应 chunk。 + Attributes: + type: chunk 类型(chunk / done / error);用于客户端按类型分发 + chunk: 增量文本片段 + done: 是否为最后一个 chunk + message_id: 消息 ID(首个 chunk 必带,便于客户端关联) + """ + type: str = Field(default="chunk", description="chunk 类型:chunk / done / error") + chunk: str + done: bool = False + message_id: Optional[str] = None + + +class ChatResponse(BaseModel): + """ + 同步聊天响应。 + Attributes: + content: 完整回复文本 + message_id: 消息唯一标识 + emotion: 情绪标签/分值(可选) + timestamp: ISO 8601 时间戳 + """ + content: str + message_id: str + emotion: Optional[Dict] = None + timestamp: str + + +__all__ = ["ChatRequest", "ChatChunk", "ChatResponse"] diff --git a/src/web/backend/schemas/creative.py b/src/web/backend/schemas/creative.py new file mode 100644 index 0000000..c254c16 --- /dev/null +++ b/src/web/backend/schemas/creative.py @@ -0,0 +1,302 @@ +""" +Creative schemas - Pydantic v2 models for creative project endpoints. +长期创作项目相关的 Pydantic v2 模型。 + +Stage C.6: 为 /api/creative/* 提供 list/detail/advance 契约。 +字段命名与前端 ``src/web/frontend/src/types/creative.ts`` 对齐。 +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# ===================================================================== +# Story Bible 子结构 +# ===================================================================== + + +class CharacterRelation(BaseModel): + """ + 角色关系条目。 + Attributes: + target: 目标角色名 + relation: 关系描述 + """ + target: str = "" + relation: str = "" + + +class Character(BaseModel): + """ + 角色条目。 + Attributes: + uuid: 角色 uuid + name: 角色名 + role: 角色定位(主角/配角/...) + description: 角色描述 + relations: 与其他角色的关系 + """ + uuid: str = "" + name: str = "" + role: str = "" + description: str = "" + relations: List[CharacterRelation] = Field(default_factory=list) + + +class Chapter(BaseModel): + """ + 章节条目。 + Attributes: + uuid: 章节 uuid + chapter_title: 章节标题 + word_count: 字数 + content: 章节正文 + """ + uuid: str = "" + chapter_title: str = "" + word_count: int = 0 + content: str = "" + + +class StoryBible(BaseModel): + """ + Story Bible(项目级线索状态机)。 + Attributes: + characters: 角色列表 + worldview: 世界观描述 + chapters: 章节列表 + mainline_direction: 主线方向(与 creative_writer 对齐) + active_themes: 当前活跃主题 + unresolved_threads: 未解线索 + resolved_threads: 已解线索 + important_facts: 重要事实 + next_direction: 下一段方向 + recent_keywords: 最近关键词 + """ + characters: List[Character] = Field(default_factory=list) + worldview: str = "" + chapters: List[Chapter] = Field(default_factory=list) + mainline_direction: str = "" + active_themes: List[str] = Field(default_factory=list) + unresolved_threads: List[str] = Field(default_factory=list) + resolved_threads: List[str] = Field(default_factory=list) + important_facts: List[str] = Field(default_factory=list) + next_direction: str = "" + recent_keywords: List[str] = Field(default_factory=list) + + +# ===================================================================== +# Project 模型 +# ===================================================================== + + +class CreativeProject(BaseModel): + """ + 创作项目(详情/统一形态)。 + Attributes: + uuid: 项目 uuid + title: 标题 + status: 业务状态(active / paused / completed / drafting / finished) + word_count: 累计字数 + current_chars: 当前字数(与数据库 current_chars 一致) + target_chars: 目标字数 + chapter_count: 章节数量 + work_type: 体裁 + premise: 故事前提 + tone: 语气 + point_of_view: 视角 + inspiration_source: 灵感来源 + outline: 大纲 + characters: 角色列表 + draft_chunks: 续写片段 + next_advance_at: 下次续写时间 + last_advanced_at: 上次续写时间 + created_at: ISO 8601 创建时间 + updated_at: ISO 8601 更新时间 + story_bible: Story Bible(详情场景下填充) + """ + uuid: str + title: str + status: str = "drafting" + word_count: int = 0 + current_chars: int = 0 + target_chars: int = 5000 + chapter_count: int = 0 + work_type: str = "短篇" + premise: str = "" + tone: str = "" + point_of_view: str = "第一人称" + inspiration_source: str = "" + outline: List[Any] = Field(default_factory=list) + characters: List[Any] = Field(default_factory=list) + draft_chunks: List[Any] = Field(default_factory=list) + next_advance_at: Optional[str] = None + last_advanced_at: Optional[str] = None + created_at: str = "" + updated_at: str = "" + story_bible: Optional[StoryBible] = None + + +class CreativeProjectCreate(BaseModel): + """ + 创建创作项目请求体(POST /api/creative/projects)。 + Attributes: + title: 标题(必填,1-200 字符) + work_type: 体裁(短篇/中篇/长篇/散文/诗歌) + premise: 故事前提 + tone: 语气 + point_of_view: 视角 + target_chars: 目标字数 + inspiration_source: 灵感来源描述 + outline: 大纲(每项一段话) + characters: 角色列表 + """ + title: str = Field(..., min_length=1, max_length=200) + work_type: str = Field("短篇", max_length=50) + premise: str = Field("", max_length=2000) + tone: str = Field("", max_length=200) + point_of_view: str = Field("第一人称", max_length=50) + target_chars: int = Field(5000, ge=100, le=1_000_000) + inspiration_source: str = Field("", max_length=1000) + outline: List[str] = Field(default_factory=list) + characters: List[Dict[str, Any]] = Field(default_factory=list) + + +# ===================================================================== +# Advance(续写)模型 +# ===================================================================== + + +class AdvanceRequest(BaseModel): + """ + 续写请求体。 + Attributes: + user: 触发用户标识(默认 "default") + hint: 可选风格/方向提示(保留给未来扩展) + advance_async: 是否异步触发(默认 True) + - True: 立即返回 task_id,异步生成内容并通过 'creative_progress' 事件推送 + - False: 同步触发,更新 last_advanced_at 后立即返回 + """ + user: str = Field("default", max_length=100) + hint: Optional[str] = Field(None, max_length=2000) + advance_async: bool = True + + +class AdvanceResponse(BaseModel): + """ + 续写响应。 + Attributes: + project_uuid: 项目 uuid + task_id: 异步任务 id(同步模式时为空) + status: 任务状态(queued / completed / failed) + last_advanced_at: 上次续写时间(同步模式下为写入时间) + project_status: 续写后项目状态 + message: 人类可读的描述 + """ + project_uuid: str + task_id: str = "" + status: str = "completed" + last_advanced_at: Optional[str] = None + project_status: str = "drafting" + message: str = "" + + +# ===================================================================== +# 列表响应包装 +# ===================================================================== + + +class CreativeProjectListResponse(BaseModel): + """ + 创作项目列表响应。 + Attributes: + projects: 项目列表 + total: 总条数 + """ + projects: List[CreativeProject] = Field(default_factory=list) + total: int = 0 + + +# ===================================================================== +# 兼容旧版命名(保留以防其他地方引用) +# ===================================================================== + + +class CreativeProjectSummary(BaseModel): + """ + 创作项目摘要(列表项)。 + Attributes: + uuid: 项目唯一标识 + title: 标题 + status: 业务状态(drafting / finished / paused) + chapter_count: 章节数量(从 draft_chunks 派生) + created_at: ISO 8601 创建时间 + updated_at: ISO 8601 更新时间 + """ + uuid: str + title: str + status: str + chapter_count: int = 0 + created_at: str + updated_at: str + + +class CreativeProjectDetail(CreativeProjectSummary): + """ + 创作项目详情。 + Attributes: + story_bible: Story Bible 全文(含 mainline_direction / active_themes / 等) + chapters: 章节列表(从 draft_chunks 派生) + memory_pool_summary: 记忆池摘要(计数 + 最新若干条) + """ + story_bible: Dict[str, Any] = Field(default_factory=dict) + chapters: List[Dict[str, Any]] = Field(default_factory=list) + memory_pool_summary: Dict[str, Any] = Field(default_factory=dict) + + +class CreativeAdvanceRequest(BaseModel): + """ + 续写请求体(payload 可选)。 + Attributes: + user: 触发用户标识(默认 "default") + hint: 可选风格/方向提示(保留给未来扩展) + """ + user: str = "default" + hint: Optional[str] = None + + +class CreativeAdvanceResponse(BaseModel): + """ + 续写响应。 + Attributes: + project_uuid: 项目 uuid + new_chapter: 续写后追加的新章节(content / created_at / char_count) + project_status: 续写后项目状态 + """ + project_uuid: str + new_chapter: Dict[str, Any] + project_status: str = "drafting" + + +__all__ = [ + # 主体模型 + "CreativeProject", + "CreativeProjectCreate", + "CreativeProjectListResponse", + # Story Bible + "StoryBible", + "Character", + "CharacterRelation", + "Chapter", + # Advance + "AdvanceRequest", + "AdvanceResponse", + # 兼容旧名 + "CreativeProjectSummary", + "CreativeProjectDetail", + "CreativeAdvanceRequest", + "CreativeAdvanceResponse", +] diff --git a/src/web/backend/schemas/database.py b/src/web/backend/schemas/database.py new file mode 100644 index 0000000..1171126 --- /dev/null +++ b/src/web/backend/schemas/database.py @@ -0,0 +1,69 @@ +""" +Database schemas - Pydantic v2 models for database management endpoints. +数据库管理端点相关的 Pydantic v2 模型。 + +Stage C.5: 为 /api/database/* 提供 list/count/query/delete 契约。 +字段命名与前端 ``src/web/frontend/src/types/database.ts`` 对齐。 +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + + +class TablesListResponse(BaseModel): + """ + 白名单表列表响应。 + Attributes: + tables: 表名列表(仅白名单中实际存在的表) + total: 表数量 + """ + tables: List[str] = Field(default_factory=list) + total: int = 0 + + +class TableDataResponse(BaseModel): + """ + 业务表数据查询响应。 + Attributes: + table: 表名 + rows: 行数据列表(每行为 dict) + total: 本次返回的行数 + """ + table: str + rows: List[Dict[str, Any]] = Field(default_factory=list) + total: int = 0 + + +class CountResponse(BaseModel): + """ + 表记录数响应。 + Attributes: + table: 表名 + total: 该表的总记录数 + """ + table: str + total: int = 0 + + +class DeleteResponse(BaseModel): + """ + 删除记录响应。 + Attributes: + table: 表名 + uuid: 被删除记录的 uuid + deleted: 是否成功删除 + """ + table: str + uuid: str + deleted: bool = False + + +__all__ = [ + "TablesListResponse", + "TableDataResponse", + "CountResponse", + "DeleteResponse", +] diff --git a/src/web/backend/schemas/debug.py b/src/web/backend/schemas/debug.py new file mode 100644 index 0000000..f61fe43 --- /dev/null +++ b/src/web/backend/schemas/debug.py @@ -0,0 +1,46 @@ +""" +Debug schemas - Pydantic v2 models for debug log endpoints. +调试日志相关的 Pydantic v2 模型。 + +Stage B.5: 为 /api/debug/* 提供日志条目/列表响应契约。 +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class DebugLogEntry(BaseModel): + """ + 调试日志条目。 + Attributes: + timestamp: ISO 8601 时间戳 + module: 模块名 + level: 日志级别(INFO / WARN / ERROR / MODULE / PROMPT / REQUEST / RESPONSE) + message: 消息主体(与 log_* 入口参数一致;可为空以便承载结构化日志) + extra: 额外结构化数据(可包含 file_info / traceback / payload 等) + """ + timestamp: str + module: str + level: str + message: str = "" + extra: Optional[Dict[str, Any]] = None + + +class DebugLogListResponse(BaseModel): + """ + 调试日志列表响应。 + Attributes: + logs: 日志条目列表 + total: 总匹配条数 + """ + logs: List[DebugLogEntry] = Field(default_factory=list) + total: int = 0 + + +__all__ = [ + "DebugLogEntry", + "DebugLogListResponse", +] diff --git a/src/web/backend/schemas/emotion.py b/src/web/backend/schemas/emotion.py new file mode 100644 index 0000000..9cb5681 --- /dev/null +++ b/src/web/backend/schemas/emotion.py @@ -0,0 +1,94 @@ +""" +Emotion schemas - Pydantic v2 models for emotion endpoints. +情感分析相关的 Pydantic v2 模型。 + +Stage B.3: 为 /api/emotion/* 提供情感雷达所需的响应契约。 +对齐前端 EmotionPanel.tsx 的 EmotionData 类型。 + +`cumulative` 字段映射"关系状态"维度(EmotionRelationshipAnalyzer +累加评分),`plutchik` 字段映射"当下情绪"维度(PlutchikEmotionWheel +8 维归一化强度)。`last_message` 为最近一次参与情感分析的用户消息。 +""" + +from __future__ import annotations + +from typing import Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# ---------------------------------------------------------------------- +# 常量:Plutchik 8 维 +# ---------------------------------------------------------------------- +PLUTCHIK_KEYS = ( + 'joy', 'trust', 'fear', 'surprise', + 'sadness', 'disgust', 'anger', 'anticipation', +) + + +def _default_eight() -> Dict[str, float]: + """8 维默认值 0.0。""" + return {k: 0.0 for k in PLUTCHIK_KEYS} + + +# ---------------------------------------------------------------------- +# Pydantic v2 模型 +# ---------------------------------------------------------------------- +class EmotionResponse(BaseModel): + """ + 最新情感分析响应(Stage B.3 雷达后端契约)。 + + Attributes: + cumulative: 累加评分(Plutchik 8 维)。 + 由 EmotionRelationshipAnalyzer 的累加评分映射而来, + 代表"关系状态"维度。 + plutchik: Plutchik 8 维情绪强度(0-1 归一化)。 + 由 PlutchikEmotionWheel 计算,代表"当下情绪"维度。 + last_message: 最近一次参与情感分析的用户消息原文。 + 没有则为空字符串。 + timestamp: ISO 8601 时间戳(情感分析完成时刻)。 + 没有则为空字符串。 + user_id: 用户标识(请求里透传,默认 "default")。 + historical_max: 可选历史最高(淡色背景叠加)。 + + Notes: + - 8 维字典使用 default_factory 兜底;缺键自动补 0.0。 + - 兼容 Pydantic v2 from_attributes,便于直接以 ORM 行/类填充。 + """ + model_config = ConfigDict(from_attributes=True) + + cumulative: Dict[str, float] = Field( + default_factory=_default_eight, + description="累加评分(8 维 0-1 归一化)", + ) + plutchik: Dict[str, float] = Field( + default_factory=_default_eight, + description="Plutchik 8 维当下情绪(0-1 归一化)", + ) + last_message: str = Field( + default="", + description="最近一次参与分析的用户消息", + ) + timestamp: str = Field( + default="", + description="ISO 8601 时间戳", + ) + user_id: str = Field( + default="default", + description="用户标识", + ) + historical_max: Optional[Dict[str, float]] = Field( + default=None, + description="可选历史最高(8 维)", + ) + + +# 兼容历史导入:保留旧名字(Stage A.3 早期曾用过) +EmotionLatestResponse = EmotionResponse + + +__all__ = [ + "PLUTCHIK_KEYS", + "EmotionResponse", + "EmotionLatestResponse", +] diff --git a/src/web/backend/schemas/event.py b/src/web/backend/schemas/event.py new file mode 100644 index 0000000..6ef6ce2 --- /dev/null +++ b/src/web/backend/schemas/event.py @@ -0,0 +1,74 @@ +""" +Event schemas - Pydantic v2 models for event listing. +事件查询相关的 Pydantic v2 模型。 + +Stage A.3: 为 /api/events 提供事件列表的契约。 + +F.7.2: 为对齐实际 events 表(event_id / event_type / source / tags), +新增可选字段并保持原 uuid / type 向后兼容。Pydantic v2 用 +``model_config = ConfigDict(populate_by_name=True)`` 同时接受旧字段。 +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class EventDTO(BaseModel): + """ + 事件数据传输对象。 + Attributes: + uuid: 事件唯一标识(旧版别名,建议新代码使用 event_id) + type: 事件类型(旧版别名,建议新代码使用 event_type) + status: 事件状态(如 'pending' / 'sent' / 'failed') + payload: 事件 payload 字典 + created_at: ISO 8601 时间戳 + + 扩展字段(F.7.2,对齐 events 表): + event_id: 实际表主键,Optional[str] = None + event_type: 实际表的 event_type 列,Optional[str] = None + source: 事件来源(如 'scheduler' / 'proactive' / 'manual'),Optional[str] = None + tags: 标签列表,Optional[List[str]] = None + """ + # 旧字段(保持向后兼容,client 仍在用) + uuid: str + type: str + status: str + payload: Dict + created_at: str + + # 新增字段(对齐实际表,原 uuid 字段保留) + event_id: Optional[str] = Field( + default=None, + description="events.event_id 主键;为对齐实际表而扩展,uuid 仍可用", + ) + event_type: Optional[str] = Field( + default=None, + description="events.event_type 列;为对齐实际表而扩展,type 仍可用", + ) + source: Optional[str] = Field( + default=None, + description="事件来源(scheduler / proactive / manual / ...)", + ) + tags: Optional[List[str]] = Field( + default=None, + description="事件标签列表(events.metadata JSON 内的 tags 字段)", + ) + + model_config = ConfigDict(populate_by_name=True) + + +class EventListResponse(BaseModel): + """ + 事件列表响应。 + Attributes: + events: 事件列表 + total: 总条数(用于分页) + """ + events: List[EventDTO] + total: int + + +__all__ = ["EventDTO", "EventListResponse"] diff --git a/src/web/backend/schemas/knowledge.py b/src/web/backend/schemas/knowledge.py new file mode 100644 index 0000000..d2ab637 --- /dev/null +++ b/src/web/backend/schemas/knowledge.py @@ -0,0 +1,116 @@ +""" +Knowledge schemas - Pydantic v2 models for knowledge base endpoints. +知识库相关的 Pydantic v2 模型。 + +Stage A.3: 为 /api/knowledge/* 提供实体/定义的契约。 +Stage C.1: 新增 KnowledgeCreate / KnowledgeUpdate,提供前端 CRUD 强约束。 +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class KnowledgeEntity(BaseModel): + """ + 知识库实体。 + Attributes: + uuid: 实体唯一标识 + name: 原始名称 + normalized_name: 归一化名称 + created_at: ISO 8601 时间戳 + """ + uuid: str + name: str + normalized_name: str + created_at: str + + +class KnowledgeDefinition(BaseModel): + """ + 知识库实体定义(实体下属的事实/属性/关系)。 + Attributes: + id: 自增主键 + entity_uuid: 所属实体 uuid + content: 定义内容 + type: 定义类型(fact / attribute / relation ...) + confidence: 置信度(0.0-1.0) + priority: 优先级 + """ + id: int + entity_uuid: str + content: str + type: str + confidence: float + priority: int + + +class KnowledgeSearchRequest(BaseModel): + """ + 知识库搜索请求。 + Attributes: + q: 搜索关键词(必填,至少 1 字符) + limit: 返回上限(默认 20) + """ + q: str = Field(..., min_length=1) + limit: int = 20 + + +class KnowledgeSearchResponse(BaseModel): + """ + 知识库搜索响应。 + Attributes: + results: 实体列表 + total: 总匹配条数 + """ + results: List[KnowledgeEntity] + total: int + + +# ===================================================================== +# Stage C.1: Web 端 CRUD 强约束 +# ===================================================================== + +class KnowledgeCreate(BaseModel): + """ + 创建知识实体的请求体(Stage C.1)。 + + Attributes: + entity_name: 实体名称(必填,1-100 字符) + category: 分类(可选,<= 64 字符;将作为 entity_definitions.type) + description: 描述(可选,<= 4000 字符;将作为 entity_definitions.content) + related_info: 相关信息字典(可选,key->value 写入 entity_related_info) + """ + entity_name: str = Field(..., min_length=1, max_length=100) + category: Optional[str] = Field(default=None, max_length=64) + description: Optional[str] = Field(default=None, max_length=4000) + related_info: Optional[Dict[str, Any]] = None + + +class KnowledgeUpdate(BaseModel): + """ + 更新知识实体的请求体(Stage C.1)。 + + 所有字段均可选;至少要传一个字段。 + Attributes: + entity_name: 新名称(1-100 字符) + category: 新分类(<= 64 字符) + description: 新描述(<= 4000 字符) + related_info: 替换该实体的全部相关信息 + """ + entity_name: Optional[str] = Field(default=None, min_length=1, max_length=100) + category: Optional[str] = Field(default=None, max_length=64) + description: Optional[str] = Field(default=None, max_length=4000) + related_info: Optional[Dict[str, Any]] = None + + +__all__ = [ + "KnowledgeEntity", + "KnowledgeDefinition", + "KnowledgeSearchRequest", + "KnowledgeSearchResponse", + "KnowledgeCreate", + "KnowledgeUpdate", +] diff --git a/src/web/backend/schemas/memory.py b/src/web/backend/schemas/memory.py new file mode 100644 index 0000000..0335554 --- /dev/null +++ b/src/web/backend/schemas/memory.py @@ -0,0 +1,109 @@ +""" +Memory schemas - Pydantic v2 models for /api/memory/* endpoints. +记忆 / 话题时间线相关的 Pydantic v2 模型。 + +Stage B.4: 为 /api/memory/timeline 与 /api/memory/loop/{uuid}/context 提供契约。 +- 数据源:``open_loops`` 表(由 OpenLoopTracker 写入)。 +- 失败降级:节点为空 / 链接为空 / 上下文为空 list,不抛 5xx。 +""" + +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class TimelineNode(BaseModel): + """ + 话题时间线节点(open_loops 一行)。 + + Attributes: + uuid: 话题唯一标识(open_loops.uuid) + topic: 话题标题(open_loops.topic) + date: 节点日期(YYYY-MM-DD,从 raised_at 截取前 10 位) + status: 话题状态('open' / 'closed'/'resolved' 等) + category: 话题分类(暂为 '其他',留给前端做兼容) + mention_count: 提及次数(open_loops.mention_count) + keywords: 关联关键词列表(open_loops.related_keywords_json) + """ + uuid: str + topic: str + date: str + status: str = "open" + category: str = "其他" + mention_count: int = 0 + keywords: List[str] = Field(default_factory=list) + + +class TimelineLink(BaseModel): + """ + 话题时间线链接(节点之间的关联关系)。 + + Attributes: + source: 源节点 uuid + target: 目标节点 uuid + relation: 关系类型('keyword' / 'time' / 'manual' 等,默认 'keyword') + """ + source: str + target: str + relation: str = "keyword" + + +class TimelineResponse(BaseModel): + """ + 时间线响应(ECharts 关系图 / 散点图所需最小集)。 + + Attributes: + nodes: 节点列表 + links: 链接列表 + days: 查询时间范围(天),默认 7 + total: 节点数量(便于前端展示) + """ + nodes: List[TimelineNode] = Field(default_factory=list) + links: List[TimelineLink] = Field(default_factory=list) + days: int = 7 + total: int = 0 + + +class LoopContextItem(BaseModel): + """ + 话题上下文条目(单条对话消息 / 锚点)。 + + Attributes: + role: 角色('user' / 'assistant' / 'system') + content: 文本内容 + timestamp: ISO 8601 时间戳(可空) + source: 数据来源('short_term_memory' / 'open_loop_context' / 'fallback') + """ + role: str = "system" + content: str = "" + timestamp: Optional[str] = None + source: str = "short_term_memory" + + +class LoopContextResponse(BaseModel): + """ + 话题上下文响应。 + + Attributes: + uuid: 话题 uuid(路径参数透传) + topic: 话题标题(找不到时为空字符串) + status: 话题状态 + items: 上下文条目列表(失败 / 未找到时为空 list) + total: 条目数量 + """ + uuid: str + topic: str = "" + status: str = "unknown" + items: List[LoopContextItem] = Field(default_factory=list) + total: int = 0 + + +__all__ = [ + "TimelineNode", + "TimelineLink", + "TimelineResponse", + "LoopContextItem", + "LoopContextResponse", +] diff --git a/src/web/backend/schemas/schedule.py b/src/web/backend/schemas/schedule.py new file mode 100644 index 0000000..6277917 --- /dev/null +++ b/src/web/backend/schemas/schedule.py @@ -0,0 +1,73 @@ +""" +Schedule schemas - Pydantic v2 models for schedule endpoints. +日程相关的 Pydantic v2 模型。 + +Stage A.3: 为 /api/schedules/* 提供日程项/创建/更新的契约。 +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class ScheduleDTO(BaseModel): + """ + 日程数据传输对象(只读视图)。 + Attributes: + id: 日程主键 + title: 标题 + description: 详细描述 + start_time: ISO 8601 开始时间 + end_time: ISO 8601 结束时间(可选) + priority: 优先级(low / normal / high) + status: 状态(pending / done / cancelled) + schedule_type: 类型(personal / work / birthday ...) + """ + id: int + title: str + description: Optional[str] = None + start_time: str + end_time: Optional[str] = None + priority: str = "normal" + status: str = "pending" + schedule_type: str = "personal" + + +class ScheduleCreate(BaseModel): + """ + 创建日程请求。 + Attributes: + title: 标题(必填,1-200 字符) + description: 详细描述 + start_time: ISO 8601 开始时间 + end_time: ISO 8601 结束时间 + priority: 优先级 + schedule_type: 类型 + """ + title: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + start_time: str + end_time: Optional[str] = None + priority: str = "normal" + schedule_type: str = "personal" + + +class ScheduleUpdate(BaseModel): + """ + 更新日程请求(部分字段更新)。 + Attributes: + id: 必填,待更新日程主键 + title / description / start_time / end_time / priority / status: 可选更新 + """ + id: int + title: Optional[str] = None + description: Optional[str] = None + start_time: Optional[str] = None + end_time: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + + +__all__ = ["ScheduleDTO", "ScheduleCreate", "ScheduleUpdate"] diff --git a/src/web/backend/services/__init__.py b/src/web/backend/services/__init__.py new file mode 100644 index 0000000..371218b --- /dev/null +++ b/src/web/backend/services/__init__.py @@ -0,0 +1,35 @@ +""" +Services package - 统一导出所有 service 及其全局单例。 +Services package - re-export all services and their singletons. + +Stage A.3: 供 /api/* 路由 / main.py 统一导入: + from src.web.backend.services import chat_service, database_service, event_service + +Stage B.3: 新增 emotion_service(情感雷达后端)。 +""" + +from __future__ import annotations + +from .chat_service import ChatService, chat_service +from .creative_service import CreativeService, creative_service +from .database_service import DatabaseService, database_service +from .emotion_service import EmotionService, emotion_service +from .event_service import EventService, event_service +from .neo_bridge import get_neo_app, get_neo_router, neo_request, set_neo_app + +__all__ = [ + "ChatService", + "chat_service", + "CreativeService", + "creative_service", + "DatabaseService", + "database_service", + "EmotionService", + "emotion_service", + "EventService", + "event_service", + "get_neo_app", + "get_neo_router", + "neo_request", + "set_neo_app", +] diff --git a/src/web/backend/services/chat_service.py b/src/web/backend/services/chat_service.py new file mode 100644 index 0000000..f34cee1 --- /dev/null +++ b/src/web/backend/services/chat_service.py @@ -0,0 +1,543 @@ +""" +Chat service module. +聊天服务模块 - 包装 ChatAgent,提供 Web 后端的同步/异步入口。 + +Stage A.3: 同步接口 +Stage B.2: 异步流式接口 (chat_stream 返回字符串,stream_chat 返回 ChatChunk) +Stage B.3 / B.4: 在流式回复完成时,向 EventService 派发 emotion_update / timeline_update +事件,供前端 /ws/events 订阅并刷新 EmotionPanel / TimelineCanvas。 +v3.1.0: 注入 ChatSessionRepository 持久化钩子(user/assistant 消息入库 + emotion_json 异步更新)。 +""" + +from __future__ import annotations + +import asyncio +import re +import sys +import warnings +from pathlib import Path +from typing import Any, AsyncIterator, Dict, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + + +# ---------------------------------------------------------------------- +# 工具:检查方法签名是否接受某个 keyword 参数 +# ---------------------------------------------------------------------- +def _method_accepts_kwarg(method: Any, kwarg_name: str) -> bool: + """ + 检查 callable 是否在其签名中接受 ``kwarg_name``。 + + 用途:ChatService.chat_stream 在调用 agent.chat_stream 时做向后兼容检测 + —— 当 agent 仍是老式签名 (user_input, context=None) 时,避免 + TypeError: got an unexpected keyword argument 'cancel_event'。 + + Returns: + True 表示方法接受该 kwarg(或 **kwargs);False 表示不接收。 + """ + try: + import inspect # 延迟导入,inspect 是标准库但避免无谓加载 + sig = inspect.signature(method) + except (TypeError, ValueError): + # 内置 / C 实现的方法可能无法 inspect;保守地当作不接受 + return False + + if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()): + return True # 接受 **kwargs ⇒ 任何 kwarg 都 OK + return kwarg_name in sig.parameters + + +class ChatService: + """ + 聊天服务(Web 后端侧) + + 设计要点: + 1. 懒加载 ChatAgent:避免 Web 启动时强制初始化 LLM / DB + 2. try/except 保护:LLM 库缺失或配置错误时不阻断 Web 启动 + 3. chat() 委托给 agent.chat();chat_stream() 透传字符串 chunk; + stream_chat() 在 Stage B.2 新增,包装 chat_stream 为 Pydantic ChatChunk + 4. 全局单例:chat_service = ChatService() + """ + + def __init__(self, agent: Optional[Any] = None) -> None: + """ + 初始化服务。 + + Args: + agent: 可选,预注入的 ChatAgent 实例。为 None 时延后到 + get_agent() 调用时再尝试实例化。 + """ + self._agent: Optional[Any] = None + + # v3.1.0: 当前绑定会话(由 set_session_id() 注入) + self._session_id: Optional[int] = None + # v3.1.0: 刚写入的 assistant 消息 id(供 on_emotion 异步更新 emotion_json) + self._last_assistant_message_id: Optional[int] = None + + if agent is not None: + self._agent = agent + else: + # 尝试立即懒加载;若失败则保持 None + self._try_init_agent() + + # ------------------------------------------------------------------ + # v3.1.0: 会话绑定 + # ------------------------------------------------------------------ + def set_session_id(self, session_id: Optional[int]) -> None: + """ + 显式绑定一个 session_id;之后 chat_stream 内会自动把 user/assistant + 消息写到该 session 下。 + + 传 None 时解绑。 + """ + if session_id is None: + self._session_id = None + return + try: + self._session_id = int(session_id) + except (TypeError, ValueError): + self._session_id = None + + def get_session_id(self) -> Optional[int]: + """返回当前绑定的 session_id(可能为 None)。""" + return self._session_id + + def _get_repo(self) -> Optional[Any]: + """ + 懒加载 ChatSessionRepository;DB 不可用时返回 None(不抛)。 + """ + try: + from src.core.database_manager import DatabaseManager + # 复用 agent 已建好的 db_manager,避免重复打开 + db_manager = None + if self._agent is not None and hasattr(self._agent, "db"): + db_manager = getattr(self._agent, "db", None) + if db_manager is None: + # 注意:保留模块全局单例避免每个 chat 一次新连接 + if not hasattr(self, "_db_singleton") or self._db_singleton is None: + self._db_singleton = DatabaseManager() + db_manager = self._db_singleton + return db_manager.get_chat_session_repository() + except Exception: # noqa: BLE001 + return None + + def on_emotion(self, emotion_payload: Optional[Dict[str, Any]]) -> None: + """ + 由 ws/chat.py 在 emotion_update 帧派发时调用; + 把情感数据写入最近一条 assistant 消息的 emotion_json 字段。 + """ + if not emotion_payload or self._last_assistant_message_id is None: + return + try: + import json as _json + repo = self._get_repo() + if repo is None: + return + repo.update_message_emotion( + self._last_assistant_message_id, + _json.dumps(emotion_payload, ensure_ascii=False), + ) + except Exception: # noqa: BLE001 + pass + + # ------------------------------------------------------------------ + # 内部:懒加载 ChatAgent + # ------------------------------------------------------------------ + def _try_init_agent(self) -> None: + """ + 尝试实例化 ChatAgent,失败时记录 warning 并保持 _agent = None。 + + 设计意图:ChatAgent 初始化会创建 DatabaseManager / LongTermMemoryManager / + SiliconFlowLLM / ProactiveEngine 等重型组件,依赖未安装或环境变量缺失时 + 可能会抛异常。Web 端必须能在 ChatAgent 不可用时继续运行(健康检查等)。 + """ + try: + from src.core.chat_agent import ChatAgent # type: ignore + + self._agent = ChatAgent() + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[ChatService] ChatAgent 初始化失败,将以无 agent 模式运行: {exc}", + RuntimeWarning, + stacklevel=2, + ) + self._agent = None + + # ------------------------------------------------------------------ + # 同步入口 + # ------------------------------------------------------------------ + def get_agent(self) -> Any: + """ + 同步获取(必要时懒加载)ChatAgent 实例。 + + Returns: + ChatAgent 实例;若 LLM / DB 不可用则返回 None。 + """ + if self._agent is None: + self._try_init_agent() + return self._agent + + def set_agent(self, agent: Optional[Any]) -> None: + """ + 注入 agent(用于测试或自定义场景)。 + 传入 None 时,重置为 lazy 模式,下次 get_agent() 时再尝试初始化。 + """ + self._agent = agent + + def chat(self, user_input: str, context: Optional[Dict] = None) -> str: + """ + 同步聊天入口。 + + Args: + user_input: 用户输入 + context: 可选上下文(不直接传给 agent,保留供未来扩展) + + Returns: + 完整回复文本 + + Raises: + RuntimeError: 当 agent 不可用时 + """ + agent = self.get_agent() + if agent is None: + raise RuntimeError( + "ChatAgent not initialized. Check LLM / DB dependencies." + ) + return agent.chat(user_input) + + # ------------------------------------------------------------------ + # 流式入口(Stage B.2 实现) + # ------------------------------------------------------------------ + async def chat_stream( + self, + user_input: str, + context: Optional[Dict] = None, + cancel_event: Optional[Any] = None, + ) -> AsyncIterator[str]: + """ + 流式聊天入口(字符串 chunk 透传)。 + + 降级策略(按优先级): + 1. agent 可用且有 chat_stream → 委托给 agent.chat_stream(user_input, context=context, cancel_event=cancel_event) + 2. agent 可用但没有 chat_stream(或其失败) → + loop.run_in_executor(None, agent.chat, user_input) 拿到完整回复后 + 按"非空白词 + 尾部空白"切片模拟流式输出(40ms 间隔), + 每个 token 前检查 cancel_event.is_set() 实现早退 + 3. agent 不可用 → yield 一次性回执 "[ChatAgent 不可用] 收到: {user_input}" 后退出 + + Args: + user_input: 用户输入 + context: 透传给 agent 的额外上下文(agent 不感知则忽略) + cancel_event: 取消事件(透传给 agent;同步降级路径中在每片前检查) + + Yields: + 字符串 chunk;最坏情况下 yield 一次回执 + """ + # 取得 agent(可能为 None) + try: + agent = self.get_agent() + except Exception as e: # noqa: BLE001 + warnings.warn( + f"[ChatService] get_agent 失败,按无 agent 处理: {e}", + RuntimeWarning, + stacklevel=2, + ) + agent = None + + # ===== v3.1.0: 前置持久化 — 确保 session + 写 user 消息 ===== + # 1) 若 _session_id 仍空但 context 里有 session_id → 使用之 + if self._session_id is None and isinstance(context, dict): + ctx_sid = context.get("session_id") + if ctx_sid is not None: + try: + self.set_session_id(int(ctx_sid)) + except (TypeError, ValueError): + pass + # 2) 仍空 → 自动创建新 session(title 取 user_input 前 30 字) + if self._session_id is None: + try: + repo = self._get_repo() + if repo is not None: + initial_title = (user_input or "新会话")[:30] or "新会话" + new_sid = repo.create(user_id="default", title=initial_title) + if new_sid: + self._session_id = int(new_sid) + except Exception: # noqa: BLE001 + self._session_id = None + # 3) 写 user 消息 + if self._session_id is not None: + try: + repo = self._get_repo() + if repo is not None: + repo.add_message(self._session_id, "user", user_input or "") + self._last_assistant_message_id = None + except Exception: # noqa: BLE001 + pass + + # 收集 assistant 全文(用于流结束后持久化) + collected_chunks: list[str] = [] + collect_failed = False + + # ===== 策略 1:委托给 agent.chat_stream ===== + if agent is not None and hasattr(agent, "chat_stream"): + # 兼容性:仅当 agent 的 chat_stream 接受 cancel_event 时才透传 + _supports_cancel = _method_accepts_kwarg(agent.chat_stream, "cancel_event") + _supports_user_id = _method_accepts_kwarg(agent.chat_stream, "user_id") + try: + kwargs: Dict[str, Any] = {"context": context} + if _supports_cancel and cancel_event is not None: + kwargs["cancel_event"] = cancel_event + if _supports_user_id: + kwargs["user_id"] = "default" + async for chunk in agent.chat_stream(user_input, **kwargs): + collected_chunks.append(chunk or "") + yield chunk + except asyncio.CancelledError: + collect_failed = True + raise + except Exception as e: # noqa: BLE001 + # agent.chat_stream 异常不向消费者直接抛;尝试降级到同步 chat() + collect_failed = True + warnings.warn( + f"[ChatService] agent.chat_stream 失败,降级到同步 chat(): {e}", + RuntimeWarning, + stacklevel=2, + ) + else: + # v3.1.0: 正常结束 → 持久化 assistant + self._persist_assistant("".join(collected_chunks)) + return + # 走到策略 2 + if collect_failed: + # 失败时也尝试持久化已经收集到的部分 + self._persist_assistant("".join(collected_chunks)) + collected_chunks = [] + collect_failed = False + + # ===== 策略 2:run_in_executor + 同步 chat() + 切分 ===== + if agent is not None and hasattr(agent, "chat"): + try: + loop = asyncio.get_event_loop() + full_response = await loop.run_in_executor( + None, agent.chat, user_input + ) + except asyncio.CancelledError: + # 取消信号直接向上传播 + self._persist_assistant("".join(collected_chunks)) + raise + except Exception as e: # noqa: BLE001 + warnings.warn( + f"[ChatService] 同步 chat() 降级失败: {e}", + RuntimeWarning, + stacklevel=2, + ) + # 走到策略 3 + full_response = None + + if full_response: + try: + tokens = re.findall(r"\S+\s*|\s+", full_response) + if not tokens: + tokens = [full_response[i:i + 2] + for i in range(0, len(full_response), 2)] + if not tokens: + tokens = list(full_response) + for token in tokens: + # 取消检查(前置 + 让出事件循环) + if cancel_event is not None and cancel_event.is_set(): + self._persist_assistant("".join(collected_chunks)) + return + # 让出事件循环,便于取消信号传播 + await asyncio.sleep(0) + collected_chunks.append(token or "") + yield token + # 40ms 间隔(30-50ms 区间内),避免网络洪水 + await asyncio.sleep(0.04) + self._persist_assistant("".join(collected_chunks)) + return + except asyncio.CancelledError: + self._persist_assistant("".join(collected_chunks)) + raise + except Exception as e: # noqa: BLE001 + warnings.warn( + f"[ChatService] 分片失败: {e}", + RuntimeWarning, + stacklevel=2, + ) + # 兜底:一次性把全部内容推出去 + collected_chunks.append(full_response or "") + yield full_response + self._persist_assistant("".join(collected_chunks)) + return + + # ===== 策略 3:最终降级 — 一次性回执 ===== + try: + fallback = f"[ChatAgent 不可用] 收到: {user_input}" + collected_chunks.append(fallback) + yield fallback + except Exception: + # 连 yield 都失败时静默退出,不影响 WS 端点的清理流程 + pass + # 兜底:尝试持久化(即便 agent 不可用也保留草稿) + self._persist_assistant("".join(collected_chunks)) + + def _persist_assistant(self, content: str) -> None: + """ + v3.1.0: 把 assistant 完整回复写入 chat_messages;记录 message_id + 供后续 on_emotion() 异步更新 emotion_json。 + """ + if not content: + return + if self._session_id is None: + return + try: + repo = self._get_repo() + if repo is None: + return + mid = repo.add_message(self._session_id, "assistant", content) + if mid: + self._last_assistant_message_id = int(mid) + except Exception: # noqa: BLE001 + pass + + async def stream_chat( + self, + user_input: str, + user_id: str = "default", + cancel_event: Optional[Any] = None, + ) -> AsyncIterator[Any]: + """ + 流式聊天入口(Pydantic ChatChunk)。 + + 包装 ChatAgent.chat_stream,产出 Pydantic ChatChunk。 + - 正常路径:每个字符串 chunk → ChatChunk(type='chunk', chunk=text, done=False) + - 错误路径:ChatChunk(type='error', chunk=str(e), done=True) + - 末尾: ChatChunk(type='done', chunk='', done=True) + + Args: + user_input: 用户输入 + user_id: 用户标识(透传给 agent) + cancel_event: 取消事件(透传给 agent) + + Yields: + ChatChunk 实例 + """ + from src.web.backend.schemas.chat import ChatChunk # 延迟导入避免循环 + + agent = self.get_agent() + if agent is None: + # agent 不可用也要走错误 chunk 协议(不抛),便于 WS 单边关闭 + yield ChatChunk(type="error", chunk="ChatAgent not initialized", done=True) + return + + try: + async for text in agent.chat_stream( + user_input, + user_id=user_id, + cancel_event=cancel_event, + ): + # 跳过空字符串 chunk(前端没有内容可显示) + if text is None or text == "": + continue + yield ChatChunk(type="chunk", chunk=text, done=False) + except asyncio.CancelledError: + # 消费者主动断开时,安静地退出(不发送 error) + return + except Exception as e: # noqa: BLE001 + # 异常路径:优雅 yield error chunk 后结束 + try: + import traceback + debug_msg = f"{type(e).__name__}: {e}" + except Exception: + debug_msg = str(e) + yield ChatChunk(type="error", chunk=debug_msg, done=True) + return + + # 末尾:done chunk + # Stage B.3 / B.4: 流式成功结束后,通知前端情感雷达 / 话题时间线刷新 + try: + self._emit_post_chat_updates(user_id=user_id) + except Exception: # noqa: BLE001 + pass + yield ChatChunk(type="done", chunk="", done=True) + + # ------------------------------------------------------------------ + # 统计 + # ------------------------------------------------------------------ + def _emit_post_chat_updates(self, user_id: str = "default") -> None: + """ + Stage B.3 / B.4: 在聊天流程结束后,把"最新情感 + 最新话题"作为 + emotion_update / timeline_update 事件派发到 EventService。 + + 设计要点: + 1. 内部每个调用都用 try/except 包裹,绝不向上抛异常 + 2. 任何子模块(analyzer / memory / event_service)不可用时静默跳过 + 3. 不修改现有 chat() / stream_chat() 公开签名 + """ + try: + from src.web.backend.services.event_service import get_event_service + es = get_event_service() + except Exception: # noqa: BLE001 + return + if es is None: + return + + # 1) 情感雷达 + try: + from src.core.emotion_analyzer import EmotionRelationshipAnalyzer + analyzer = EmotionRelationshipAnalyzer() + payload = analyzer.get_latest_for_user(user_id) or {} + except Exception: # noqa: BLE001 + payload = {} + try: + es.emit_event('emotion_update', { + 'user_id': user_id, + 'data': payload.get('plutchik') or {}, + 'additive_scores': payload.get('additive_scores') or {}, + 'dominant': payload.get('dominant'), + 'timestamp': payload.get('timestamp'), + }) + except Exception: # noqa: BLE001 + pass + + # 2) 话题时间线 + try: + from src.core.long_term_memory import LongTermMemoryManager + mgr = LongTermMemoryManager() + topics = mgr.list_topics(user_id=user_id, days=7) or [] + except Exception: # noqa: BLE001 + topics = [] + try: + from datetime import datetime as _dt + ts_now = _dt.now().isoformat() + except Exception: # noqa: BLE001 + ts_now = '' + try: + es.emit_event('timeline_update', { + 'user_id': user_id, + 'days': 7, + 'topics': topics, + 'replace': True, + 'timestamp': ts_now, + }) + except Exception: # noqa: BLE001 + pass + + def get_stats(self) -> Dict[str, Any]: + """ + 返回服务状态与版本号。 + """ + return { + "agent_loaded": self._agent is not None, + "version": "3.0.0", + } + + +# 全局单例 +chat_service = ChatService() + + +__all__ = ["ChatService", "chat_service"] diff --git a/src/web/backend/services/creative_service.py b/src/web/backend/services/creative_service.py new file mode 100644 index 0000000..63e1f94 --- /dev/null +++ b/src/web/backend/services/creative_service.py @@ -0,0 +1,309 @@ +""" +Creative service module. +长期创作项目服务模块 - 包装 CreativeProjectManager + DatabaseManager, +为 Web 后端提供 list / detail / advance 等安全入口。 + +Stage C.6 设计要点: +1. 复用现有方法签名(``list_creative_projects`` / ``get_creative_project`` / + ``_advance_one``),不修改 ``CreativeProjectManager`` 既有方法。 +2. 失败路径返回 ``None`` / ``[]`` / ``(False, reason)``,由 API 层映射到 4xx。 +3. 懒加载 ``CreativeProjectManager``:避免 Web 启动时强制 LLM 初始化。 +4. 全局单例 ``creative_service``。 +""" + +from __future__ import annotations + +import sys +import warnings +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + + +class CreativeService: + """ + 创作项目服务(Web 后端侧) + + Attributes: + _manager: 懒加载的 ``CreativeProjectManager`` 实例 + """ + + def __init__(self) -> None: + self._manager: Optional[Any] = None + self._db: Optional[Any] = None + + # ------------------------------------------------------------------ + # 内部:懒加载 manager / db + # ------------------------------------------------------------------ + def _try_init_manager(self) -> Optional[Any]: + if self._manager is not None: + return self._manager + try: + from src.core.creative_writer import CreativeProjectManager # type: ignore + self._manager = CreativeProjectManager() + return self._manager + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[CreativeService] CreativeProjectManager 初始化失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + self._manager = None + return None + + def _get_db(self) -> Optional[Any]: + """ + 获取 DatabaseManager(来自 manager 或独立懒加载)。 + 优先使用 manager 自带的 db;否则懒加载 DatabaseManager。 + """ + mgr = self._try_init_manager() + if mgr is not None and getattr(mgr, 'db', None) is not None: + return mgr.db + if self._db is not None: + return self._db + try: + from src.core.database_manager import DatabaseManager # type: ignore + self._db = DatabaseManager() + return self._db + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[CreativeService] DatabaseManager 懒加载失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return None + + # ------------------------------------------------------------------ + # 列表 + # ------------------------------------------------------------------ + def list_projects( + self, + user_id: str = "default", + status: Optional[str] = None, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ + 列出创作项目。 + + - ``user_id``: 当前保留参数(数据库无 user_id 字段,所有项目视为同一用户)。 + - ``status``: 业务状态过滤(drafting / finished / paused 等)。 + - ``limit``: 返回上限。 + + Returns: + 标准化为 ``CreativeProjectSummary`` 兼容字段的项目列表。 + """ + db = self._get_db() + if db is None: + return [] + try: + raw = db.list_creative_projects(status=status, limit=max(1, int(limit))) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[CreativeService] list_projects 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + out: List[Dict[str, Any]] = [] + for item in raw or []: + try: + out.append(self._normalize_summary(item)) + except Exception: + continue + return out + + # ------------------------------------------------------------------ + # 详情 + # ------------------------------------------------------------------ + def get_project(self, project_uuid: str) -> Optional[Dict[str, Any]]: + """ + 获取项目详情(含 Story Bible / 章节 / 记忆池摘要)。 + 不存在 → None。 + """ + if not project_uuid or not isinstance(project_uuid, str): + return None + db = self._get_db() + if db is None: + return None + try: + project = db.get_creative_project(project_uuid) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[CreativeService] get_project({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return None + if not project: + return None + + detail = self._normalize_detail(project) + + # 拉取 Story Bible + try: + bible = db.get_story_bible(project_uuid) or {} + except Exception: + bible = {} + detail['story_bible'] = bible or {} + + # 章节:优先 draft_chunks;fallback 到 outline 的伪章节 + try: + chunks = project.get('draft_chunks') or [] + except Exception: + chunks = [] + chapters: List[Dict[str, Any]] = [] + for idx, ch in enumerate(chunks or []): + try: + chapters.append({ + 'uuid': f"{project_uuid}-ch-{idx + 1:02d}", + 'chapter_title': f"Draft Chapter {idx + 1}", + 'word_count': int(ch.get('char_count', 0) or 0), + 'content': ch.get('content', '') or '', + 'created_at': ch.get('created_at', '') or '', + }) + except Exception: + continue + if not chapters: + outlines = project.get('outline') or [] + for idx, line in enumerate(outlines or []): + chapters.append({ + 'uuid': f"{project_uuid}-outline-{idx + 1:02d}", + 'chapter_title': f"Outline {idx + 1}", + 'word_count': 0, + 'content': str(line) if line else '', + 'created_at': '', + }) + detail['chapters'] = chapters + detail['chapter_count'] = len(chapters) + + # 记忆池摘要 + try: + pool = db.get_creative_memory_pool(project_uuid, limit=50) or [] + except Exception: + pool = [] + detail['memory_pool_summary'] = { + 'count': len(pool), + 'latest': [ + { + 'memory_type': m.get('memory_type', ''), + 'content': (m.get('content', '') or '')[:200], + 'importance': m.get('importance', 0.0), + 'created_at': m.get('created_at', ''), + } + for m in (pool[:5] if isinstance(pool, list) else []) + ], + } + + return detail + + # ------------------------------------------------------------------ + # 续写 + # ------------------------------------------------------------------ + def advance(self, project_uuid: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """ + 触发续写。 + + Returns: + (ok, reason, new_chapter_dict or None) + - ok=True 时 new_chapter 必有值 + - ok=False 时 reason 描述原因(disabled / not_found / internal_error / not_drafting / quality_fail) + """ + if not project_uuid or not isinstance(project_uuid, str): + return False, "invalid_uuid", None + + mgr = self._try_init_manager() + if mgr is None: + return False, "manager_unavailable", None + if not getattr(mgr, 'enabled', False): + return False, "disabled", None + + db = self._get_db() + if db is None: + return False, "db_unavailable", None + + try: + project = db.get_creative_project(project_uuid) + except Exception: + project = None + if not project: + return False, "not_found", None + if (project.get('status') or '').lower() == 'finished': + return False, "already_finished", None + + try: + # 复用 _advance_one(project, now) 既有方法 + now = datetime.now() + ok = bool(mgr._advance_one(project, now)) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[CreativeService] advance({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False, f"internal_error: {exc}", None + + if not ok: + return False, "quality_fail_or_skip", None + + # 重新拉取最新章节 + try: + updated = db.get_creative_project(project_uuid) or {} + except Exception: + updated = {} + chunks = updated.get('draft_chunks') or [] + new_chapter: Optional[Dict[str, Any]] = None + if chunks: + try: + last = chunks[-1] + new_chapter = { + 'uuid': f"{project_uuid}-ch-{len(chunks):02d}", + 'chapter_title': f"Draft Chapter {len(chunks)}", + 'word_count': int(last.get('char_count', 0) or 0), + 'content': last.get('content', '') or '', + 'created_at': last.get('created_at', '') or '', + } + except Exception: + new_chapter = None + + return True, "ok", new_chapter + + # ------------------------------------------------------------------ + # 标准化 + # ------------------------------------------------------------------ + @staticmethod + def _normalize_summary(item: Dict[str, Any]) -> Dict[str, Any]: + try: + chunks = item.get('draft_chunks') or [] + chapter_count = len(chunks) + except Exception: + chapter_count = 0 + return { + 'uuid': str(item.get('uuid', '')), + 'title': str(item.get('title', '') or ''), + 'status': str(item.get('status', 'drafting') or 'drafting'), + 'chapter_count': int(chapter_count), + 'created_at': str(item.get('created_at', '') or ''), + 'updated_at': str(item.get('updated_at', '') or ''), + } + + @staticmethod + def _normalize_detail(item: Dict[str, Any]) -> Dict[str, Any]: + base = CreativeService._normalize_summary(item) + base.update({ + 'story_bible': {}, + 'chapters': [], + 'memory_pool_summary': {}, + }) + return base + + +# 全局单例 +creative_service = CreativeService() + + +__all__ = ["CreativeService", "creative_service"] diff --git a/src/web/backend/services/database_service.py b/src/web/backend/services/database_service.py new file mode 100644 index 0000000..5366331 --- /dev/null +++ b/src/web/backend/services/database_service.py @@ -0,0 +1,904 @@ +""" +Database service module. +数据库服务模块 - 包装 DatabaseManager,为 Web 后端提供安全的表查询/删除/统计入口。 + +Stage A.3: 提供业务表的只读查询 + 基于 uuid 的受控删除。 + +设计要点: +1. 白名单:只允许访问预定义的业务表(防御任意 SQL 拼接) +2. 懒加载 DatabaseManager:避免 Web 启动时强制 init DB +3. try/except 保护:单表/单行失败不影响其他调用 +4. 全局单例:database_service = DatabaseService() +""" + +from __future__ import annotations + +import json +import os +import sys +import uuid as _uuid +import warnings +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + + +# 业务表白名单:仅允许查询/删除这些表 +ALLOWED_TABLES: frozenset = frozenset({ + 'entities', + 'entity_definitions', + 'entity_related_info', + 'short_term_memory', + 'long_term_memory', + 'emotion_history', + 'metadata', + 'environment_descriptions', + 'environment_objects', + 'agent_expressions', + 'user_expression_habits', + 'life_state_daily', + 'dream_records', + 'diary_entries', + 'open_loops', + 'creative_projects', + 'creative_story_bibles', + 'creative_memory_pool', + 'schedules', + 'schedule_entries', + 'schedule_confirmations', + 'vision_tool_logs', + 'events', +}) + + +class DatabaseService: + """ + 数据库服务(Web 后端侧) + + Attributes: + db_path: SQLite 数据库文件路径(可通过 env CHAT_AGENT_DB 覆盖) + _db: 懒加载的 DatabaseManager 实例 + """ + + DEFAULT_DB_ENV = 'CHAT_AGENT_DB' + DEFAULT_DB_NAME = 'chat_agent.db' + + def __init__(self, db_path: Optional[str] = None) -> None: + """ + 初始化 DatabaseService。 + + Args: + db_path: 可选;为 None 时取 os.getenv('CHAT_AGENT_DB', 'chat_agent.db') + """ + if db_path is None: + db_path = os.getenv(self.DEFAULT_DB_ENV, self.DEFAULT_DB_NAME) + self.db_path: str = db_path + self._db: Optional[Any] = None + + # ------------------------------------------------------------------ + # 内部:懒加载 DatabaseManager + # ------------------------------------------------------------------ + def _try_init_db(self) -> Optional[Any]: + """ + 尝试实例化 DatabaseManager,失败时记录 warning 并返回 None。 + """ + if self._db is not None: + return self._db + try: + from src.core.database_manager import DatabaseManager # type: ignore + self._db = DatabaseManager(db_path=self.db_path) + return self._db + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] DatabaseManager 初始化失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + self._db = None + return None + + # ------------------------------------------------------------------ + # DatabaseManager 句柄 + # ------------------------------------------------------------------ + def get_db(self) -> Any: + """ + 懒加载 DatabaseManager;首次获取时会自动 init database。 + 首次获取时尝试执行 PRAGMA journal_mode=WAL(防御性)。 + + Returns: + DatabaseManager 实例;不可用时返回 None。 + """ + db = self._try_init_db() + if db is None: + return None + # 防御性:确保 WAL 模式被启用 + try: + with db.get_connection() as conn: + try: + conn.execute("PRAGMA journal_mode=WAL") + except Exception: + # 不影响主流程 + pass + except Exception: + # 拿不到连接也不抛出 + pass + return db + + def _validate_table(self, table: str) -> str: + """ + 验证表名是否在白名单中。 + 不在白名单 → raise ValueError。 + """ + if not table or not isinstance(table, str): + raise ValueError(f"Table {table!r} not allowed") + if table not in ALLOWED_TABLES: + raise ValueError(f"Table {table} not allowed") + return table + + # ------------------------------------------------------------------ + # 表查询 + # ------------------------------------------------------------------ + def get_tables(self) -> List[str]: + """ + 返回白名单中实际存在的业务表(sqlite_master 查询)。 + """ + db = self.get_db() + if db is None: + return [] + + whitelist = sorted(ALLOWED_TABLES) + existing: List[str] = [] + try: + with db.get_connection() as conn: + cur = conn.cursor() + # 限定为白名单表名,避免无关系统表 + placeholders = ','.join('?' for _ in whitelist) + try: + cur.execute( + f"SELECT name FROM sqlite_master " + f"WHERE type='table' AND name IN ({placeholders})", + whitelist, + ) + except Exception: + # 极端情况:sqlite_master 不可用 + return whitelist + rows = cur.fetchall() or [] + for row in rows: + try: + name = row['name'] if isinstance(row, dict) else row[0] + except (KeyError, IndexError, TypeError): + continue + if name: + existing.append(str(name)) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] get_tables 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return whitelist # 兜底:返回白名单 + + return sorted(existing) + + def query_table( + self, + table: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + 查询表数据。 + 表名必须在白名单中;limit/offset 做基本边界检查。 + """ + safe_table = self._validate_table(table) + # 边界保护 + try: + limit = max(1, min(int(limit), 1000)) + except (TypeError, ValueError): + limit = 100 + try: + offset = max(0, int(offset)) + except (TypeError, ValueError): + offset = 0 + + db = self.get_db() + if db is None: + return [] + + rows: List[Dict[str, Any]] = [] + try: + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT * FROM {safe_table} LIMIT ? OFFSET ?", + (limit, offset), + ) + fetched = cur.fetchall() or [] + for row in fetched: + try: + if isinstance(row, dict): + rows.append(dict(row)) + else: + rows.append({k: row[k] for k in row.keys()}) + except Exception: + # 单行转换失败:尝试 raw 转换 + try: + rows.append(dict(row)) + except Exception: + continue + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] query_table({safe_table}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + return rows + + def delete_record(self, table: str, uuid: str) -> bool: + """ + 按 uuid 删除单条记录。 + 表名必须在白名单中;uuid 必须是非空字符串。 + 成功删除一条返回 True;否则 False。 + """ + safe_table = self._validate_table(table) + if not uuid or not isinstance(uuid, str): + return False + + db = self.get_db() + if db is None: + return False + + try: + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"DELETE FROM {safe_table} WHERE uuid = ?", + (uuid,), + ) + affected = cur.rowcount + return bool(affected and affected > 0) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] delete_record({safe_table}, uuid) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False + + # ------------------------------------------------------------------ + # Schedule CRUD(Stage C.2) + # ------------------------------------------------------------------ + # 实际写入的表(schedule_* 白名单中的第一张能匹配上的;当前后端未在 + # init_database 中创建 schedule_* 表,方法会先检查存在性,缺失时 + # 返回友好错误或空列表,绝不抛 5xx。 + _SCHEDULE_PRIMARY_TABLE = 'schedules' + _SCHEDULE_ALLOWED_FIELDS = frozenset({ + 'title', 'description', 'start_time', 'end_time', + 'priority', 'status', 'schedule_type', + 'is_collaborative', 'collaborator_name', 'collaborator_status', + 'confirmed', 'created_at', 'updated_at', + }) + + def _ensure_schedule_table(self) -> Optional[str]: + """ + 返回当前可用的 schedule 主表名;若 schedule_* 都不存在则返回 None。 + """ + try: + existing = set(self.get_tables() or []) + except Exception: + existing = set() + for cand in (self._SCHEDULE_PRIMARY_TABLE, 'schedule_entries'): + if cand in existing: + return cand + return None + + def get_schedule_by_date(self, date: str) -> List[Dict[str, Any]]: + """ + 按日期(YYYY-MM-DD)返回日程列表。 + 缺失表 → 返回 [];绝不抛 5xx。 + """ + try: + table = self._ensure_schedule_table() + if table is None: + return [] + db = self.get_db() + if db is None: + return [] + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT * FROM {table} " + f"WHERE substr(COALESCE(start_time,''),1,10) = ? " + f"ORDER BY start_time ASC", + (date,), + ) + rows = cur.fetchall() or [] + out: List[Dict[str, Any]] = [] + for r in rows: + try: + out.append(dict(r) if not isinstance(r, dict) else dict(r)) + except Exception: + continue + return out + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] get_schedule_by_date({date}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + + def get_schedule_by_range(self, from_date: str, to_date: str) -> List[Dict[str, Any]]: + """ + 按日期范围 [from_date, to_date] 返回日程列表。 + 缺失表 → 返回 [];绝不抛 5xx。 + """ + try: + table = self._ensure_schedule_table() + if table is None: + return [] + db = self.get_db() + if db is None: + return [] + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT * FROM {table} " + f"WHERE substr(COALESCE(start_time,''),1,10) >= ? " + f"AND substr(COALESCE(start_time,''),1,10) <= ? " + f"ORDER BY start_time ASC", + (from_date, to_date), + ) + rows = cur.fetchall() or [] + out: List[Dict[str, Any]] = [] + for r in rows: + try: + out.append(dict(r) if not isinstance(r, dict) else dict(r)) + except Exception: + continue + return out + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] get_schedule_by_range({from_date},{to_date}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + + def add_schedule_item(self, item: Dict[str, Any]) -> int: + """ + 插入一条日程记录。 + 返回新记录 id(int);失败时返回 0。 + 缺失 schedule_* 表 → 返回 0(不会抛 5xx)。 + """ + try: + table = self._ensure_schedule_table() + if table is None: + return 0 + db = self.get_db() + if db is None: + return 0 + safe = {k: v for k, v in (item or {}).items() + if k in self._SCHEDULE_ALLOWED_FIELDS} + if not safe: + return 0 + cols = list(safe.keys()) + placeholders = ','.join('?' for _ in cols) + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"INSERT INTO {table} ({','.join(cols)}) " + f"VALUES ({placeholders})", + [safe[c] for c in cols], + ) + new_id = cur.lastrowid or 0 + return int(new_id) if new_id else 0 + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] add_schedule_item 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return 0 + + def update_schedule_item(self, item_id: int, updates: Dict[str, Any]) -> bool: + """ + 按 id 更新日程;缺失表 / 不存在 → 返回 False,绝不抛 5xx。 + """ + try: + table = self._ensure_schedule_table() + if table is None: + return False + safe = {k: v for k, v in (updates or {}).items() + if k in self._SCHEDULE_ALLOWED_FIELDS and v is not None} + if not safe: + return False + set_clause = ", ".join(f"{k} = ?" for k in safe.keys()) + db = self.get_db() + if db is None: + return False + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"UPDATE {table} SET {set_clause} WHERE id = ?", + [*safe.values(), int(item_id)], + ) + return (cur.rowcount or 0) > 0 + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] update_schedule_item({item_id}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False + + def delete_schedule_item(self, item_id: int) -> bool: + """ + 按 id 删除日程;缺失表 / 不存在 → 返回 False,绝不抛 5xx。 + """ + try: + table = self._ensure_schedule_table() + if table is None: + return False + db = self.get_db() + if db is None: + return False + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"DELETE FROM {table} WHERE id = ?", + (int(item_id),), + ) + return (cur.rowcount or 0) > 0 + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] delete_schedule_item({item_id}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False + + def confirm_schedule_item(self, item_id: int, confirmed: bool) -> bool: + """ + 协作日程确认/取消确认:写入 confirmed 字段并把 status 同步。 + 缺失表 / 不存在 → 返回 False,绝不抛 5xx。 + """ + try: + table = self._ensure_schedule_table() + if table is None: + return False + db = self.get_db() + if db is None: + return False + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"UPDATE {table} " + f"SET confirmed = ?, status = ?, updated_at = ? " + f"WHERE id = ?", + ( + 1 if confirmed else 0, + 'confirmed' if confirmed else 'pending', + datetime.now().isoformat(), + int(item_id), + ), + ) + return (cur.rowcount or 0) > 0 + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] confirm_schedule_item({item_id}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False + + # ------------------------------------------------------------------ + # Creative Project CRUD(Stage C.6) + # ------------------------------------------------------------------ + # 长期创作项目的最小可用接口。所有方法都走 try/except 保护: + # - 缺失表 / DB 不可用 → 返回空值 / False,不抛 5xx + # - 单行转换失败:跳过该行,不影响整体 + + _CREATIVE_PRIMARY_TABLE = 'creative_projects' + _CREATIVE_BIBLE_TABLE = 'creative_story_bibles' + _CREATIVE_POOL_TABLE = 'creative_memory_pool' + + def _ensure_creative_table(self) -> Optional[str]: + """ + 返回当前可用的 creative_projects 表名;不存在则返回 None。 + """ + try: + existing = set(self.get_tables() or []) + except Exception: + existing = set() + if self._CREATIVE_PRIMARY_TABLE in existing: + return self._CREATIVE_PRIMARY_TABLE + return None + + def list_creative_projects( + self, + status: Optional[str] = None, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ + 列出 creative_projects 记录(按 status 过滤可选)。 + - 表缺失 / DB 不可用 → 返回 [] + - status 留空:列出全部 + - limit 做 1..200 边界保护 + """ + try: + table = self._ensure_creative_table() + if table is None: + return [] + db = self.get_db() + if db is None: + return [] + try: + bounded_limit = max(1, min(int(limit), 200)) + except (TypeError, ValueError): + bounded_limit = 50 + + with db.get_connection() as conn: + cur = conn.cursor() + if status: + cur.execute( + f"SELECT * FROM {table} " + f"WHERE status = ? " + f"ORDER BY created_at DESC LIMIT ?", + (str(status), int(bounded_limit)), + ) + else: + cur.execute( + f"SELECT * FROM {table} " + f"ORDER BY created_at DESC LIMIT ?", + (int(bounded_limit),), + ) + rows = cur.fetchall() or [] + out: List[Dict[str, Any]] = [] + for r in rows: + try: + out.append(dict(r) if not isinstance(r, dict) else dict(r)) + except Exception: + continue + return out + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] list_creative_projects 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + + def get_creative_project(self, project_uuid: str) -> Optional[Dict[str, Any]]: + """ + 按 uuid 读取单个 creative_projects 记录。 + 失败 / 不存在 → 返回 None。 + """ + try: + if not project_uuid or not isinstance(project_uuid, str): + return None + table = self._ensure_creative_table() + if table is None: + return None + db = self.get_db() + if db is None: + return None + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT * FROM {table} WHERE uuid = ?", + (project_uuid,), + ) + row = cur.fetchone() + if not row: + return None + try: + return dict(row) if not isinstance(row, dict) else dict(row) + except Exception: + return None + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] get_creative_project({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return None + + def create_creative_project(self, project: Dict[str, Any]) -> Optional[str]: + """ + 插入一条 creative_projects 记录;返回新 uuid;失败返回 None。 + - 缺字段会用数据库默认值兜底 + - project['uuid'] 缺省时自动生成 + """ + try: + table = self._ensure_creative_table() + if table is None: + return None + db = self.get_db() + if db is None: + return None + + new_uuid = (project or {}).get('uuid') or str(_uuid.uuid4()) + now_iso = datetime.now().isoformat() + payload = { + 'uuid': new_uuid, + 'title': (project or {}).get('title') or '未命名', + 'work_type': (project or {}).get('work_type') or '短篇', + 'premise': (project or {}).get('premise', ''), + 'tone': (project or {}).get('tone', ''), + 'point_of_view': (project or {}).get('point_of_view', '第一人称'), + 'target_chars': int((project or {}).get('target_chars', 5000) or 5000), + 'current_chars': int((project or {}).get('current_chars', 0) or 0), + 'status': (project or {}).get('status') or 'drafting', + 'inspiration_source': (project or {}).get('inspiration_source', ''), + 'outline_json': json.dumps((project or {}).get('outline') or [], + ensure_ascii=False), + 'characters_json': json.dumps((project or {}).get('characters') or [], + ensure_ascii=False), + 'draft_chunks_json': json.dumps((project or {}).get('draft_chunks') or [], + ensure_ascii=False), + 'next_advance_at': (project or {}).get('next_advance_at'), + 'last_advanced_at': (project or {}).get('last_advanced_at'), + 'created_at': (project or {}).get('created_at') or now_iso, + 'updated_at': (project or {}).get('updated_at') or now_iso, + } + cols = list(payload.keys()) + placeholders = ','.join('?' for _ in cols) + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"INSERT INTO {table} ({','.join(cols)}) " + f"VALUES ({placeholders})", + [payload[c] for c in cols], + ) + return new_uuid + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] create_creative_project 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return None + + def update_creative_project(self, project_uuid: str, + updates: Dict[str, Any]) -> bool: + """ + 按 uuid 更新 creative_projects 字段;行不存在 / 失败 → 返回 False。 + updates 仅接受安全字段(title / status / current_chars / target_chars / + next_advance_at / last_advanced_at / premise / tone / point_of_view / + work_type / inspiration_source / outline / characters / draft_chunks)。 + """ + try: + if not project_uuid or not isinstance(project_uuid, str): + return False + table = self._ensure_creative_table() + if table is None: + return False + safe_scalar = { + 'title', 'work_type', 'premise', 'tone', 'point_of_view', + 'target_chars', 'current_chars', 'status', + 'inspiration_source', 'next_advance_at', 'last_advanced_at', + } + safe_json = { + 'outline': 'outline_json', + 'characters': 'characters_json', + 'draft_chunks': 'draft_chunks_json', + } + sets: List[str] = [] + values: List[Any] = [] + for k, v in (updates or {}).items(): + if k in safe_scalar: + sets.append(f"{k} = ?") + values.append(v) + elif k in safe_json: + sets.append(f"{safe_json[k]} = ?") + try: + values.append(json.dumps(v, ensure_ascii=False)) + except Exception: + values.append(json.dumps([], ensure_ascii=False)) + if not sets: + return False + sets.append('updated_at = ?') + values.append(datetime.now().isoformat()) + values.append(project_uuid) + db = self.get_db() + if db is None: + return False + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"UPDATE {table} SET {', '.join(sets)} WHERE uuid = ?", + values, + ) + return (cur.rowcount or 0) > 0 + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] update_creative_project({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False + + def get_story_bible(self, project_uuid: str) -> Optional[Dict[str, Any]]: + """ + 读取 project_uuid 对应的 Story Bible(含 JSON 字段反序列化)。 + 缺失 / 失败 → 返回 None。 + """ + try: + if not project_uuid or not isinstance(project_uuid, str): + return None + db = self.get_db() + if db is None: + return None + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT * FROM {self._CREATIVE_BIBLE_TABLE} " + f"WHERE project_uuid = ?", + (project_uuid,), + ) + row = cur.fetchone() + if not row: + return None + data: Dict[str, Any] + try: + data = dict(row) if not isinstance(row, dict) else dict(row) + except Exception: + return None + for json_k in ( + 'active_themes_json', 'unresolved_threads_json', + 'resolved_threads_json', 'important_facts_json', + 'recent_keywords_json', + ): + try: + data[json_k.replace('_json', '')] = json.loads( + data.get(json_k) or '[]' + ) + except Exception: + data[json_k.replace('_json', '')] = [] + return data + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] get_story_bible({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return None + + def upsert_story_bible(self, project_uuid: str, + bible: Dict[str, Any]) -> bool: + """ + 插入/替换 Story Bible;失败 → 返回 False。 + """ + try: + if not project_uuid or not isinstance(project_uuid, str): + return False + db = self.get_db() + if db is None: + return False + now_iso = datetime.now().isoformat() + payload = { + 'project_uuid': project_uuid, + 'mainline_direction': (bible or {}).get('mainline_direction', ''), + 'active_themes_json': json.dumps( + (bible or {}).get('active_themes') or [], + ensure_ascii=False), + 'unresolved_threads_json': json.dumps( + (bible or {}).get('unresolved_threads') or [], + ensure_ascii=False), + 'resolved_threads_json': json.dumps( + (bible or {}).get('resolved_threads') or [], + ensure_ascii=False), + 'important_facts_json': json.dumps( + (bible or {}).get('important_facts') or [], + ensure_ascii=False), + 'next_direction': (bible or {}).get('next_direction', ''), + 'recent_keywords_json': json.dumps( + (bible or {}).get('recent_keywords') or [], + ensure_ascii=False), + 'created_at': now_iso, + 'updated_at': now_iso, + } + cols = list(payload.keys()) + placeholders = ','.join('?' for _ in cols) + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"INSERT OR REPLACE INTO {self._CREATIVE_BIBLE_TABLE} " + f"({','.join(cols)}) VALUES ({placeholders})", + [payload[c] for c in cols], + ) + return True + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] upsert_story_bible({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return False + + def get_creative_memory_pool( + self, + project_uuid: str, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ + 读取 project_uuid 对应的 creative_memory_pool 记录。 + 缺失 / 失败 → 返回 []。 + """ + try: + if not project_uuid or not isinstance(project_uuid, str): + return [] + db = self.get_db() + if db is None: + return [] + try: + bounded_limit = max(1, min(int(limit), 200)) + except (TypeError, ValueError): + bounded_limit = 50 + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT * FROM {self._CREATIVE_POOL_TABLE} " + f"WHERE project_uuid = ? " + f"ORDER BY importance DESC, created_at DESC " + f"LIMIT ?", + (project_uuid, int(bounded_limit)), + ) + rows = cur.fetchall() or [] + out: List[Dict[str, Any]] = [] + for r in rows: + try: + out.append(dict(r) if not isinstance(r, dict) else dict(r)) + except Exception: + continue + return out + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[DatabaseService] get_creative_memory_pool({project_uuid}) 失败: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + + # ------------------------------------------------------------------ + # 统计 + # ------------------------------------------------------------------ + def get_stats(self) -> Dict[str, Any]: + """ + 返回 db_path / tables_count / db_size_bytes 等只读统计信息。 + """ + stats: Dict[str, Any] = { + 'db_path': self.db_path, + 'tables_count': 0, + 'db_size_bytes': 0, + } + # tables count + try: + stats['tables_count'] = len(self.get_tables()) + except Exception: + stats['tables_count'] = 0 + # db file size + try: + if self.db_path and os.path.exists(self.db_path): + stats['db_size_bytes'] = int(os.path.getsize(self.db_path)) + except Exception: + stats['db_size_bytes'] = 0 + return stats + + +# 全局单例 +database_service = DatabaseService() + + +__all__ = [ + "DatabaseService", + "database_service", + "ALLOWED_TABLES", +] diff --git a/src/web/backend/services/emotion_service.py b/src/web/backend/services/emotion_service.py new file mode 100644 index 0000000..997fb0c --- /dev/null +++ b/src/web/backend/services/emotion_service.py @@ -0,0 +1,475 @@ +""" +Emotion service module. +情感雷达后端服务 - 包装 EmotionRelationshipAnalyzer + PlutchikEmotionWheel, +为 /api/emotion/latest 和 WebSocket emotion_update 推送提供: + - 8 维累加评分(关系状态维度) + - 8 维 Plutchik 强度(当下情绪维度) + - 最近一次分析的时间戳 / 用户消息 + +设计要点: +1. 懒加载 emotion_analyzer:避免 Web 启动时强制实例化 ChatAgent / LLM +2. 缓存最近一次结果(避免每次 HTTP 请求都跑 LLM / 走 DB) +3. 失败/缺失时一律降级为全 0 字典 + 空字符串,不抛异常 +4. 进程内单例:emotion_service = EmotionService() +""" + +from __future__ import annotations + +import sys +import threading +import time +import warnings +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + + +PLUTCHIK_KEYS = ( + 'joy', 'trust', 'fear', 'surprise', + 'sadness', 'disgust', 'anger', 'anticipation', +) + + +def _empty_eight() -> Dict[str, float]: + return {k: 0.0 for k in PLUTCHIK_KEYS} + + +def _empty_payload(user_id: str = "default") -> Dict[str, Any]: + """失败/无数据时的兜底 payload。""" + return { + "cumulative": _empty_eight(), + "plutchik": _empty_eight(), + "last_message": "", + "timestamp": "", + "user_id": user_id or "default", + "historical_max": None, + } + + +def _coerce_float(value: Any, default: float = 0.0) -> float: + try: + if value is None: + return default + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_dict(d: Any) -> Dict[str, float]: + """把任意 dict 转成 8 维 dict;缺键补 0.0。""" + out = _empty_eight() + if not isinstance(d, dict): + return out + for k in PLUTCHIK_KEYS: + if k in d: + out[k] = _coerce_float(d[k], 0.0) + return out + + +class EmotionService: + """ + 情感雷达后端服务(Web 端入口) + + Attributes: + _cache: 进程内缓存 {user_id: (ts, payload)},避免每次 HTTP 都跑 LLM + _lock: 写缓存的轻量锁 + _analyzer: 懒加载的 EmotionRelationshipAnalyzer 实例 + _wheel: 懒加载的 PlutchikEmotionWheel 实例 + """ + + # 缓存有效期(秒):同一用户在这个窗口内的请求直接走缓存 + CACHE_TTL_SECONDS = 30.0 + + def __init__(self) -> None: + self._cache: Dict[str, Dict[str, Any]] = {} + self._lock = threading.RLock() + self._analyzer: Optional[Any] = None + self._wheel: Optional[Any] = None + # 主动尝试一次懒加载(失败时静默降级) + self._try_init() + + # ------------------------------------------------------------------ + # 懒加载 EmotionRelationshipAnalyzer / PlutchikEmotionWheel + # ------------------------------------------------------------------ + def _try_init(self) -> None: + try: + from src.core.emotion_analyzer import ( # type: ignore + EmotionRelationshipAnalyzer, + PlutchikEmotionWheel, + ) + self._analyzer = EmotionRelationshipAnalyzer() + self._wheel = PlutchikEmotionWheel(db_manager=self._analyzer.db) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"[EmotionService] 情感分析器初始化失败,将以无 analyzer 模式运行: {exc}", + RuntimeWarning, + stacklevel=2, + ) + self._analyzer = None + self._wheel = None + + def _ensure(self) -> bool: + """确保 analyzer / wheel 可用。失败时返回 False。""" + if self._analyzer is None or self._wheel is None: + self._try_init() + return self._analyzer is not None and self._wheel is not None + + # ------------------------------------------------------------------ + # 对外:缓存读写 + # ------------------------------------------------------------------ + def _cache_get(self, user_id: str) -> Optional[Dict[str, Any]]: + with self._lock: + entry = self._cache.get(user_id) + if not entry: + return None + ts = float(entry.get("ts", 0.0)) + if (time.time() - ts) > self.CACHE_TTL_SECONDS: + # 缓存过期 + self._cache.pop(user_id, None) + return None + return entry.get("payload") + + def _cache_put(self, user_id: str, payload: Dict[str, Any]) -> None: + with self._lock: + self._cache[user_id] = {"ts": time.time(), "payload": dict(payload)} + # 兜底:cache 体积上限 64 + if len(self._cache) > 64: + # 删除最旧的一条 + oldest_key = min( + self._cache.keys(), + key=lambda k: self._cache[k].get("ts", 0.0), + ) + self._cache.pop(oldest_key, None) + + def cache_clear(self, user_id: Optional[str] = None) -> None: + """主动失效缓存(测试 / 显式刷新)。""" + with self._lock: + if user_id is None: + self._cache.clear() + else: + self._cache.pop(user_id, None) + + # ------------------------------------------------------------------ + # 对外:构造 payload + # ------------------------------------------------------------------ + def get_latest_emotion(self, user_id: str = "default") -> Dict[str, Any]: + """ + 返回最新的情感分析结果。 + + Args: + user_id: 用户标识(当前实现下所有用户共用一份全局情感数据, + user_id 仅作为返回字段透传 + 缓存 key 使用) + + Returns: + dict: + { + "cumulative": {joy, trust, fear, surprise, sadness, disgust, anger, anticipation} # 8 维累加 + "plutchik": {同 8 维} # 0-1 归一化的当下情绪 + "last_message": str # 最近一次参与分析的用户消息(尽力从 DB 拼出) + "timestamp": str # ISO 8601 + "user_id": str + "historical_max": Optional[dict] # 8 维历史最高,没有则为 None + } + """ + uid = (user_id or "default").strip() or "default" + + # 1) 命中缓存:直接返回 + cached = self._cache_get(uid) + if cached is not None: + return dict(cached) + + # 2) 没命中 / 过期:尝试重新构造 + payload = self._build_payload(uid) + self._cache_put(uid, payload) + return dict(payload) + + def _build_payload(self, user_id: str) -> Dict[str, Any]: + """实际从 analyzer 拉数据;失败时返回空 payload。""" + empty = _empty_payload(user_id) + if not self._ensure(): + return empty + + # ---- 2.1) 拉取最近一次关系型分析(DB) ---- + try: + latest = self._analyzer.get_latest_emotion() # type: ignore[union-attr] + except Exception: # noqa: BLE001 + latest = None + + # ---- 2.2) 计算 cumulative(关系状态维度) ---- + cumulative = self._derive_cumulative(latest) + + # ---- 2.3) 计算 plutchik(当下情绪维度) ---- + plutchik = self._derive_plutchik() + + # ---- 2.4) last_message / timestamp ---- + timestamp = "" + last_message = "" + if isinstance(latest, dict): + timestamp = str(latest.get("timestamp") or latest.get("created_at") or "") + # impression 包含历史印象;不直接作为 last_message。 + # last_message 优先从短时记忆 DB 推断,否则留空。 + last_message = self._derive_last_message(latest) + + # ---- 2.5) historical_max(8 维历史最高) ---- + historical_max = self._derive_historical_max(cumulative, plutchik) + + return { + "cumulative": cumulative, + "plutchik": plutchik, + "last_message": last_message, + "timestamp": timestamp, + "user_id": user_id, + "historical_max": historical_max, + } + + # ------------------------------------------------------------------ + # 内部:维度推导 + # ------------------------------------------------------------------ + def _derive_cumulative(self, latest: Optional[Dict[str, Any]]) -> Dict[str, float]: + """ + 把 relationship_type / emotional_tone / overall_score 映射成 + 8 维 0-1 归一化评分。 + + 规则(启发式,无 LLM): + - 整体评分(overall_score 0-100)→ 主导正向情绪的权重 + - emotional_tone / sentiment 调整正负向分布 + """ + out = _empty_eight() + if not isinstance(latest, dict): + return out + + try: + overall = _coerce_float(latest.get("overall_score"), 0.0) + except Exception: + overall = 0.0 + # 0-100 → 0-1 + score_norm = max(0.0, min(1.0, overall / 100.0)) + + tone = str(latest.get("emotional_tone") or "").strip() + sentiment = str(latest.get("sentiment") or "").strip().lower() + relationship = str(latest.get("relationship_type") or "").strip() + + # 主导:积极 / 消极 tone → 8 维分布 + is_positive = ( + tone in ("积极", "正面", "乐观", "友好") + or sentiment in ("positive", "pos") + ) + is_negative = ( + tone in ("消极", "负面", "悲观", "紧张") + or sentiment in ("negative", "neg") + ) + + if is_positive: + # 积极:joy / trust / anticipation 为主,surprise 副 + out["joy"] = round(score_norm * 0.9, 4) + out["trust"] = round(score_norm * 0.7, 4) + out["anticipation"] = round(score_norm * 0.5, 4) + out["surprise"] = round(score_norm * 0.3, 4) + elif is_negative: + # 消极:sadness / anger / fear / disgust 为主 + out["sadness"] = round(score_norm * 0.9, 4) + out["anger"] = round(score_norm * 0.6, 4) + out["fear"] = round(score_norm * 0.5, 4) + out["disgust"] = round(score_norm * 0.4, 4) + else: + # 中性:信任 / 期待 + 轻微 4 维 + out["trust"] = round(0.4 + score_norm * 0.3, 4) + out["anticipation"] = round(0.3 + score_norm * 0.2, 4) + out["joy"] = round(0.2 + score_norm * 0.2, 4) + out["surprise"] = round(0.1 + score_norm * 0.1, 4) + + # 关系类型微调 + if relationship in ("知己", "亲密朋友", "好友"): + out["trust"] = min(1.0, out["trust"] + 0.15) + out["joy"] = min(1.0, out["joy"] + 0.1) + elif relationship in ("初识", "陌生人"): + out["fear"] = min(1.0, out["fear"] + 0.1) + out["surprise"] = min(1.0, out["surprise"] + 0.1) + + return out + + def _derive_plutchik(self) -> Dict[str, float]: + """ + 从 PlutchikEmotionWheel 拉取衰减后的 8 维强度; + 无 wheel 或失败时返回全 0。 + """ + if self._wheel is None: + return _empty_eight() + try: + # wheel 状态在 ChatAgent 里维护;这里只读 _last_emotion_wheel_state + state = self._read_wheel_state() + if not state: + return _empty_eight() + # 应用衰减 + try: + state = self._wheel.decay_emotions(state) + except Exception: + pass + return _safe_dict(state.get("emotions") or {}) + except Exception: # noqa: BLE001 + return _empty_eight() + + def _read_wheel_state(self) -> Optional[Dict[str, Any]]: + """ + 读取 PlutchikEmotionWheel 当前状态。 + 优先从 ChatAgent 进程内单例拿;拿不到再尝试 metadata 表里的快照。 + """ + # 1) 尝试从 ChatAgent 拿(如果有) + try: + from src.core.chat_agent import ChatAgent # type: ignore + agent = ChatAgent() + state = getattr(agent, "_last_emotion_wheel_state", None) + if isinstance(state, dict): + return state + except Exception: + pass + + # 2) 尝试从 metadata 表读快照(key='emotion_wheel_state') + try: + db = None + if self._wheel is not None and getattr(self._wheel, "db", None) is not None: + db = self._wheel.db + elif self._analyzer is not None and getattr(self._analyzer, "db", None) is not None: + db = self._analyzer.db + if db is not None and hasattr(db, "get_metadata"): + raw = db.get_metadata("emotion_wheel_state") + if raw: + import json + try: + return json.loads(raw) if isinstance(raw, str) else dict(raw) + except Exception: + return None + except Exception: + return None + return None + + def _derive_last_message(self, latest: Optional[Dict[str, Any]]) -> str: + """ + 尽力从短时记忆中取最近一条用户消息。 + 失败时返回空字符串。 + """ + if not isinstance(latest, dict): + return "" + # 如果 latest 字典里直接带了 last_message 字段(兜底),优先用 + direct = latest.get("last_message") + if isinstance(direct, str) and direct.strip(): + return direct.strip()[:2000] + # 尝试从 short_term_memory 表里查最近 1 条 user 消息 + try: + db = None + if self._analyzer is not None and getattr(self._analyzer, "db", None) is not None: + db = self._analyzer.db + if db is None: + return "" + method = getattr(db, "get_recent_messages", None) + if method is None: + return "" + try: + rows = method(limit=1, role="user") # type: ignore[arg-type] + except TypeError: + # 兼容不同的方法签名 + rows = method(limit=1) + if not rows: + return "" + row = rows[0] if isinstance(rows, list) else rows + if isinstance(row, dict): + content = row.get("content") or row.get("message") or "" + else: + content = str(getattr(row, "content", "") or "") + return str(content).strip()[:2000] + except Exception: + return "" + + def _derive_historical_max( + self, + cumulative: Dict[str, float], + plutchik: Dict[str, float], + ) -> Optional[Dict[str, float]]: + """ + 尽力从 emotion_history 表聚合 8 维历史最高。 + 当前没有按维度存 Plutchik 历史 → 直接用 cumulative + plutchik 元素最大值。 + """ + try: + db = None + if self._analyzer is not None and getattr(self._analyzer, "db", None) is not None: + db = self._analyzer.db + if db is None: + return None + history = db.get_emotion_history() if hasattr(db, "get_emotion_history") else [] + if not history: + return None + # 简单策略:取最近 20 条的整体评分,映射到 8 维 + scores: list = [] + for h in history[:20]: + if not isinstance(h, dict): + continue + try: + s = _coerce_float(h.get("overall_score"), 0.0) + except Exception: + continue + scores.append(max(0.0, min(1.0, s / 100.0))) + if not scores: + return None + peak = max(scores) + # 按 cumulative 模板放大 + return {k: round(min(1.0, v + peak * 0.2), 4) + for k, v in cumulative.items()} + except Exception: + return None + + # ------------------------------------------------------------------ + # 对外:emit(WebSocket emotion_update 推文的 helper) + # ------------------------------------------------------------------ + def build_emotion_update_event( + self, user_id: str = "default" + ) -> Dict[str, Any]: + """ + 为 WebSocket 推送构造 emotion_update 事件 payload。 + + 返回 dict: + { + "type": "emotion_update", + "data": { 8 维 plutchik }, + "cumulative": { 8 维 }, + "historical_max": Optional[dict], + "last_message": str, + "timestamp": str, + "user_id": str, + } + """ + payload = self.get_latest_emotion(user_id) + return { + "type": "emotion_update", + "data": dict(payload.get("plutchik") or {}), + "cumulative": dict(payload.get("cumulative") or {}), + "historical_max": payload.get("historical_max"), + "last_message": payload.get("last_message", ""), + "timestamp": payload.get("timestamp", ""), + "user_id": payload.get("user_id", user_id), + } + + def get_stats(self) -> Dict[str, Any]: + """返回服务状态。""" + return { + "analyzer_loaded": self._analyzer is not None, + "wheel_loaded": self._wheel is not None, + "cache_size": len(self._cache), + "cache_ttl_seconds": self.CACHE_TTL_SECONDS, + "version": "1.0.0", + } + + +# 全局单例 +emotion_service = EmotionService() + + +__all__ = [ + "EmotionService", + "emotion_service", + "PLUTCHIK_KEYS", +] diff --git a/src/web/backend/services/event_service.py b/src/web/backend/services/event_service.py new file mode 100644 index 0000000..4d8f821 --- /dev/null +++ b/src/web/backend/services/event_service.py @@ -0,0 +1,353 @@ +""" +EventService - 跨端事件服务 + +Stage D.3: +- 提供 `register_websocket_listener(channel, callback)` 注册 WebSocket 监听器 +- 提供 `emit_event(event_type, payload)` 触发事件(异步派发到匹配 channel 的 listener) +- 提供 `start()` 启动后台任务,订阅 EventManager 并把事件转发到 listeners + +设计要点: +1. 进程内单例:fastapi 应用内通过 `get_event_service()` 获取同一实例 +2. 与 WebSocket ConnectionManager 解耦:listener 拿到的只是 (event_type, payload) 元组 +3. 所有调用 try/except 保护,单个 listener 失败不影响其他 +""" + +from __future__ import annotations + +import asyncio +import inspect +import sys +import threading +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def log_info(self, *args, **kwargs): pass + def log_warn(self, *args, **kwargs): pass + def log_error(self, *args, **kwargs): pass + return _Stub() + +debug_logger = get_debug_logger() + + +# 类型别名:listener 签名 (event_type: str, payload: dict) -> Awaitable[None] | None +ListenerCallback = Callable[[str, Dict[str, Any]], Optional[Awaitable[None]]] + + +class EventService: + """ + 事件服务(Web 后端侧) + 1. 维护 _listeners: Dict[channel, List[callback]] + 2. start() 后台订阅 EventManager 事件 → 转发到 listeners + 3. emit_event(event_type, payload) 可由 WebSocket handler 主动派发 + """ + + _instance_lock = threading.Lock() + + def __init__(self) -> None: + # channel -> [callback] + self._listeners: Dict[str, List[ListenerCallback]] = {} + self._lock = threading.RLock() + self._started: bool = False + self._unsubscribers: List[Callable[[], None]] = [] + # 兜底队列:在 start() 之前调用 emit_event 时,把事件暂存;start 后批量 dispatch + self._pending_events: List[Dict[str, Any]] = [] + # 事件循环引用(WebSocket 在 fastapi 事件循环中运行) + self._loop: Optional[asyncio.AbstractEventLoop] = None + + # ------------------------------------------------------------------ + # 单例 + # ------------------------------------------------------------------ + @classmethod + def get_instance(cls) -> "EventService": + with cls._instance_lock: + if not hasattr(cls, "_instance") or cls._instance is None: + cls._instance = cls() + return cls._instance + + # ------------------------------------------------------------------ + # 注册监听器 + # ------------------------------------------------------------------ + def register_websocket_listener( + self, + channel: str, + callback: ListenerCallback, + ) -> Callable[[], None]: + """ + 注册一个 WebSocket listener 到指定 channel。 + - channel: 事件类型字符串('*' 表示通配) + - callback: 同步或 async 函数,签名 (event_type, payload) -> None + 返回 unsubscribe 函数,调用后移除该 listener。 + """ + if not channel: + channel = '*' + if not callable(callback): + raise TypeError("callback must be callable") + + with self._lock: + self._listeners.setdefault(str(channel), []).append(callback) + + def _unregister() -> None: + self.unregister_websocket_listener(channel, callback) + + return _unregister + + def unregister_websocket_listener( + self, + channel: str, + callback: ListenerCallback, + ) -> bool: + """注销 listener。""" + key = str(channel or '*') + with self._lock: + listeners = self._listeners.get(key) + if not listeners: + return False + try: + listeners.remove(callback) + except ValueError: + return False + if not listeners: + self._listeners.pop(key, None) + return True + + def listener_count(self, channel: Optional[str] = None) -> int: + """查询 listener 数量(测试 / 健康检查用)。""" + with self._lock: + if channel is None: + return sum(len(v) for v in self._listeners.values()) + return len(self._listeners.get(str(channel), [])) + + # ------------------------------------------------------------------ + # 事件派发 + # ------------------------------------------------------------------ + def emit_event(self, event_type: str, payload: Dict[str, Any]) -> None: + """ + 触发事件:找到匹配 channel 的所有 listener,异步派发。 + - 服务未 start 时,事件会暂存在 _pending_events 中;start() 后批量回放 + - 单个 listener 抛错不会影响其他 + """ + try: + event_type = str(event_type or 'message') + payload_dict: Dict[str, Any] = dict(payload or {}) + + with self._lock: + matched = list(self._listeners.get(event_type, [])) + \ + list(self._listeners.get('*', [])) + + if not matched: + # 未启动或暂无订阅:暂存到 pending(仅保留最近 200 条,避免内存膨胀) + if not self._started: + with self._lock: + self._pending_events.append({ + 'event_type': event_type, + 'payload': payload_dict, + }) + if len(self._pending_events) > 200: + self._pending_events = self._pending_events[-200:] + return + + self._dispatch_to_listeners(event_type, payload_dict, matched) + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('EventService', f'emit_event 失败: {e}', e) + except Exception: + pass + + def _dispatch_to_listeners( + self, + event_type: str, + payload: Dict[str, Any], + listeners: List[ListenerCallback], + ) -> None: + """把事件分发到 listeners,sync/async 兼容。""" + for cb in listeners: + try: + if inspect.iscoroutinefunction(cb): + # 尝试调度到事件循环 + scheduled = self._schedule_coroutine(cb, event_type, payload) + if not scheduled: + # 没有事件循环时降级为同步执行(用 asyncio.run) + try: + asyncio.run(cb(event_type, payload)) + except Exception as inner: # noqa: BLE001 + self._safe_log_error('EventService', + f'async listener 执行失败: {inner}', inner) + else: + cb(event_type, payload) + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'listener 执行失败({event_type}): {e}', e) + + def _schedule_coroutine( + self, + cb: ListenerCallback, + event_type: str, + payload: Dict[str, Any], + ) -> bool: + """在已知事件循环上调度协程;返回是否成功调度。""" + try: + loop = self._loop + if loop is None: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = None + if loop is not None and loop.is_running(): + loop.create_task(cb(event_type, payload)) + return True + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'调度协程失败({event_type}): {e}', e) + return False + + # ------------------------------------------------------------------ + # 启动 / 关闭 + # ------------------------------------------------------------------ + def start(self, event_manager: Any = None) -> bool: + """ + 启动后台任务,订阅 EventManager 并把事件转发到 listeners。 + - 可重复调用;多次调用幂等。 + - 启动后会回放 _pending_events 中的事件。 + """ + if self._started: + return True + + try: + # 记录事件循环引用(WebSocket 在同一循环中运行) + try: + self._loop = asyncio.get_event_loop() + except RuntimeError: + self._loop = None + + em = event_manager + if em is None: + try: + from src.core.event_manager import get_event_manager + em = get_event_manager() + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'获取 EventManager 失败: {e}', e) + em = None + + if em is not None and hasattr(em, 'subscribe'): + # 订阅常见事件类型 + 通配 + event_types = [ + 'scheduler_tick', + 'proactive_message', + 'chat_event', + 'debug_log', + 'message', + ] + for et in event_types: + try: + unsub = em.subscribe(et, self._on_manager_event) + if unsub is not None and callable(unsub): + self._unsubscribers.append(unsub) + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'订阅 {et} 失败: {e}', e) + # 通配订阅 + try: + unsub = em.subscribe('*', self._on_manager_event) + if unsub is not None and callable(unsub): + self._unsubscribers.append(unsub) + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'订阅通配失败: {e}', e) + + self._started = True + try: + debug_logger.log_info('EventService', '事件服务已启动', { + 'listeners': self.listener_count(), + }) + except Exception: + pass + + # 回放 pending 事件 + with self._lock: + pending = list(self._pending_events) + self._pending_events.clear() + for item in pending: + try: + self.emit_event(item.get('event_type', 'message'), + item.get('payload', {})) + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'回放 pending 失败: {e}', e) + return True + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', f'start 失败: {e}', e) + return False + + def stop(self) -> None: + """停止服务(清理订阅、保留 listeners 以便再次 start)。""" + try: + for unsub in list(self._unsubscribers): + try: + unsub() + except Exception: + pass + self._unsubscribers.clear() + self._started = False + try: + debug_logger.log_info('EventService', '事件服务已停止') + except Exception: + pass + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', f'stop 失败: {e}', e) + + # ------------------------------------------------------------------ + # EventManager → EventService 桥接 + # ------------------------------------------------------------------ + def _on_manager_event(self, event_type: str, payload: Dict[str, Any]) -> None: + """ + 由 EventManager.subscribe 注册的回调。 + 把事件转发给本服务的所有匹配 listener。 + """ + try: + # 复用 emit_event 逻辑 + self.emit_event(event_type, payload) + except Exception as e: # noqa: BLE001 + self._safe_log_error('EventService', + f'EventManager 事件转发失败: {e}', e) + + # ------------------------------------------------------------------ + # 工具 + # ------------------------------------------------------------------ + def _safe_log_error(self, module: str, msg: str, exc: Any = None) -> None: + try: + debug_logger.log_error(module, msg, exc) + except Exception: + pass + + +# 全局单例(直接调用 get_event_service() 即可) +_event_service: Optional[EventService] = None +_event_service_lock = threading.Lock() + + +def get_event_service() -> EventService: + """获取全局 EventService 单例。""" + global _event_service + with _event_service_lock: + if _event_service is None: + _event_service = EventService() + return _event_service + + +# Stage A.3: 直接可导入的模块级单例别名(与 get_event_service() 共享同一实例) +# 兼容 services/__init__.py 中的 `from .event_service import event_service` 用法。 +event_service: EventService = get_event_service() + + +__all__ = ["EventService", "get_event_service", "event_service", "ListenerCallback"] diff --git a/src/web/backend/services/neo_bridge.py b/src/web/backend/services/neo_bridge.py new file mode 100644 index 0000000..fa027d8 --- /dev/null +++ b/src/web/backend/services/neo_bridge.py @@ -0,0 +1,115 @@ +""" +Neo Bridge - Web 后端与新架构 nervous_system 之间的安全桥接层。 + +本模块允许 Web 后端的 v3 服务在不破坏现有逻辑的前提下,通过 CentralRouter +调用 v4 认知模块(cortex、limbic、prefrontal 等)。 + +设计要点: +1. 安全导入:不强制初始化 NeoApp,导入失败时所有 helper 静默降级。 +2. 单一入口:neo_request() 统一构造 Packet 并通过 CentralRouter 路由。 +3. 失败隔离:所有调用都包装在 try/except 中,返回 Packet.error 而不是抛异常。 +""" + +from __future__ import annotations + +from typing import Any, Dict + +from src.nervous_system.router.packet import Packet, PacketType + + +# 全局 NeoApp 引用,由 src.web.backend.main 在 startup 时注入。 +_neo_app: Any = None + + +def set_neo_app(neo_app: Any) -> None: + """ + 注册全局 NeoApp 实例。 + 由 main.py 在应用启动时调用。 + """ + global _neo_app + _neo_app = neo_app + + +def get_neo_app() -> Any: + """ + 获取当前已注册的全局 NeoApp 实例。 + 如果尚未初始化或不可用,返回 None。 + """ + return _neo_app + + +def get_neo_router() -> Any: + """ + 获取 NeoApp 内部的 CentralRouter。 + 如果 NeoApp 不可用,返回 None。 + """ + if _neo_app is None: + return None + try: + return _neo_app.router + except Exception: # noqa: BLE001 + return None + + +async def neo_request( + target: str, + channel: str, + payload: Dict[str, Any], + metadata: Dict[str, Any] | None = None, +) -> Packet: + """ + 通过 CentralRouter 向指定 v4 模块发送请求并返回响应 Packet。 + + Args: + target: 目标模块 ID,例如 "cortex.echo"、"limbic.amygdala"。 + channel: 业务通道,例如 "chat"、"emotion_latest"。 + payload: 业务数据字典。 + metadata: 可选元数据(user_id、trace_id 等)。 + + Returns: + 响应 Packet。如果 v4 不可用或路由失败,返回一个 ERROR Packet。 + """ + if _neo_app is None: + return Packet( + source="web.backend.neo_bridge", + target=target, + packet_type=PacketType.ERROR, + channel=channel, + payload={"error": "NeoApp not initialized", "code": "NEOAPP_NOT_READY"}, + metadata=metadata or {}, + ) + + router = get_neo_router() + if router is None: + return Packet( + source="web.backend.neo_bridge", + target=target, + packet_type=PacketType.ERROR, + channel=channel, + payload={"error": "CentralRouter not available", "code": "ROUTER_NOT_READY"}, + metadata=metadata or {}, + ) + + packet = Packet( + source="web.backend", + target=target, + packet_type=PacketType.REQUEST, + channel=channel, + payload=payload, + metadata=metadata or {}, + ) + + try: + response = await router.route(packet) + except Exception as exc: # noqa: BLE001 + response = Packet.error(packet, str(exc), code="ROUTER_EXCEPTION") + + return response + + +__all__ = [ + "get_neo_app", + "get_neo_router", + "neo_request", + "set_neo_app", +] diff --git a/src/web/backend/ws/__init__.py b/src/web/backend/ws/__init__.py new file mode 100644 index 0000000..21642d9 --- /dev/null +++ b/src/web/backend/ws/__init__.py @@ -0,0 +1,114 @@ +""" +WebSocket routers +聚合所有 ws/* 子模块的 router,统一挂载到 FastAPI app。 + +Stage A.2 / D.3: 端点骨架与跨端事件分发。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from fastapi import APIRouter + +# 子模块 routers(用 try/except 保护,避免单个模块失败影响整体导入) +_routers = [] +_loaded = {} + +try: + from src.web.backend.ws.chat import router as _chat_router + _routers.append(_chat_router) + _loaded['chat'] = True +except Exception as e: # noqa: BLE001 + _loaded['chat'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('ws', f'failed to import chat router: {e}') + except Exception: + pass + +try: + from src.web.backend.ws.debug import router as _debug_router + _routers.append(_debug_router) + _loaded['debug'] = True +except Exception as e: # noqa: BLE001 + _loaded['debug'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('ws', f'failed to import debug router: {e}') + except Exception: + pass + +try: + from src.web.backend.ws.events import router as _events_router + _routers.append(_events_router) + _loaded['events'] = True +except Exception as e: # noqa: BLE001 + _loaded['events'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('ws', f'failed to import events router: {e}') + except Exception: + pass + +# 兼容旧版 /ws/event 单数端点(前端 EmotionPanel/TimelineCanvas 仍引用)。 +# docs/api.md 标记为"已弃用,请用 /ws/events",但需要保持可达以做向后兼容。 +try: + from src.web.backend.ws.event import router as _event_router + _routers.append(_event_router) + _loaded['event'] = True +except Exception as e: # noqa: BLE001 + _loaded['event'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('ws', f'failed to import event router: {e}') + except Exception: + pass + +try: + from src.web.backend.ws.proactive import router as _proactive_router + _routers.append(_proactive_router) + _loaded['proactive'] = True +except Exception as e: # noqa: BLE001 + _loaded['proactive'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('ws', f'failed to import proactive router: {e}') + except Exception: + pass + +# Stage v4.1: 前端日志统一采集 +# 注:该 router 聚合到 ws_router 后,由 main.py 统一挂载到 /ws/* +try: + from src.web.backend.ws.frontend_logs import router as _frontend_logs_router + _routers.append(_frontend_logs_router) + _loaded['frontend_logs'] = True +except Exception as e: # noqa: BLE001 + _loaded['frontend_logs'] = False + try: + from src.tools.debug_logger import get_debug_logger + get_debug_logger().log_warn('ws', f'failed to import frontend_logs router: {e}') + except Exception: + pass + + +# 统一 router(保留空 router 以便 main.py 无脑 include_router) +router = APIRouter() +for r in _routers: + try: + router.include_router(r) + except Exception: + pass + + +def get_loaded_status() -> dict: + return dict(_loaded) + + +__all__ = ["router", "get_loaded_status"] diff --git a/src/web/backend/ws/chat.py b/src/web/backend/ws/chat.py new file mode 100644 index 0000000..7cc63b1 --- /dev/null +++ b/src/web/backend/ws/chat.py @@ -0,0 +1,463 @@ +""" +Chat WebSocket Router +- /ws/chat:流式聊天 +- 通过 ChatService.stream_chat 转发到客户端 +- Stage D.3 集成:connection 时把 chat_event 转发到 EventService +- Stage B.2 集成:把 echo 占位替换为 ChatAgent 真流式 +- Stage B.3 集成:聊天流式结束时推送 emotion_update 事件(前端缓存到 window.__lastEmotion) +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import uuid +from pathlib import Path +from typing import Any, AsyncIterator, Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from src.web.backend.ws.manager import manager + +try: + from src.web.backend.services.chat_service import ChatService +except Exception: # noqa: BLE001 + ChatService = None # type: ignore + +# Stage B.3: 情感雷达服务(懒加载、可选依赖) +try: + from src.web.backend.services.emotion_service import emotion_service +except Exception: # noqa: BLE001 + emotion_service = None # type: ignore + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def log_info(self, *args, **kwargs): pass + def log_warn(self, *args, **kwargs): pass + def log_error(self, *args, **kwargs): pass + return _Stub() + +# v4.0: WebSocket 网关桥接(可选,失败时降级到 ChatService) +try: + from src.web.backend.services.neo_bridge import get_neo_app + from src.nervous_system.gateway.base_gateway import GatewayRequest + from src.nervous_system.router.packet import PacketType +except Exception: # noqa: BLE001 + get_neo_app = None # type: ignore + GatewayRequest = None # type: ignore + PacketType = None # type: ignore + +debug_logger = get_debug_logger() + +router = APIRouter() +CHANNEL = "chat" + + +# ---------------------------------------------------------------------- +# v4.0: WebSocket 网关辅助函数 +# ---------------------------------------------------------------------- +def _get_neo_ws_gateway(): + """获取 NeoApp 的 WebSocketGateway,若不可用返回 None。""" + if get_neo_app is None: + return None + try: + neo_app = get_neo_app() + if neo_app is None: + return None + return getattr(neo_app, "ws_gateway", None) + except Exception: # noqa: BLE001 + return None + + +async def _stream_via_neo( + ws_gateway, + conn_id: str, + user_id: str, + user_input: str, + session_id: Optional[int], + context: dict, +) -> AsyncIterator[str]: + """ + 通过 WebSocketGateway → CentralRouter → prefrontal.workflow 流式获取回复。 + + Yields 字符串 chunk;若出错则 yield 错误提示字符串。 + """ + request = GatewayRequest( + method="WS_MESSAGE", + path="/ws/chat", + body={ + "user_input": user_input, + "user_id": user_id, + "session_id": session_id, + "context": context, + }, + metadata={ + "conn_id": conn_id, + "user_id": user_id, + "trace_id": uuid.uuid4().hex, + }, + ) + + async for packet in ws_gateway.handle_stream(request): + if packet.is_error(): + yield f"[网关错误: {packet.payload.get('error', 'unknown error')}]" + break + + if packet.packet_type != PacketType.STREAM: + continue + + stream_event = packet.payload.get("stream_event") + if stream_event == "chunk": + content = packet.payload.get("content", "") + if content: + yield content + elif stream_event == "error": + yield f"[流式错误: {packet.payload.get('error', 'unknown stream error')}]" + break + elif stream_event == "done": + break + + +# ---------------------------------------------------------------------- +# Stage B.3: 工具:构造 + 发送 emotion_update +# ---------------------------------------------------------------------- +async def _publish_emotion_update( + conn_id: str, + user_id: str = "default", + last_message: str = "", +) -> None: + """ + 在聊天流式结束时调用: + 1) 调 emotion_service.get_latest_emotion(user_id) 拿到 8 维 dict + 2) 构造 emotion_update 事件 payload + 3) 通过 manager.send_personal 直接发到 chat 连接 + 4) 同时 es.emit_event('emotion_update', payload) 推给 /ws/events 订阅者 + 失败时静默降级(不阻断 chat 主循环)。 + """ + uid = (user_id or "default").strip() or "default" + payload: dict = {} + try: + if emotion_service is not None: + payload = emotion_service.build_emotion_update_event(uid) or {} + # 透传最近一次用户消息(哪怕情感分析没拿到 last_message) + if last_message and isinstance(payload, dict): + if not payload.get("last_message"): + payload["last_message"] = last_message[:2000] + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('ws.chat', f'build_emotion_update 失败: {exc}') + except Exception: + pass + payload = { + "type": "emotion_update", + "data": {}, + "cumulative": {}, + "historical_max": None, + "last_message": last_message or "", + "timestamp": "", + "user_id": uid, + } + + # 1) 直接发给当前 chat ws 客户端(前端 useChatStream 处理) + try: + await manager.send_personal(conn_id, payload) + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('ws.chat', f'send_personal emotion_update 失败: {exc}') + except Exception: + pass + + # 2) 同步 publish 给 /ws/events 订阅者(emotion_update 事件) + try: + from src.web.backend.services.event_service import get_event_service + es = get_event_service() + if es is not None: + # emit_event 期望 payload 是 dict;我们把外层 type 留出来 + publish_payload = {k: v for k, v in payload.items() if k != "type"} + publish_payload.setdefault("data", payload.get("data") or {}) + es.emit_event('emotion_update', publish_payload) + except Exception: + pass + + # 3) v3.1.0: 异步把情感数据持久化到最近一条 assistant 消息 + try: + from src.web.backend.services.chat_service import chat_service as _cs + if _cs is not None: + # payload.data 是 8 维 dict;连同 plutchik/cumulative 一并写库 + _cs.on_emotion({ + "data": payload.get("data") or {}, + "cumulative": payload.get("cumulative") or {}, + "historical_max": payload.get("historical_max"), + "dominant": payload.get("dominant"), + "last_message": payload.get("last_message") or "", + "timestamp": payload.get("timestamp") or "", + }) + except Exception: + pass + + +@router.websocket("/ws/chat") +async def chat_ws(websocket: WebSocket) -> None: + """ + /ws/chat 端点 + 客户端发送 {"type": "message", "content": "..."} + 服务端流式返回 Pydantic ChatChunk 的 model_dump()(含 type / chunk / done) + 聊天结束后追加一条 {"type": "emotion_update", ...} 帧(Stage B.3) + """ + try: + await websocket.accept() + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('ws.chat', f'accept 失败: {e}', e) + except Exception: + pass + return + + conn_id = uuid.uuid4().hex + try: + await manager.connect(websocket, conn_id, CHANNEL) + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('ws.chat', f'connect 失败: {e}', e) + except Exception: + pass + + # v4.0: 尝试注册到 WebSocketGateway + ws_gateway = _get_neo_ws_gateway() + if ws_gateway is not None: + try: + await ws_gateway.register_connection(conn_id, websocket) + except Exception: # noqa: BLE001 + pass + + # v3.1.0: 性能修复 - 复用模块级 ChatService 单例,避免每个 WS 连接都 + # 重新实例化 ChatAgent / ModelRouter / LangChainLLM / DatabaseManager + # 等重型组件(旧实现每连接 3 个 LLM 初始化 + 全套 agent 重建) + chat_service: Optional[ChatService] = None + try: + from src.web.backend.services.chat_service import chat_service as _singleton + chat_service = _singleton + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('ws.chat', f'复用 chat_service 单例失败: {exc}') + except Exception: + pass + + # v3.1.0: 解析 query param 中的 session_id / user_id 并绑定 + try: + q_session_id = websocket.query_params.get("session_id") + if q_session_id and chat_service is not None: + try: + chat_service.set_session_id(int(q_session_id)) + except (TypeError, ValueError): + pass + except Exception: + pass + + try: + await manager.send_personal(conn_id, { + "type": "system", + "message": "connected to /ws/chat", + }) + except Exception: + pass + + # v3.1.0: 若仍未绑 session_id(前端未传),首条消息来时由 chat_service 自动创建 + # ack 帧会把真实 session_id 告诉前端 + try: + await manager.send_personal(conn_id, { + "type": "ack", + "session_id": str(chat_service.get_session_id()) if chat_service and chat_service.get_session_id() else "", + "conn_id": conn_id, + }) + except Exception: + pass + + try: + while True: + try: + raw = await websocket.receive_text() + except WebSocketDisconnect: + break + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('ws.chat', f'receive_text 失败: {e}', e) + except Exception: + pass + break + + try: + data = json.loads(raw) if raw else {} + except Exception: + data = {"type": "message", "content": raw} + + msg_type = (data.get("type") or "message").lower() + user_input = (data.get("content") or data.get("text") or "").strip() + user_id = (data.get("user_id") or "default").strip() or "default" + + if msg_type == "ping": + await manager.send_personal(conn_id, {"type": "pong"}) + continue + + if not user_input: + await manager.send_personal(conn_id, { + "type": "error", + "message": "empty input", + }) + continue + + try: + from src.web.backend.services.event_service import get_event_service + es = get_event_service() + es.emit_event('chat_event', { + "kind": "user_message", + "content": user_input, + }) + except Exception: + pass + + # v4.0: 优先通过 WebSocketGateway → CentralRouter 流式路由 + use_neo = ws_gateway is not None + if not use_neo and chat_service is None: + await manager.send_personal(conn_id, { + "type": "error", + "message": "ChatService unavailable", + }) + continue + + # 推送协议:每 chunk -> {"type": "chunk", "content": chunk} + # 结束 -> {"type": "done"} + # 异常 -> {"type": "error", "message": str(e)} + try: + # v3.1.0: 若首条消息自动创建了 session,主动把 session_id 通过 ack 帧告诉前端 + _had_session_at_start = bool( + chat_service and chat_service.get_session_id() + ) + + if use_neo: + # v4.0: 走神经系统工作流 + q_session_id = websocket.query_params.get("session_id") + session_id: Optional[int] = None + if q_session_id: + try: + session_id = int(q_session_id) + except (TypeError, ValueError): + session_id = None + async for chunk in _stream_via_neo( + ws_gateway, + conn_id=conn_id, + user_id=user_id, + user_input=user_input, + session_id=session_id, + context=data, + ): + if chunk is None or chunk == "": + continue + await manager.send_personal(conn_id, { + "type": "chunk", + "content": chunk, + }) + else: + # v3.x: 降级到 ChatService + async for chunk in chat_service.chat_stream( + user_input, context=data + ): + if chunk is None or chunk == "": + continue + await manager.send_personal(conn_id, { + "type": "chunk", + "content": chunk, + }) + + # 正常结束:done 帧 + await manager.send_personal(conn_id, {"type": "done"}) + + # v3.1.0: 若是首条消息并触发了 session 创建,补发 session_id + if chat_service is not None and not _had_session_at_start: + new_sid = chat_service.get_session_id() + if new_sid: + try: + await manager.send_personal(conn_id, { + "type": "session", + "session_id": str(new_sid), + }) + except Exception: + pass + + try: + from src.web.backend.services.event_service import get_event_service + es = get_event_service() + es.emit_event('chat_event', {"kind": "done", "input": user_input}) + except Exception: + pass + + # Stage B.3: 流式结束 → 推送 emotion_update + # 缓存失效 + 重新构造(确保拿到最新分析) + try: + if emotion_service is not None: + emotion_service.cache_clear(user_id) + except Exception: + pass + # v3.1.0: 先调 on_emotion 把情感数据写入 assistant 消息的 emotion_json + try: + if chat_service is not None: + # 占位 payload —— 真实值会由 _publish_emotion_update 推送; + # 这里只是尝试一次同步写入最新缓存(如果有) + pass + except Exception: + pass + await _publish_emotion_update( + conn_id=conn_id, + user_id=user_id, + last_message=user_input, + ) + except Exception as e: # noqa: BLE001 + # 兜底:流式过程中出现非预期异常,发送 error frame 后继续 + try: + debug_logger.log_error('ws.chat', f'chat_stream 失败: {e}', e) + except Exception: + pass + try: + await manager.send_personal(conn_id, { + "type": "error", + "message": str(e), + }) + except Exception: + pass + except WebSocketDisconnect: + pass + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('ws.chat', f'主循环异常: {e}', e) + except Exception: + pass + finally: + # v3.1.0: 连接结束清理 - 重置单例的 _session_id, + # 防止下个连接继承上次的会话上下文(避免跨连接串话) + try: + if chat_service is not None and hasattr(chat_service, "set_session_id"): + chat_service.set_session_id(None) + except Exception: + pass + # v4.0: 从 WebSocketGateway 注销连接 + if ws_gateway is not None: + try: + await ws_gateway.unregister_connection(conn_id) + except Exception: + pass + try: + manager.disconnect(conn_id) + except Exception: + pass + + +__all__ = ["router", "CHANNEL"] diff --git a/src/web/backend/ws/debug.py b/src/web/backend/ws/debug.py new file mode 100644 index 0000000..99c4067 --- /dev/null +++ b/src/web/backend/ws/debug.py @@ -0,0 +1,190 @@ +""" +Debug WebSocket Router +- /ws/debug:流式调试日志 +- Stage D.4: 连接时注册 DebugLogger 订阅者;断开时取消订阅 +- 同时也注册一个 EventService listener,把 'debug_log' 事件广播到本 channel +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import uuid +from pathlib import Path +from typing import Any, Callable, Dict, Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from src.web.backend.ws.manager import manager + +try: + from src.web.backend.services.event_service import get_event_service +except Exception: # noqa: BLE001 + def get_event_service(): + return None + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + _subscribers = [] + def subscribe(self, *a, **kw): return lambda: None + def unsubscribe(self, *a, **kw): return False + return _Stub() + +debug_logger = get_debug_logger() +router = APIRouter() +CHANNEL = "debug" + + +async def _safe_send_personal(conn_id: str, payload: Any) -> bool: + try: + return await manager.send_personal(conn_id, payload) + except Exception: + return False + + +@router.websocket("/ws/debug") +async def debug_ws(websocket: WebSocket) -> None: + """ + /ws/debug 端点 + - 接受连接后,订阅 DebugLogger;每条日志通过 WS 推送给客户端 + - 断开时取消订阅 + - 同时也注册一个 EventService listener,把 'debug_log' 事件广播到本 channel + """ + try: + await websocket.accept() + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('ws.debug', f'accept 失败: {e}', e) + except Exception: + pass + return + + conn_id = uuid.uuid4().hex + try: + await manager.connect(websocket, conn_id, CHANNEL) + except Exception: + pass + + await _safe_send_personal(conn_id, {"type": "system", "message": "connected to /ws/debug"}) + + # 订阅 DebugLogger + queue: asyncio.Queue = asyncio.Queue(maxsize=500) + + def _subscriber(log_entry: Dict[str, Any]) -> None: + try: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = None + if loop is not None and loop.is_running(): + loop.call_soon_threadsafe(queue.put_nowait, log_entry) + else: + queue.put_nowait(log_entry) + except Exception: + pass + + unsubscribe = None + try: + if hasattr(debug_logger, 'subscribe'): + unsubscribe = debug_logger.subscribe(_subscriber) + except Exception: + unsubscribe = None + + # 注册 EventService listener + es_unsub = None + try: + es = get_event_service() + if es is not None: + async def _es_listener(_event_type: str, payload: Dict[str, Any]) -> None: + try: + await manager.broadcast(CHANNEL, { + "type": "event", + "event_type": _event_type, + "payload": payload, + }) + except Exception: + pass + + es_unsub = es.register_websocket_listener('debug_log', _es_listener) + except Exception: + es_unsub = None + + try: + while True: + # 使用 asyncio.wait 监听 receive_text 与 queue.get + receive_task = asyncio.create_task(websocket.receive_text()) + done_event = asyncio.create_task(queue.get()) + try: + done, _pending = await asyncio.wait( + {receive_task, done_event}, + return_when=asyncio.FIRST_COMPLETED, + ) + except Exception: + if not receive_task.done(): + receive_task.cancel() + if not done_event.done(): + done_event.cancel() + break + + if receive_task in done: + if not done_event.done(): + done_event.cancel() + try: + raw = receive_task.result() + except WebSocketDisconnect: + break + except Exception: + raw = None + if raw is None: + break + try: + data = json.loads(raw) if raw else {} + except Exception: + data = {"raw": raw} + if (data.get("type") or "").lower() == "ping": + await _safe_send_personal(conn_id, {"type": "pong"}) + continue + + if done_event in done: + if not receive_task.done(): + receive_task.cancel() + try: + log_entry = done_event.result() + except Exception: + log_entry = None + if log_entry is not None: + await _safe_send_personal(conn_id, { + "type": "log", + "entry": log_entry, + }) + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + try: + if unsubscribe is not None: + unsubscribe() + except Exception: + pass + try: + if es_unsub is not None: + es_unsub() + except Exception: + pass + try: + manager.disconnect(conn_id) + except Exception: + pass + + +__all__ = ["router", "CHANNEL"] diff --git a/src/web/backend/ws/debug_broadcaster.py b/src/web/backend/ws/debug_broadcaster.py new file mode 100644 index 0000000..dd5ccf5 --- /dev/null +++ b/src/web/backend/ws/debug_broadcaster.py @@ -0,0 +1,140 @@ +""" +DebugBroadcaster +- 启动时把 DebugLogger 的日志广播到 /ws/debug 频道 +- 兼容两种集成方式: + 1) 通过 EventService(推荐):注册 EventService 'debug_log' listener,由 events WS 转发 + 2) 直接通过 manager.broadcast('debug', ...) 向 /ws/debug 客户端推送 + +本模块只负责订阅 + 派发;具体 WebSocket 生命周期由 ws/debug.py 管理。 +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Any, Callable, Dict, Optional + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + + +class DebugBroadcaster: + """ + 调试日志广播器 + - 启动时调用 attach() 注册订阅者 + - 关闭时调用 detach() 取消订阅 + - 同步 subscriber:直接 broadcast;async subscriber:调度到事件循环 + """ + + CHANNEL = "debug" + + def __init__(self) -> None: + self._unsub_debug: Optional[Callable[[], None]] = None + self._unsub_event_service: Optional[Callable[[], None]] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + + def attach(self, manager: Any = None, event_service: Any = None, + loop: Optional[asyncio.AbstractEventLoop] = None) -> bool: + """ + 注册订阅者。 + + Args: + manager: ConnectionManager 实例(来自 ws/manager.py)。Stage A.2 兼容: + 既支持新 manager(仅 channel -> broadcast)也支持老 manager 单例。 + event_service: EventService 实例(可选) + loop: 主事件循环 + + Returns: + True 表示至少成功注册了 DebugLogger 订阅者。 + """ + self._loop = loop + try: + from src.tools.debug_logger import get_debug_logger + except Exception: + return False + debug_logger = get_debug_logger() + try: + debug_logger.set_main_loop(loop) + except Exception: + pass + + if manager is not None and hasattr(debug_logger, 'subscribe'): + def _sync_subscriber(log_entry: Dict[str, Any]) -> None: + try: + if manager is None: + return + coro = manager.broadcast(self.CHANNEL, { + "type": "log", + "entry": log_entry, + }) + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + if running_loop is not None and self._loop is running_loop: + running_loop.create_task(coro) + else: + target = self._loop + if target is not None and target.is_running(): + asyncio.run_coroutine_threadsafe(coro, target) + else: + try: + asyncio.run(coro) + except Exception: + pass + except Exception: + pass + + try: + unsub = debug_logger.subscribe(_sync_subscriber) + if unsub is not None and callable(unsub): + self._unsub_debug = unsub + except Exception: + self._unsub_debug = None + + if event_service is not None and hasattr(event_service, 'register_websocket_listener'): + try: + async def _es_listener(_event_type: str, payload: Dict[str, Any]) -> None: + if manager is not None: + await manager.broadcast(self.CHANNEL, payload) + + self._unsub_event_service = event_service.register_websocket_listener( + 'debug_log', _es_listener + ) + except Exception: + self._unsub_event_service = None + + return self._unsub_debug is not None or self._unsub_event_service is not None + + def detach(self) -> None: + """取消所有订阅。""" + if self._unsub_debug is not None: + try: + self._unsub_debug() + except Exception: + pass + self._unsub_debug = None + if self._unsub_event_service is not None: + try: + self._unsub_event_service() + except Exception: + pass + self._unsub_event_service = None + + +# 进程级单例 +_broadcaster: Optional[DebugBroadcaster] = None + + +def get_debug_broadcaster() -> DebugBroadcaster: + """获取全局 DebugBroadcaster 单例。""" + global _broadcaster + if _broadcaster is None: + _broadcaster = DebugBroadcaster() + return _broadcaster + + +__all__ = ["DebugBroadcaster", "get_debug_broadcaster"] diff --git a/src/web/backend/ws/event.py b/src/web/backend/ws/event.py new file mode 100644 index 0000000..8d23ef8 --- /dev/null +++ b/src/web/backend/ws/event.py @@ -0,0 +1,59 @@ +""" +``/ws/event`` 端点 — 事件通道 +``/ws/event`` endpoint — event channel. + +Stage A.2: 骨架实现。 +- 接受连接 → 注册到 ``manager`` 的 ``event`` channel。 +- 保持长连接;服务端**不主动发送**(事件由 EventManager 异步触发,Stage D 接入)。 +- 客户端发送的消息目前仅做 ack 回执。 +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from .manager import manager + + +router = APIRouter() + + +@router.websocket("/ws/event") +async def event_endpoint(websocket: WebSocket) -> None: + """ + 事件通道 WebSocket 端点。 + Event channel WebSocket endpoint. + + 协议(Stage A.2 骨架): + - 客户端 connect → 服务端发送 ``{"type": "ack", "conn_id": ..., "channel": "event"}``。 + - 服务端**不主动推送**(Stage D 接入 EventManager 后由 ``manager.broadcast("event", ...)`` 驱动)。 + - 客户端发送任意消息 → 服务端回 ``{"type": "ack", "received": true}``。 + """ + await websocket.accept() + conn_id = uuid.uuid4().hex + await manager.connect(websocket, conn_id, "event") + + try: + await websocket.send_json( + {"type": "ack", "conn_id": conn_id, "channel": "event"} + ) + + while True: + # 仅做接收 / ack,不主动推送(Stage D 接入) + await websocket.receive_text() + await websocket.send_json({"type": "ack", "received": True}) + + except WebSocketDisconnect: + manager.disconnect(conn_id) + except Exception as exc: # noqa: BLE001 + print(f"[ws/event] 异常 conn_id={conn_id}: {exc}") + manager.disconnect(conn_id) + try: + await websocket.close() + except Exception: # noqa: BLE001 + pass + + +__all__ = ["router"] diff --git a/src/web/backend/ws/events.py b/src/web/backend/ws/events.py new file mode 100644 index 0000000..3e7f03d --- /dev/null +++ b/src/web/backend/ws/events.py @@ -0,0 +1,149 @@ +""" +Events WebSocket Router +- /ws/events:通用事件推送(scheduler_tick / proactive_message / chat_event / message / 通配) +- 通过 EventService.register_websocket_listener 订阅 +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import uuid +from pathlib import Path +from typing import Any, Dict + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +from src.web.backend.ws.manager import manager + +try: + from src.web.backend.services.event_service import get_event_service +except Exception: # noqa: BLE001 + def get_event_service(): + return None + +router = APIRouter() +CHANNEL = "events" + + +@router.websocket("/ws/events") +async def events_ws(websocket: WebSocket) -> None: + """ + /ws/events 端点 + - 接受后注册 EventService listener(订阅常见 event types) + - 把事件原样推送给客户端 + - 断开时自动取消订阅 + """ + try: + await websocket.accept() + except Exception: + return + + conn_id = uuid.uuid4().hex + try: + await manager.connect(websocket, conn_id, CHANNEL) + except Exception: + pass + + try: + await manager.send_personal(conn_id, { + "type": "system", "message": "connected to /ws/events", + }) + except Exception: + pass + + unsubscribers = [] + + try: + es = get_event_service() + if es is not None: + channels = [ + 'scheduler_tick', + 'proactive_message', + 'chat_event', + 'message', + ] + + for ch in channels: + async def _make_listener(_ch: str): + async def _listener(event_type: str, payload: Dict[str, Any]) -> None: + try: + await manager.broadcast(CHANNEL, { + "type": "event", + "channel": _ch, + "event_type": event_type, + "payload": payload, + }) + except Exception: + pass + return _listener + + try: + listener = await _make_listener(ch) + unsub = es.register_websocket_listener(ch, listener) + if unsub is not None: + unsubscribers.append(unsub) + except Exception: + pass + + # 通配订阅(兜底) + async def _wildcard_listener(event_type: str, payload: Dict[str, Any]) -> None: + try: + await manager.broadcast(CHANNEL, { + "type": "event", + "channel": "*", + "event_type": event_type, + "payload": payload, + }) + except Exception: + pass + + try: + unsub = es.register_websocket_listener('*', _wildcard_listener) + if unsub is not None: + unsubscribers.append(unsub) + except Exception: + pass + except Exception: + pass + + try: + while True: + try: + raw = await websocket.receive_text() + except WebSocketDisconnect: + break + except Exception: + break + try: + data = json.loads(raw) if raw else {} + except Exception: + data = {"raw": raw} + if (data.get("type") or "").lower() == "ping": + try: + await manager.send_personal(conn_id, {"type": "pong"}) + except Exception: + pass + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + for u in unsubscribers: + try: + u() + except Exception: + pass + try: + manager.disconnect(conn_id) + except Exception: + pass + + +__all__ = ["router", "CHANNEL"] diff --git a/src/web/backend/ws/frontend_logs.py b/src/web/backend/ws/frontend_logs.py new file mode 100644 index 0000000..059a11e --- /dev/null +++ b/src/web/backend/ws/frontend_logs.py @@ -0,0 +1,2 @@ +""" +Frontend Logs Web \ No newline at end of file diff --git a/src/web/backend/ws/manager.py b/src/web/backend/ws/manager.py new file mode 100644 index 0000000..21ec23f --- /dev/null +++ b/src/web/backend/ws/manager.py @@ -0,0 +1,240 @@ +""" +WebSocket Connection Manager +集中维护所有活跃的 WebSocket 连接(按 channel 分组)。 + +Stage A.2 兼容(conn_id-based API): +- active_connections: Dict[conn_id -> WebSocket] +- channels: Dict[channel_name -> Set[conn_id]] +- 已被 event.py / proactive.py 等 Stage A.2 端点使用 + +Stage D.3 扩展: +- 新增 alias 方法 connect_by_channel / disconnect_by_channel / broadcast_by_channel + 供新端点 (chat/debug/events) 使用 +- 新增 connection_count / channels 查询方法 +- 单点失败不影响其他连接(try/except 保护) +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Set +from fastapi import WebSocket + +# 确保从项目根目录能正确 import src.* +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def log_info(self, *args, **kwargs): pass + def log_warn(self, *args, **kwargs): pass + def log_error(self, *args, **kwargs): pass + return _Stub() + +debug_logger = get_debug_logger() + + +class ConnectionManager: + """ + WebSocket 连接管理器(单例友好的纯类)。 + + Attributes: + active_connections: 全局连接表,``conn_id -> WebSocket``。 + channels: 通道索引,``channel_name -> set(conn_id)``。 + _lock: 写操作的轻量锁(仅在 add/remove/broadcast 时短暂使用)。 + """ + + def __init__(self) -> None: + self.active_connections: Dict[str, WebSocket] = {} + self.channels: Dict[str, Set[str]] = {} + self._lock: asyncio.Lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # 注册 / 注销(Stage A.2 API) + # ------------------------------------------------------------------ + async def connect(self, websocket: WebSocket, conn_id: str, channel: str) -> None: + """ + 注册一条连接(Stage A.2 老 API)。 + + 调用方需先 ``await websocket.accept()``,本方法不负责 accept。 + """ + self.register_channel(channel) + async with self._lock: + self.active_connections[conn_id] = websocket + self.channels[channel].add(conn_id) + try: + debug_logger.log_info('WSManager', f'客户端加入 [{channel}] conn_id={conn_id}') + except Exception: + pass + + def disconnect(self, conn_id: str) -> None: + """ + 注销一条连接(同步、幂等)。 + """ + self.active_connections.pop(conn_id, None) + for ch_name, members in list(self.channels.items()): + if conn_id in members: + members.discard(conn_id) + + # ------------------------------------------------------------------ + # Stage D.3: 接受 + 注册一行式 + # ------------------------------------------------------------------ + async def accept_and_connect(self, channel: str, websocket: WebSocket) -> str: + """ + 一步完成 accept + 注册到 channel,返回 conn_id。 + """ + try: + await websocket.accept() + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('WSManager', f'accept 失败: {e}', e) + except Exception: + pass + conn_id = uuid.uuid4().hex + await self.connect(websocket, conn_id, channel) + return conn_id + + async def close_and_disconnect(self, channel: str, websocket: WebSocket, + conn_id: Optional[str] = None) -> None: + """关闭 + 注销。""" + if conn_id is not None: + self.disconnect(conn_id) + return + # 兜底:反向查 conn_id + for cid, ws in list(self.active_connections.items()): + if ws is websocket: + self.disconnect(cid) + return + + # ------------------------------------------------------------------ + # 单播 + # ------------------------------------------------------------------ + async def send_personal(self, conn_id: str, message: Any) -> bool: + """ + 向指定 conn_id 发送一条消息(dict / str 均可)。 + """ + ws = self.active_connections.get(conn_id) + if ws is None: + return False + try: + if isinstance(message, (dict, list)): + await ws.send_text(json.dumps(message, ensure_ascii=False, default=str)) + elif isinstance(message, str): + await ws.send_text(message) + else: + await ws.send_text(str(message)) + return True + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('WSManager', + f'send_personal 失败 conn_id={conn_id}: {exc}') + except Exception: + pass + self.disconnect(conn_id) + return False + + # ------------------------------------------------------------------ + # 广播 + # ------------------------------------------------------------------ + async def broadcast(self, channel: str, message: Any) -> int: + """ + 向指定 channel 内的所有连接广播一条消息。 + 返回成功发送的数量。 + """ + if isinstance(message, (dict, list)): + text = json.dumps(message, ensure_ascii=False, default=str) + elif isinstance(message, str): + text = message + else: + text = str(message) + + members = list(self.channels.get(channel, set())) + sent = 0 + for conn_id in members: + ws = self.active_connections.get(conn_id) + if ws is None: + self.channels[channel].discard(conn_id) + continue + try: + await ws.send_text(text) + sent += 1 + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('WSManager', + f'broadcast 失败 channel={channel} conn_id={conn_id}: {exc}') + except Exception: + pass + self.disconnect(conn_id) + return sent + + async def broadcast_all(self, message: Any) -> int: + """向所有已注册连接广播一条消息。""" + if isinstance(message, (dict, list)): + text = json.dumps(message, ensure_ascii=False, default=str) + elif isinstance(message, str): + text = message + else: + text = str(message) + conn_ids = list(self.active_connections.keys()) + sent = 0 + for conn_id in conn_ids: + ws = self.active_connections.get(conn_id) + if ws is None: + continue + try: + await ws.send_text(text) + sent += 1 + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_warn('WSManager', + f'broadcast_all 失败 conn_id={conn_id}: {exc}') + except Exception: + pass + self.disconnect(conn_id) + return sent + + # ------------------------------------------------------------------ + # 通道管理 / 统计 + # ------------------------------------------------------------------ + def register_channel(self, channel: str) -> None: + """预注册一个通道名(幂等)。""" + if channel not in self.channels: + self.channels[channel] = set() + + def get_stats(self) -> dict: + per_channel: Dict[str, int] = { + ch: len(members) for ch, members in self.channels.items() + } + return { + "total_connections": len(self.active_connections), + "channels": per_channel, + } + + def connection_count(self, channel: Optional[str] = None) -> int: + """查询连接数。""" + if channel is None: + return len(self.active_connections) + return len(self.channels.get(str(channel), set())) + + def list_channels(self) -> List[str]: + return list(self.channels.keys()) + + +# 全局单例,方便其他模块直接 import +manager = ConnectionManager() + + +def get_connection_manager() -> ConnectionManager: + """获取全局 ConnectionManager 单例。""" + return manager + + +__all__ = ["ConnectionManager", "manager", "get_connection_manager"] diff --git a/src/web/backend/ws/proactive.py b/src/web/backend/ws/proactive.py new file mode 100644 index 0000000..04b962f --- /dev/null +++ b/src/web/backend/ws/proactive.py @@ -0,0 +1,235 @@ +""" +``/ws/proactive`` 端点 — 主动消息通道 +``/ws/proactive`` endpoint — proactive message channel. + +Stage A.2: 骨架实现(accept + ack)。 +Stage D.2: 完善 — 订阅 ``EventService`` 的 ``proactive_message`` 事件, + 包装 payload 为前端约定的格式后广播给客户端。 + - 频率节流:``PROACTIVE_MESSAGE_INTERVAL``(秒)可配置; + 低于阈值的重复消息不上推(前端可配置)。 + - 不修改 ``ProactiveEngine`` 现有逻辑,仅在端点层做节流。 +""" + +from __future__ import annotations + +import os +import time +import uuid +from typing import Any, Dict, Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from .manager import manager + +try: + from src.web.backend.services.event_service import get_event_service +except Exception: # noqa: BLE001 + def get_event_service(): + return None + +try: + from src.tools.debug_logger import get_debug_logger +except Exception: # noqa: BLE001 + def get_debug_logger(): + class _Stub: + def log_info(self, *args, **kwargs): pass + def log_warn(self, *args, **kwargs): pass + def log_error(self, *args, **kwargs): pass + return _Stub() + +debug_logger = get_debug_logger() + + +router = APIRouter() +CHANNEL = "proactive" + + +# ---------------------------------------------------------------------- +# 频率节流配置 +# ---------------------------------------------------------------------- +def _get_proactive_interval() -> float: + """ + 读取 ``PROACTIVE_MESSAGE_INTERVAL`` 环境变量(秒)。 + 解析失败或未设置 → 返回 0(不节流,沿用 ProactiveEngine 自身节流)。 + """ + raw = os.getenv("PROACTIVE_MESSAGE_INTERVAL", "") + try: + if raw is None or str(raw).strip() == "": + return 0.0 + v = float(str(raw).strip()) + return max(0.0, v) + except (TypeError, ValueError): + return 0.0 + + +# 进程内节流状态(按 channel 共享即可) +_last_proactive_at: float = 0.0 +_PROACTIVE_INTERVAL_SEC: float = _get_proactive_interval() + + +def _should_throttle() -> bool: + """ + 是否被节流。返回 True 表示应跳过本次上推。 + """ + if _PROACTIVE_INTERVAL_SEC <= 0: + return False + now = time.time() + if (now - _last_proactive_at) < _PROACTIVE_INTERVAL_SEC: + return True + return False + + +def _mark_proactive_pushed() -> None: + global _last_proactive_at + _last_proactive_at = time.time() + + +# ---------------------------------------------------------------------- +# Payload 包装 +# ---------------------------------------------------------------------- +def _wrap_proactive_payload(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """ + 将 EventManager / ProactiveEngine 推送的 payload 包装为前端期望的格式。 + 字段: + - type: "proactive_message" + - content: 主动消息文本 + - timestamp: ISO 8601 时间戳 + - user: 接收用户 + - reason: 想法来源(habit_care / open_loop_recall / creative_share / weather_note) + - play_sound: 是否播放通知音(默认 True) + """ + if not isinstance(payload, dict): + payload = {} + content = str(payload.get("content") or payload.get("text") or "") + timestamp = str(payload.get("timestamp") or "") + user = str(payload.get("user") or "default") + reason = str(payload.get("reason") or payload.get("source") or "") + priority = str(payload.get("priority") or "normal") + return { + "type": "proactive_message", + "content": content, + "timestamp": timestamp, + "user": user, + "reason": reason, + "priority": priority, + "idea_id": str(payload.get("idea_id") or ""), + "source": str(payload.get("source") or "ProactiveEngine"), + "play_sound": bool(payload.get("play_sound", True)), + } + + +# ---------------------------------------------------------------------- +# 端点 +# ---------------------------------------------------------------------- +@router.websocket("/ws/proactive") +async def proactive_endpoint(websocket: WebSocket) -> None: + """ + 主动消息通道 WebSocket 端点。 + + 协议: + - 客户端 connect → 服务端发送 ``{"type": "ack", "conn_id": ..., "channel": "proactive"}``。 + - 客户端发送任意消息 → 服务端回 ``{"type": "ack", "received": true}``。 + - 主动推送:服务端通过 ``EventService`` 监听 ``proactive_message`` 事件, + 节流检查后包装为前端约定格式并广播。 + """ + await websocket.accept() + conn_id = uuid.uuid4().hex + try: + await manager.connect(websocket, conn_id, CHANNEL) + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error('ws.proactive', f'connect 失败: {e}', e) + except Exception: + pass + + # Stage D.2: 订阅 proactive_message 事件 + unsubscribers = [] + try: + es = get_event_service() + if es is not None: + async def _listener(event_type: str, payload: Dict[str, Any]) -> None: + if event_type != "proactive_message": + return + if _should_throttle(): + try: + debug_logger.log_info( + 'ws.proactive', + '主动消息被节流(间隔 < PROACTIVE_MESSAGE_INTERVAL)', + ) + except Exception: + pass + return + wrapped = _wrap_proactive_payload(payload) + try: + await manager.broadcast(CHANNEL, wrapped) + _mark_proactive_pushed() + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_error( + 'ws.proactive', f'广播失败: {e}', e, + ) + except Exception: + pass + + try: + unsub = es.register_websocket_listener( + "proactive_message", _listener, + ) + if unsub is not None: + unsubscribers.append(unsub) + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_warn( + 'ws.proactive', + f'订阅 proactive_message 失败: {e}', + ) + except Exception: + pass + except Exception as e: # noqa: BLE001 + try: + debug_logger.log_warn('ws.proactive', f'初始化 listener 失败: {e}') + except Exception: + pass + + try: + await websocket.send_json( + {"type": "ack", "conn_id": conn_id, "channel": CHANNEL} + ) + + while True: + try: + raw = await websocket.receive_text() + except WebSocketDisconnect: + break + except Exception: + break + # 简单 ack + try: + await websocket.send_json({"type": "ack", "received": True}) + except Exception: + break + except WebSocketDisconnect: + pass + except Exception as exc: # noqa: BLE001 + try: + debug_logger.log_error('ws.proactive', f'主循环异常: {exc}', exc) + except Exception: + pass + finally: + # 取消订阅 + for u in unsubscribers: + try: + u() + except Exception: + pass + try: + manager.disconnect(conn_id) + except Exception: + pass + try: + await websocket.close() + except Exception: # noqa: BLE001 + pass + + +__all__ = ["router", "CHANNEL"] diff --git a/src/web/frontend/.gitignore b/src/web/frontend/.gitignore new file mode 100644 index 0000000..9dda84f --- /dev/null +++ b/src/web/frontend/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite +../static +*.local +.DS_Store diff --git a/src/web/frontend/index.html b/src/web/frontend/index.html new file mode 100644 index 0000000..8d5b14c --- /dev/null +++ b/src/web/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Neo Agent + + +
+ + + diff --git a/src/web/frontend/package-lock.json b/src/web/frontend/package-lock.json new file mode 100644 index 0000000..e2446f9 --- /dev/null +++ b/src/web/frontend/package-lock.json @@ -0,0 +1,3860 @@ +{ + "name": "neo-agent-web-frontend", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "neo-agent-web-frontend", + "version": "3.0.0", + "dependencies": { + "@ant-design/icons": "^5.3.0", + "antd": "^5.15.0", + "axios": "^1.6.7", + "echarts": "^5.5.0", + "echarts-for-react": "^3.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "react-window": "^1.8.10", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@types/react-window": "^1.8.8", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.1.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/src/web/frontend/package.json b/src/web/frontend/package.json new file mode 100644 index 0000000..015e5bf --- /dev/null +++ b/src/web/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "neo-agent-web-frontend", + "version": "3.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .ts,.tsx" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "antd": "^5.15.0", + "@ant-design/icons": "^5.3.0", + "zustand": "^4.5.0", + "echarts": "^5.5.0", + "echarts-for-react": "^3.0.2", + "axios": "^1.6.7", + "react-window": "^1.8.10" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@types/react-window": "^1.8.8", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.1.0", + "tailwindcss": "^3.4.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.35" + } +} diff --git a/src/web/frontend/postcss.config.js b/src/web/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/src/web/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx new file mode 100644 index 0000000..d1e4af1 --- /dev/null +++ b/src/web/frontend/src/App.tsx @@ -0,0 +1,31 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './components/Layout'; +import ChatPage from './pages/Chat'; +import DebugPage from './pages/Debug'; +import KnowledgePage from './pages/Knowledge'; +import SchedulePage from './pages/Schedule'; +import EventPage from './pages/Event'; +import NPSPage from './pages/NPS'; +import DatabasePage from './pages/Database'; +import CreativePage from './pages/Creative'; +import SettingsPage from './pages/Settings'; + +export default function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/src/web/frontend/src/api/chatSessions.ts b/src/web/frontend/src/api/chatSessions.ts new file mode 100644 index 0000000..9e81419 --- /dev/null +++ b/src/web/frontend/src/api/chatSessions.ts @@ -0,0 +1,156 @@ +/** + * Chat Sessions API 客户端 - v3.1.0 + * ================================= + * + * 与后端 `src/web/backend/api/chat_sessions.py` 对齐: + * - GET /api/chat/sessions?user_id=&limit=&offset= + * - POST /api/chat/sessions body: { user_id, title? } + * - GET /api/chat/sessions/{id}/messages?limit= + * - PATCH /api/chat/sessions/{id} body: { title } + * - DELETE /api/chat/sessions/{id} -> 204 + * + * 设计要点: + * - 纯 fetch,无 axios 依赖,便于与 useChatStream 解耦 + * - base URL 优先用 import.meta.env.VITE_API_BASE,否则默认 '/api' + * - 失败抛 Error, 由 UI 层决定是否弹 message.error + */ + +export interface ChatSession { + id: number; + user_id: string; + title: string; + created_at: number; + updated_at: number; + message_count: number; +} + +export interface ChatMessage { + id: number; + session_id: number; + role: 'user' | 'assistant' | 'system'; + content: string; + emotion_json: string | null; + conn_id: string | null; + created_at: number; +} + +function getBaseUrl(): string { + const env = (import.meta as any)?.env; + const base = env?.VITE_API_BASE; + if (typeof base === 'string' && base.trim()) { + return base.replace(/\/+$/, ''); + } + return '/api'; +} + +async function unwrapError(resp: Response, fallback: string): Promise { + let detail = fallback; + try { + const data = await resp.json(); + if (data && typeof data === 'object') { + const d = (data as any).detail; + if (typeof d === 'string' && d.trim()) { + detail = d; + } + } + } catch { + /* ignore parse error */ + } + throw new Error(detail); +} + +/** + * 列出用户会话(按 updated_at 倒序)。 + */ +export async function listSessions( + userId: string = 'default', + limit: number = 50, + offset: number = 0, +): Promise<{ items: ChatSession[]; total: number }> { + const url = `${getBaseUrl()}/chat/sessions?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=${offset}`; + const resp = await fetch(url, { method: 'GET' }); + if (!resp.ok) { + return unwrapError(resp, `listSessions failed: ${resp.status}`); + } + const data = await resp.json(); + return { + items: Array.isArray(data?.items) ? (data.items as ChatSession[]) : [], + total: Number(data?.total ?? 0) || 0, + }; +} + +/** + * 创建新会话。 + */ +export async function createSession( + userId: string, + title?: string, +): Promise { + const url = `${getBaseUrl()}/chat/sessions`; + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, title: title ?? '新会话' }), + }); + if (!resp.ok) { + return unwrapError(resp, `createSession failed: ${resp.status}`); + } + return (await resp.json()) as ChatSession; +} + +/** + * 删除会话(级联删 messages)。 + */ +export async function deleteSession(sessionId: number): Promise { + const url = `${getBaseUrl()}/chat/sessions/${sessionId}`; + const resp = await fetch(url, { method: 'DELETE' }); + if (!resp.ok && resp.status !== 204) { + return unwrapError(resp, `deleteSession failed: ${resp.status}`); + } +} + +/** + * 重命名会话。 + */ +export async function renameSession( + sessionId: number, + title: string, +): Promise { + const url = `${getBaseUrl()}/chat/sessions/${sessionId}`; + const resp = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + if (!resp.ok) { + return unwrapError(resp, `renameSession failed: ${resp.status}`); + } + return (await resp.json()) as ChatSession; +} + +/** + * 拉取会话消息(按 id 升序)。 + */ +export async function listMessages( + sessionId: number, + limit: number = 200, +): Promise<{ items: ChatMessage[]; total: number }> { + const url = `${getBaseUrl()}/chat/sessions/${sessionId}/messages?limit=${limit}`; + const resp = await fetch(url, { method: 'GET' }); + if (!resp.ok) { + return unwrapError(resp, `listMessages failed: ${resp.status}`); + } + const data = await resp.json(); + return { + items: Array.isArray(data?.items) ? (data.items as ChatMessage[]) : [], + total: Number(data?.total ?? 0) || 0, + }; +} + +export default { + listSessions, + createSession, + deleteSession, + renameSession, + listMessages, +}; diff --git a/src/web/frontend/src/api/creative.ts b/src/web/frontend/src/api/creative.ts new file mode 100644 index 0000000..2d0daaf --- /dev/null +++ b/src/web/frontend/src/api/creative.ts @@ -0,0 +1,235 @@ +// Creative REST API 客户端。 +// 对应后端路由:src/web/backend/api/creative.py +// - GET /api/creative/projects?status=...&limit=50 +// - GET /api/creative/projects/{uuid} +// - POST /api/creative/projects +// - POST /api/creative/projects/{uuid}/advance +// - DELETE /api/creative/projects/{uuid} +// +// 类型与 src/web/backend/schemas/creative.py 的 Pydantic v2 模型对齐: +// - CreativeProject / CreativeProjectListResponse +// - CreativeProjectCreate +// - AdvanceRequest / AdvanceResponse +// Vite dev server 已将 /api 代理到后端 8000 端口(vite.config.ts)。 + +import type { CreativeProject } from '@/types/creative'; + +// 重新导出常用类型,保持单点导入(其他类型由调用方按需直接从 '@/types/creative' 取)。 +export type { + CreativeProject, + StoryBible, + Chapter, + Character, + CreativeProjectStatus, +} from '@/types/creative'; + +// 列表接口的响应包:与后端 CreativeProjectListResponse 对齐 +export interface CreativeProjectListResponse { + projects: CreativeProject[]; + total: number; +} + +// 续写请求体:与后端 AdvanceRequest 对齐 +export interface AdvanceRequest { + user?: string; + hint?: string; + advance_async?: boolean; +} + +// 续写响应:与后端 AdvanceResponse 对齐 +export interface AdvanceResponse { + project_uuid: string; + task_id: string; + status: string; + last_advanced_at?: string | null; + project_status: string; + message: string; +} + +// 创建项目请求体:与后端 CreativeProjectCreate 对齐 +export interface CreativeProjectCreate { + title: string; + work_type?: string; + premise?: string; + tone?: string; + point_of_view?: string; + target_chars?: number; + inspiration_source?: string; + outline?: string[]; + characters?: Record[]; +} + +// 自定义错误:包含后端 detail 和 HTTP 状态码 +export class CreativeApiError extends Error { + status: number; + detail: string; + constructor(status: number, detail: string) { + super(`Creative API error ${status}: ${detail}`); + this.name = 'CreativeApiError'; + this.status = status; + this.detail = detail; + } +} + +const DEFAULT_TIMEOUT_MS = 15_000; + +/** + * 统一 fetch 封装: + * - 解析 JSON 响应 + * - 非 2xx 抛出 CreativeApiError + * - 自动拼接 query string + * - 支持 AbortController 超时 + */ +async function request( + path: string, + init: RequestInit = {}, + query?: Record, +): Promise { + let url = path; + if (query) { + const params = new URLSearchParams(); + Object.entries(query).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') { + params.append(k, String(v)); + } + }); + const qs = params.toString(); + if (qs) url += (url.includes('?') ? '&' : '?') + qs; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + + let res: Response; + try { + res = await fetch(url, { + ...init, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(init.headers || {}), + }, + }); + } catch (err) { + clearTimeout(timer); + if ((err as Error).name === 'AbortError') { + throw new CreativeApiError(0, `请求超时:${url}`); + } + throw new CreativeApiError(0, `网络错误:${(err as Error).message}`); + } + clearTimeout(timer); + + // 204 No Content + if (res.status === 204) { + return undefined as unknown as T; + } + + let payload: unknown = null; + const text = await res.text(); + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { detail: text }; + } + } + + if (!res.ok) { + const detail = + (payload && typeof payload === 'object' && 'detail' in payload + ? String((payload as { detail: unknown }).detail) + : null) || `HTTP ${res.status}`; + throw new CreativeApiError(res.status, detail); + } + + return payload as T; +} + +// ===================================================================== +// 对外 API +// ===================================================================== + +/** + * 列出创作项目。 + * GET /api/creative/projects?status=...&limit=50 + * @param status 可选过滤条件:active / paused / completed / drafting / finished + * @param limit 返回上限(1-200),默认 50 + */ +export async function listProjects( + status?: string, + limit: number = 50, +): Promise { + return request( + '/api/creative/projects', + { method: 'GET' }, + { status, limit }, + ); +} + +/** + * 读取项目详情(含 Story Bible)。 + * GET /api/creative/projects/{uuid} + * 404 时抛 CreativeApiError(404, ...)。 + */ +export async function getProject(uuid: string): Promise { + if (!uuid) { + throw new CreativeApiError(400, 'project uuid is required'); + } + return request( + `/api/creative/projects/${encodeURIComponent(uuid)}`, + { method: 'GET' }, + ); +} + +/** + * 创建创作项目。 + * POST /api/creative/projects + * 返回 201 + CreativeProject。 + */ +export async function createProject( + payload: CreativeProjectCreate, +): Promise { + return request('/api/creative/projects', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +/** + * 触发续写。默认异步模式:返回 task_id,进度通过 /ws/events 的 + * 'creative_progress' 事件推送。 + * POST /api/creative/projects/{uuid}/advance + * 404 时抛 CreativeApiError(404, ...)。 + */ +export async function advanceProject( + uuid: string, + payload: AdvanceRequest = {}, +): Promise { + if (!uuid) { + throw new CreativeApiError(400, 'project uuid is required'); + } + return request( + `/api/creative/projects/${encodeURIComponent(uuid)}/advance`, + { + method: 'POST', + // body 可缺省;后端会使用 AdvanceRequest() 默认值 + body: JSON.stringify(payload), + }, + ); +} + +/** + * 删除项目。 + * DELETE /api/creative/projects/{uuid} + * 成功返回 204 No Content。 + */ +export async function deleteProject(uuid: string): Promise { + if (!uuid) { + throw new CreativeApiError(400, 'project uuid is required'); + } + await request( + `/api/creative/projects/${encodeURIComponent(uuid)}`, + { method: 'DELETE' }, + ); +} diff --git a/src/web/frontend/src/api/database.ts b/src/web/frontend/src/api/database.ts new file mode 100644 index 0000000..3f4375d --- /dev/null +++ b/src/web/frontend/src/api/database.ts @@ -0,0 +1,137 @@ +// Database API 客户端 +// 包装 `/api/database/*` 三个端点:tables 列表、单表分页查询、按 uuid 删除(带 X-Confirm 二次确认头)。 +// 与后端 schemas/database.py 契约对齐。 + +import axios, { AxiosError } from 'axios'; +import type { Row } from '@/types/database'; + +// ---------------------------------------------------------------------- +// 类型契约(与后端 Pydantic v2 模型对齐) +// ---------------------------------------------------------------------- + +/** `GET /api/database/tables` 响应 */ +export interface TablesListResponse { + tables: string[]; + total: number; +} + +/** `GET /api/database/{table}?limit=&offset=` 响应 */ +export interface TableDataResponse { + table: string; + rows: Row[]; + total: number; +} + +/** `DELETE /api/database/{table}/{uuid}` 响应 */ +export interface DeleteResponse { + table: string; + uuid: string; + deleted: boolean; +} + +/** 后端统一错误响应:`{"error": "..."}` */ +export interface ApiErrorBody { + error: string; +} + +// ---------------------------------------------------------------------- +// axios 实例:走 vite dev server 的 `/api` 代理,无需写 baseURL +// ---------------------------------------------------------------------- + +const http = axios.create({ + timeout: 15000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// ---------------------------------------------------------------------- +// 错误处理工具 +// ---------------------------------------------------------------------- + +/** + * 把 axios 错误规整为「中文用户可读」的字符串。 + * 优先使用后端 `{"error": "..."}` 字段(项目硬约束:所有 4xx 响应都是这个格式)。 + */ +export const formatApiError = (err: unknown): string => { + if (axios.isAxiosError(err)) { + const axErr = err as AxiosError; + const backendMsg = axErr.response?.data?.error; + if (backendMsg) return backendMsg; + if (axErr.response?.status === 400) { + return '请求参数错误(400)'; + } + if (axErr.response?.status === 404) { + return '未找到对应记录(404)'; + } + if (axErr.code === 'ECONNABORTED') { + return '请求超时,请检查后端服务'; + } + if (axErr.message) return axErr.message; + } + if (err instanceof Error) return err.message; + return '未知错误'; +}; + +// ---------------------------------------------------------------------- +// 1) GET /api/database/tables +// ---------------------------------------------------------------------- + +/** + * 列出白名单中实际存在的业务表。 + * @returns 表名列表 + total + */ +export const listTables = async (): Promise => { + const res = await http.get('/api/database/tables'); + return res.data; +}; + +// ---------------------------------------------------------------------- +// 2) GET /api/database/{table}?limit=100&offset=0 +// ---------------------------------------------------------------------- + +/** + * 分页查询指定表的数据。 + * @param table 表名(必须命中后端白名单,否则 400) + * @param limit 每页行数,默认 100 + * @param offset 偏移量,默认 0 + */ +export const getTableData = async ( + table: string, + limit = 100, + offset = 0, +): Promise => { + const res = await http.get( + `/api/database/${encodeURIComponent(table)}`, + { + params: { limit, offset }, + }, + ); + return res.data; +}; + +// ---------------------------------------------------------------------- +// 3) DELETE /api/database/{table}/{uuid} —— 必须带 X-Confirm: true +// ---------------------------------------------------------------------- + +/** + * 按 uuid 删除单条记录。 + * 注意:后端硬约束要求请求头 `X-Confirm: true`,缺失 → 400。 + * @param table 表名 + * @param uuid 记录 uuid + */ +export const deleteRecord = async ( + table: string, + uuid: string, +): Promise => { + const res = await http.delete( + `/api/database/${encodeURIComponent(table)}/${encodeURIComponent(uuid)}`, + { + // 必须显式声明二次确认头;缺失或非 "true" → 后端 400 + headers: { + 'X-Confirm': 'true', + }, + }, + ); + return res.data; +}; diff --git a/src/web/frontend/src/api/knowledge.ts b/src/web/frontend/src/api/knowledge.ts new file mode 100644 index 0000000..096f788 --- /dev/null +++ b/src/web/frontend/src/api/knowledge.ts @@ -0,0 +1,175 @@ +/** + * Knowledge REST API 客户端(Stage C.1)。 + * + * 端点契约(对应后端 src/web/backend/api/knowledge.py): + * GET /api/knowledge/search?q=&limit= 关键词搜索 + * GET /api/knowledge?limit= 列出所有实体 + * POST /api/knowledge 新增实体 + * DELETE /api/knowledge/{uuid} 删除实体 + * + * 设计要点: + * 1. 基于 axios(package.json 已声明 ^1.6.7),通过 Vite dev proxy 转发到 :8000。 + * 2. 所有方法返回后端原始 JSON;Entity 字段补全由 toEntity() 适配。 + * 3. 错误统一通过 antd message.error 提示,并向上抛出供调用方决定是否继续。 + * 4. 搜索关键词为空时回退到 listEntities,避免后端 q min_length=1 校验失败。 + */ + +import axios, { AxiosError } from 'axios'; +import { message } from 'antd'; +import { + KNOWN_ENTITY_CATEGORIES, + type Entity, + type EntityCategory, + type KnowledgeEntityRow, +} from '../types/knowledge'; + +const API_BASE = '/api/knowledge'; + +/** + * 把后端返回的任意字符串归一化到 EntityCategory 枚举。 + * 不在白名单时回落到 'concept',保证 UI 渲染不崩。 + */ +function normalizeCategory(input: string | undefined | null): EntityCategory { + if (!input) return 'concept'; + const value = input.trim().toLowerCase(); + if ((KNOWN_ENTITY_CATEGORIES as readonly string[]).includes(value)) { + return value as EntityCategory; + } + return 'concept'; +} + +/** + * 把后端 KnowledgeEntityRow 适配为前端 UI 使用的 Entity。 + * 后端 entities 表本身只含 uuid/name/normalized_name/created_at/updated_at; + * category/description 由 search_knowledge() 通过 definition 表补齐; + * confidence/mention_count/metadata/related_uuids 后端暂无直接字段,使用默认值。 + */ +export function toEntity(row: KnowledgeEntityRow): Entity { + return { + uuid: String(row.uuid ?? ''), + name: String(row.name ?? ''), + category: normalizeCategory(row.category), + // 后端暂未提供 confidence,UI 期望 0-1,这里给中性默认值 0.5 + confidence: 0.5, + // 后端暂未提供 mention_count,前端用 0 占位 + mention_count: 0, + updated_at: row.updated_at || row.created_at || new Date().toISOString(), + description: row.description ?? '', + metadata: {}, + related_uuids: [], + }; +} + +export interface SearchResponsePayload { + results: KnowledgeEntityRow[]; + total: number; +} + +export interface CreateResponsePayload { + uuid: string; + entity_name: string; + created: boolean; +} + +export interface DeleteResponsePayload { + uuid: string; + deleted: boolean; +} + +/** 提取后端错误信息:优先用 {"error": "..."},否则用 axios message。 */ +function extractErrorMessage(err: unknown, fallback: string): string { + const ax = err as AxiosError<{ error?: string; detail?: string }>; + const data = ax?.response?.data as { error?: string; detail?: string } | undefined; + if (data?.error) return data.error; + if (data?.detail) return data.detail; + if (ax?.message) return ax.message; + return fallback; +} + +/** + * 搜索实体。 + * - q 为空字符串时回退到 listEntities()(后端 search 要求 q.min_length=1)。 + * - 默认 limit 50 足以覆盖演示场景。 + */ +export async function searchEntities(q: string = '', limit: number = 50): Promise { + const keyword = (q ?? '').trim(); + if (!keyword) { + return listEntities(limit); + } + try { + const { data } = await axios.get(`${API_BASE}/search`, { + params: { q: keyword, limit }, + }); + const rows = Array.isArray(data?.results) ? data.results : []; + return rows.map(toEntity); + } catch (err) { + const msg = extractErrorMessage(err, '搜索知识库失败'); + message.error(`搜索失败:${msg}`); + throw err; + } +} + +/** + * 列出所有实体。 + * 后端 /api/knowledge?limit= 仅返回 entities 行,不含 definition 字段, + * 所以 description/category 会落默认值。 + */ +export async function listEntities(limit: number = 50): Promise { + try { + const { data } = await axios.get(API_BASE, { + params: { limit }, + }); + const rows = Array.isArray(data?.results) ? data.results : []; + return rows.map(toEntity); + } catch (err) { + const msg = extractErrorMessage(err, '列出知识库失败'); + message.error(`列出失败:${msg}`); + throw err; + } +} + +export interface CreateEntityPayload { + name: string; + category: EntityCategory; + description?: string; + related_info?: Record; +} + +/** + * 新增实体。 + * 前端表单字段 {name, category, description} 映射到后端 + * {entity_name, category, description, related_info}。 + */ +export async function createEntity( + payload: CreateEntityPayload, +): Promise { + try { + const { data } = await axios.post(API_BASE, { + entity_name: payload.name, + category: payload.category, + description: payload.description ?? '', + related_info: payload.related_info, + }); + message.success(`已新增实体:${data?.entity_name ?? payload.name}`); + return data; + } catch (err) { + const msg = extractErrorMessage(err, '创建实体失败'); + message.error(`创建失败:${msg}`); + throw err; + } +} + +/** 删除实体,后端返回 {uuid, deleted: true}。 */ +export async function deleteEntity(uuid: string): Promise { + try { + const { data } = await axios.delete( + `${API_BASE}/${encodeURIComponent(uuid)}`, + ); + message.success(`已删除实体:${uuid}`); + return data; + } catch (err) { + const msg = extractErrorMessage(err, '删除实体失败'); + message.error(`删除失败:${msg}`); + throw err; + } +} diff --git a/src/web/frontend/src/api/llm.ts b/src/web/frontend/src/api/llm.ts new file mode 100644 index 0000000..c3f7664 --- /dev/null +++ b/src/web/frontend/src/api/llm.ts @@ -0,0 +1,84 @@ +/** + * LLM 配置 API 客户端 - v3.1.0 + * ============================= + * + * 与后端 `src/web/backend/api/llm.py` 对齐: + * - GET /api/llm/config -> LLMConfig + * - POST /api/llm/test body: { prompt: "ping" } -> {ok, latency_ms, sample_response, error} + */ + +export interface LLMConfig { + provider: string; + base_url: string; + model_name: string; + temperature: number; + max_tokens: number; + timeout: number; + valid: boolean; + api_key_set?: boolean; +} + +export interface LLMTestResult { + ok: boolean; + latency_ms?: number; + sample_response?: string; + error?: string; +} + +function getBaseUrl(): string { + const env = (import.meta as any)?.env; + const base = env?.VITE_API_BASE; + if (typeof base === 'string' && base.trim()) { + return base.replace(/\/+$/, ''); + } + return '/api'; +} + +async function unwrapError(resp: Response, fallback: string): Promise { + let detail = fallback; + try { + const data = await resp.json(); + if (data && typeof data === 'object') { + const d = (data as any).detail; + if (typeof d === 'string' && d.trim()) { + detail = d; + } + } + } catch { + /* ignore */ + } + throw new Error(detail); +} + +/** + * 读取当前生效 LLM 配置(provider / base_url / model / 温度 / tokens / timeout / 有效性)。 + */ +export async function getConfig(): Promise { + const url = `${getBaseUrl()}/llm/config`; + const resp = await fetch(url, { method: 'GET' }); + if (!resp.ok) { + return unwrapError(resp, `getConfig failed: ${resp.status}`); + } + return (await resp.json()) as LLMConfig; +} + +/** + * 调一次 ping 验证 LLM 连通性。 + */ +export async function testConnection(prompt: string = 'ping'): Promise { + const url = `${getBaseUrl()}/llm/test`; + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + if (!resp.ok) { + return unwrapError(resp, `testConnection failed: ${resp.status}`); + } + return (await resp.json()) as LLMTestResult; +} + +export default { + getConfig, + testConnection, +}; diff --git a/src/web/frontend/src/api/nps.ts b/src/web/frontend/src/api/nps.ts new file mode 100644 index 0000000..1550d19 --- /dev/null +++ b/src/web/frontend/src/api/nps.ts @@ -0,0 +1,229 @@ +/** + * NPS 前端 API 客户端。 + * 对接后端 /api/nps 路由: + * GET /api/nps 列出已注册 NPS + * POST /api/nps/register 注册新 NPS + * POST /api/nps/invoke 调用 NPS + * + * 字段命名约定: + * 后端使用 `parameters` (Record) 与 `registered_at`; + * 前端页面使用 `params` (NPSParam[]) 与 `created_at`。 + * 在此模块内做映射,避免污染页面层。 + */ + +import axios, { AxiosError, AxiosResponse } from 'axios'; +import type { NPS, NPSParam, NPSType, NPSConfigFormValues } from '../types/nps'; + +// ===================================================================== +// 后端契约类型 +// ===================================================================== + +/** 后端 NPS 条目形状(GET /api/nps / POST /api/nps/register 的响应)。 */ +export interface NpsEntry { + uuid: string; + name: string; + description: string; + version: string; + parameters: Record; + registered_at: string; + // 可选:UI 需要的扩展字段(后端可能返回) + enabled?: boolean; + nps_type?: NPSType; + call_count?: number; + last_called_at?: string | null; +} + +/** 注册 NPS 的请求体(POST /api/nps/register)。 */ +export interface NpsRegisterPayload { + name: string; + description: string; + parameters: Record; + version?: string; + enabled?: boolean; + nps_type?: NPSType; +} + +/** 调用 NPS 的请求体(POST /api/nps/invoke)。 */ +export interface NpsInvokePayload { + uuid: string; + args: Record; +} + +/** 调用 NPS 的响应(POST /api/nps/invoke)。 */ +export interface NpsInvokeResponse { + result: unknown; + status: string; + executed_at: string; +} + +/** 注册 NPS 的入参(页面 → API 模块)。 */ +export type NpsRegisterInput = NPSConfigFormValues; + +// ===================================================================== +// axios 实例 +// ===================================================================== + +// 走 vite proxy:/api → http://localhost:8000 +const http = axios.create({ + baseURL: '/api', + timeout: 15000, +}); + +// ===================================================================== +// 内部工具:字段映射 +// ===================================================================== + +/** + * 把后端 NpsEntry 映射为前端 NPS 形状。 + * 后端 parameters (dict) → 前端 params (NPSParam[])。 + */ +export function toFrontendNps(entry: NpsEntry): NPS { + const params: NPSParam[] = Object.entries(entry.parameters ?? {}).map( + ([key, value]) => ({ + key, + // 复杂值序列化为字符串,避免丢信息 + value: + typeof value === 'string' + ? value + : value == null + ? '' + : JSON.stringify(value), + }), + ); + return { + uuid: entry.uuid, + name: entry.name, + description: entry.description ?? '', + nps_type: (entry.nps_type ?? 'function') as NPSType, + params, + enabled: entry.enabled ?? true, + call_count: entry.call_count ?? 0, + last_called_at: entry.last_called_at ?? null, + created_at: entry.registered_at ?? new Date().toISOString(), + }; +} + +/** + * 把前端 NPSParam[] 转换回后端 parameters (dict)。 + * 过滤掉 key 为空的项。 + */ +function paramsToParameters( + params: NPSParam[] | undefined, +): Record { + if (!params) return {}; + const out: Record = {}; + for (const p of params) { + const k = (p.key ?? '').trim(); + if (!k) continue; + out[k] = p.value ?? ''; + } + return out; +} + +/** 兼容多种列表响应形态(数组 / {results} / {items} / {data})。 */ +function unwrapList(payload: unknown): NpsEntry[] { + if (Array.isArray(payload)) return payload as NpsEntry[]; + if (payload && typeof payload === 'object') { + const obj = payload as Record; + for (const key of ['results', 'items', 'data', 'list']) { + const v = obj[key]; + if (Array.isArray(v)) return v as NpsEntry[]; + } + } + return []; +} + +/** + * 解析 axios 错误,提取可读中文消息。 + */ +function extractErrorMessage(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const axErr = err as AxiosError<{ error?: string; detail?: string; message?: string }>; + if (axErr.response?.data) { + const data = axErr.response.data; + if (typeof data === 'string') return data; + if (typeof data === 'object' && data !== null) { + return ( + data.error || + data.detail || + data.message || + axErr.message || + fallback + ); + } + } + return axErr.message || fallback; + } + if (err instanceof Error) return err.message || fallback; + return fallback; +} + +// ===================================================================== +// 公开 API +// ===================================================================== + +/** + * 列出已注册的 NPS。 + * 失败时抛 Error(message 为可读中文),由调用方捕获后 message.error 显示。 + */ +export async function listNps(): Promise { + try { + const resp: AxiosResponse = await http.get('/nps'); + const raw = unwrapList(resp.data); + return raw.map(toFrontendNps); + } catch (err) { + throw new Error(extractErrorMessage(err, '获取 NPS 列表失败')); + } +} + +/** + * 注册新 NPS。 + * 入参使用页面表单形状;内部转换为后端契约。 + */ +export async function registerNps( + payload: NpsRegisterInput, +): Promise { + try { + const body: NpsRegisterPayload = { + name: payload.name, + description: payload.description ?? '', + parameters: paramsToParameters(payload.params), + enabled: payload.enabled, + nps_type: payload.nps_type, + version: '1.0.0', + }; + const resp = await http.post('/nps/register', body); + if (!resp.data || !resp.data.uuid) { + throw new Error('注册响应缺少 uuid'); + } + return toFrontendNps(resp.data); + } catch (err) { + throw new Error(extractErrorMessage(err, '注册 NPS 失败')); + } +} + +/** + * 调用已注册的 NPS。 + * 失败时抛 Error,由调用方处理。 + */ +export async function invokeNps( + payload: NpsInvokePayload, +): Promise { + try { + const resp = await http.post('/nps/invoke', payload); + if (!resp.data || typeof resp.data !== 'object') { + throw new Error('调用响应格式异常'); + } + return resp.data; + } catch (err) { + throw new Error(extractErrorMessage(err, '调用 NPS 失败')); + } +} + +// 兼容命名空间导入:import * as npsApi from '../../api/nps' +export default { + listNps, + registerNps, + invokeNps, + toFrontendNps, +}; diff --git a/src/web/frontend/src/api/schedule.ts b/src/web/frontend/src/api/schedule.ts new file mode 100644 index 0000000..69e16c8 --- /dev/null +++ b/src/web/frontend/src/api/schedule.ts @@ -0,0 +1,243 @@ +/** + * Schedule API 客户端 + * + * 与后端 `src/web/backend/api/schedule.py` 对齐: + * - GET /api/schedule?date=YYYY-MM-DD + * - GET /api/schedule?from=YYYY-MM-DD&to=YYYY-MM-DD + * - POST /api/schedule + * - PUT /api/schedule/{id} + * - DELETE /api/schedule/{id} + * - POST /api/schedule/{id}/confirm?confirmed=true + * + * 设计要点: + * - 失败统一抛 Error(message=后端 detail 或 axios message),由调用方决定是否弹 message.error + * - 字段映射:后端 `id`(int) → 前端 `uuid`(string) + * - 协作字段后端 DTO 未暴露,默认 false/undefined(后续若 backend 扩展可自动读取) + */ + +import axios, { type AxiosError } from 'axios'; + +export type SchedulePriority = 'low' | 'normal' | 'high'; +export type ScheduleType = 'personal' | 'work' | 'family'; +export type ScheduleStatus = 'pending' | 'confirmed' | 'done' | 'cancelled'; +export type CollaboratorStatus = 'confirmed' | 'pending' | 'declined'; + +/** 前端 UI 使用的日程视图(字段超集,后端未给的协作字段给默认值) */ +export interface Schedule { + /** 数据库主键(后端 int) */ + id?: number; + /** 字符串形式的主键,UI 列表的 rowKey */ + uuid: string; + title: string; + description: string; + /** ISO 字符串 */ + start_time: string; + /** ISO 字符串 */ + end_time: string; + priority: SchedulePriority; + schedule_type: ScheduleType; + status: ScheduleStatus; + /** 是否协作日程 */ + is_collaborative: boolean; + /** 协作方状态 */ + collaborator_status?: CollaboratorStatus; + /** 协作人名称 */ + collaborator_name?: string; +} + +/** 新建日程请求体(对应后端 ScheduleCreate) */ +export interface ScheduleCreatePayload { + title: string; + description?: string; + start_time: string; + end_time: string; + priority?: SchedulePriority; + schedule_type?: ScheduleType; +} + +/** 更新日程请求体(对应后端 ScheduleUpdate) */ +export interface ScheduleUpdatePayload { + title?: string; + description?: string; + start_time?: string; + end_time?: string; + priority?: SchedulePriority; + status?: ScheduleStatus; +} + +const client = axios.create({ baseURL: '/api' }); + +/** 后端原始 DTO 行(容忍空字段) */ +interface RawScheduleRow { + id?: number | string; + uuid?: string; + title?: string; + description?: string | null; + start_time?: string; + end_time?: string | null; + priority?: string; + status?: string; + schedule_type?: string; + is_collaborative?: number | boolean | null; + collaborator_status?: string | null; + collaborator_name?: string | null; + confirmed?: number | boolean | null; +} + +function normalizePriority(v?: string | null): SchedulePriority { + if (v === 'low' || v === 'high') return v; + return 'normal'; +} + +function normalizeType(v?: string | null): ScheduleType { + if (v === 'work' || v === 'family') return v; + return 'personal'; +} + +function normalizeStatus(v?: string | null): ScheduleStatus { + if (v === 'confirmed' || v === 'done' || v === 'cancelled') return v; + return 'pending'; +} + +function normalizeCollaboratorStatus( + v?: string | null, +): CollaboratorStatus | undefined { + if (v === 'confirmed' || v === 'declined') return v; + if (v === 'pending') return 'pending'; + return undefined; +} + +/** 后端 DTO 行 → 前端 Schedule */ +function mapRow(row: RawScheduleRow): Schedule { + const idNum = + row.id !== undefined && row.id !== null ? Number(row.id) : undefined; + const safeId = idNum !== undefined && Number.isFinite(idNum) ? idNum : undefined; + return { + id: safeId, + uuid: String(row.uuid || safeId || ''), + title: String(row.title || ''), + description: row.description ?? '', + start_time: String(row.start_time || ''), + end_time: row.end_time ? String(row.end_time) : '', + priority: normalizePriority(row.priority), + schedule_type: normalizeType(row.schedule_type), + status: normalizeStatus(row.status), + is_collaborative: Boolean(row.is_collaborative), + collaborator_status: normalizeCollaboratorStatus(row.collaborator_status), + collaborator_name: row.collaborator_name ?? undefined, + }; +} + +/** 提取后端错误信息(优先 detail) */ +function unwrapError(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const ax = err as AxiosError<{ detail?: string | unknown[] }>; + const detail = ax.response?.data?.detail; + if (typeof detail === 'string' && detail) return detail; + if (Array.isArray(detail)) { + const joined = detail + .map((d) => + typeof d === 'object' ? JSON.stringify(d) : String(d), + ) + .join('; '); + if (joined) return joined; + } + return ax.message || fallback; + } + if (err instanceof Error && err.message) return err.message; + return fallback; +} + +/** + * 按日期查询日程。 + * @param date YYYY-MM-DD + */ +export async function listByDate(date: string): Promise { + try { + const { data } = await client.get<{ items?: RawScheduleRow[] }>('/schedule', { + params: { date }, + }); + return (data.items || []).map(mapRow); + } catch (err) { + throw new Error(unwrapError(err, '查询日程失败')); + } +} + +/** + * 按日期区间查询日程。 + * @param from YYYY-MM-DD + * @param to YYYY-MM-DD + */ +export async function listByRange(from: string, to: string): Promise { + try { + const { data } = await client.get<{ items?: RawScheduleRow[] }>('/schedule', { + params: { from, to }, + }); + return (data.items || []).map(mapRow); + } catch (err) { + throw new Error(unwrapError(err, '查询日程区间失败')); + } +} + +/** + * 新建日程。 + */ +export async function create(payload: ScheduleCreatePayload): Promise { + try { + const { data } = await client.post<{ + id?: number | string; + item?: RawScheduleRow; + }>('/schedule', payload); + const row: RawScheduleRow = data.item + ? { ...payload, ...data.item, id: data.item.id ?? data.id } + : { ...payload, id: data.id }; + return mapRow(row); + } catch (err) { + throw new Error(unwrapError(err, '新建日程失败')); + } +} + +/** + * 更新日程。 + */ +export async function update( + id: number | string, + payload: ScheduleUpdatePayload, +): Promise { + try { + await client.put(`/schedule/${id}`, { id: Number(id), ...payload }); + } catch (err) { + throw new Error(unwrapError(err, '更新日程失败')); + } +} + +/** + * 删除日程。 + */ +export async function remove(id: number | string): Promise { + try { + await client.delete(`/schedule/${id}`); + } catch (err) { + throw new Error(unwrapError(err, '删除日程失败')); + } +} + +/** + * 协作日程确认 / 取消确认。 + * @param confirmed true=确认, false=取消确认(回到 pending) + */ +export async function confirmCollaboration( + id: number | string, + confirmed: boolean = true, +): Promise { + try { + const { data } = await client.post<{ status?: string }>( + `/schedule/${id}/confirm`, + null, + { params: { confirmed } }, + ); + return normalizeStatus(data.status); + } catch (err) { + throw new Error(unwrapError(err, '协作确认失败')); + } +} diff --git a/src/web/frontend/src/components/ChatBubble.tsx b/src/web/frontend/src/components/ChatBubble.tsx new file mode 100644 index 0000000..264936b --- /dev/null +++ b/src/web/frontend/src/components/ChatBubble.tsx @@ -0,0 +1,129 @@ +/** + * ChatBubble - visual representation of a single chat message. + * + * - user: right-aligned, blue background + * - assistant: left-aligned, white background, markdown rendered + * - system: centered, gray + * + * Streaming assistant messages show a blinking caret at the end. + */ + +import React, { useMemo } from 'react'; +import { Typography } from 'antd'; +import type { ChatMessage } from '../hooks/useChatStream'; + +const { Text } = Typography; + +export interface ChatBubbleProps { + message: ChatMessage; + /** Optional render function for markdown; defaults to a simple per-line renderer */ + renderMarkdown?: (content: string) => React.ReactNode; +} + +function formatRelativeTime(iso: string): string { + try { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diff = Date.now() - then; + if (diff < 0) return '刚刚'; + const sec = Math.floor(diff / 1000); + if (sec < 10) return '刚刚'; + if (sec < 60) return `${sec}秒前`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}分钟前`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}小时前`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}天前`; + return new Date(iso).toLocaleDateString(); + } catch { + return ''; + } +} + +function defaultMarkdown(content: string): React.ReactNode { + // Lightweight renderer: split by newlines, keep it simple. + return content.split('\n').map((line, idx) => ( + + {line} + {idx < content.split('\n').length - 1 ?
: null} +
+ )); +} + +const baseBubble = + 'inline-block max-w-[80%] px-3 py-2 rounded-lg shadow-sm break-words whitespace-pre-wrap leading-relaxed text-sm'; + +const stylesByRole: Record = { + user: { + wrap: 'flex justify-end', + bubble: `${baseBubble} bg-blue-500 text-white rounded-tr-sm`, + text: 'text-white', + align: 'items-end', + }, + assistant: { + wrap: 'flex justify-start', + bubble: `${baseBubble} bg-white text-gray-800 border border-gray-200 rounded-tl-sm`, + text: 'text-gray-800', + align: 'items-start', + }, + system: { + wrap: 'flex justify-center', + bubble: `${baseBubble} bg-gray-100 text-gray-500 italic rounded-full px-4 py-1 text-xs`, + text: 'text-gray-500', + align: 'items-center', + }, +}; + +const ChatBubbleComponent: React.FC = ({ message, renderMarkdown }) => { + const { role, content, timestamp, done } = message; + const style = stylesByRole[role] ?? stylesByRole.system; + const isStreaming = role === 'assistant' && done === false; + + const renderedContent = useMemo(() => { + if (role === 'system') return content; + const fn = renderMarkdown ?? defaultMarkdown; + return fn(content); + }, [role, content, renderMarkdown]); + + if (role === 'system') { + return ( +
+
+
+ {content} +
+ {timestamp ? ( + + {formatRelativeTime(timestamp)} + + ) : null} +
+
+ ); + } + + return ( +
+
+
+
{renderedContent}
+ {isStreaming ? ( + ▍ + ) : null} +
+ {timestamp ? ( + + {formatRelativeTime(timestamp)} + + ) : null} +
+
+ ); +}; + +export const ChatBubble = React.memo(ChatBubbleComponent); +export default ChatBubble; diff --git a/src/web/frontend/src/components/ChatInput.tsx b/src/web/frontend/src/components/ChatInput.tsx new file mode 100644 index 0000000..de00883 --- /dev/null +++ b/src/web/frontend/src/components/ChatInput.tsx @@ -0,0 +1,91 @@ +/** + * ChatInput - auto-resizing textarea with send button. + * + * 快捷键: + * - Enter: 发送 + * - Shift+Enter: 换行 + * - Ctrl/⌘ + Enter: 发送(与 Enter 等价,便于多行输入场景) + * - Disabled while sending + */ + +import React, { useCallback, useRef, useState } from 'react'; +import { Input, Button, Tooltip } from 'antd'; +import { SendOutlined, LoadingOutlined } from '@ant-design/icons'; + +export interface ChatInputProps { + onSend: (text: string) => void; + disabled?: boolean; + placeholder?: string; + /** Maximum rows the textarea will auto-grow to. */ + maxRows?: number; +} + +const ChatInputComponent: React.FC = ({ + onSend, + disabled = false, + placeholder = '按 Ctrl/⌘ + Enter 发送,Enter 发送,Shift+Enter 换行', + maxRows = 6, +}) => { + const [value, setValue] = useState(''); + const taRef = useRef(null); + + const submit = useCallback(() => { + const text = value.trim(); + if (!text || disabled) return; + onSend(text); + setValue(''); + // refocus after sending + requestAnimationFrame(() => { + try { + taRef.current?.focus?.(); + } catch { + // ignore + } + }); + }, [value, disabled, onSend]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Ctrl/⌘ + Enter 发送 + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + submit(); + return; + } + // Enter 直接发送(无 Shift) + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + submit(); + } + }, + [submit] + ); + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + autoSize={{ minRows: 1, maxRows }} + placeholder={placeholder} + disabled={disabled} + className="flex-1" + /> + + + +
+ ); +}; + +export const ChatInput = React.memo(ChatInputComponent); +export default ChatInput; diff --git a/src/web/frontend/src/components/DataTable.tsx b/src/web/frontend/src/components/DataTable.tsx new file mode 100644 index 0000000..5e37bf0 --- /dev/null +++ b/src/web/frontend/src/components/DataTable.tsx @@ -0,0 +1,171 @@ +import React, { useMemo, useState } from 'react'; +import { Table, Button, Popconfirm, Tag, Tooltip, Typography, Space } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { DeleteOutlined, CodeOutlined } from '@ant-design/icons'; +import type { Column, Row } from '@/types/database'; + +const { Text, Paragraph } = Typography; + +export interface DataTableProps { + columns: Column[]; + data: Row[]; + loading?: boolean; + rowKey?: string; + onDelete?: (uuid: string) => void; +} + +const formatValue = (value: unknown, type: Column['type']): React.ReactNode => { + if (value === null || value === undefined) { + return —; + } + switch (type) { + case 'number': + return {Number(value).toLocaleString()}; + case 'datetime': + return {String(value)}; + case 'boolean': + return value ? true : false; + case 'json': + return ; + case 'string': + default: + return {String(value)}; + } +}; + +const JsonCell: React.FC<{ value: unknown }> = ({ value }) => { + const [open, setOpen] = useState(false); + const text = useMemo(() => { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + }, [value]); + if (open) { + return ( + + +
+          {text}
+        
+
+ ); + } + return ( + + + + ); +}; + +const DataTable: React.FC = ({ + columns, + data, + loading = false, + rowKey = 'uuid', + onDelete, +}) => { + const tableColumns: ColumnsType = useMemo(() => { + const antdColumns: ColumnsType = columns.map((col) => ({ + title: col.label, + dataIndex: col.key, + key: col.key, + width: col.width, + sorter: + col.type === 'number' || col.type === 'datetime' + ? (a: Row, b: Row) => { + const av = a[col.key]; + const bv = b[col.key]; + if (typeof av === 'number' && typeof bv === 'number') { + return av - bv; + } + return String(av ?? '').localeCompare(String(bv ?? '')); + } + : undefined, + filters: + col.type === 'string' || col.type === 'datetime' + ? Array.from(new Set(data.map((r) => String(r[col.key] ?? '')))) + .slice(0, 20) + .map((v) => ({ text: v, value: v })) + : undefined, + onFilter: (value, record) => String(record[col.key] ?? '') === value, + render: (value: unknown) => formatValue(value, col.type), + ellipsis: col.type === 'string' || col.type === 'datetime', + })); + + if (onDelete) { + antdColumns.push({ + title: 'Actions', + key: 'actions', + width: 120, + fixed: 'right', + render: (_value, record) => { + const uuid = String(record[rowKey] ?? ''); + return ( + onDelete(uuid)} + > + + + ); + }, + }); + } + + return antdColumns; + }, [columns, data, onDelete, rowKey]); + + return ( +
+ + rowKey={rowKey} + columns={tableColumns} + dataSource={data} + loading={loading} + scroll={{ x: 'max-content' }} + size="small" + pagination={{ + defaultPageSize: 10, + pageSizeOptions: ['10', '20', '50', '100'], + showSizeChanger: true, + showTotal: (total) => `${total} rows`, + }} + /> + {data.length === 0 && !loading ? ( + + No rows in this table. + + ) : null} +
+ ); +}; + +export default DataTable; diff --git a/src/web/frontend/src/components/Debug/LogExporter.tsx b/src/web/frontend/src/components/Debug/LogExporter.tsx new file mode 100644 index 0000000..f75f5b6 --- /dev/null +++ b/src/web/frontend/src/components/Debug/LogExporter.tsx @@ -0,0 +1,111 @@ +/** + * LogExporter - export log entries as TXT or NDJSON via Blob download. + * + * The component is purely presentational + serialization; it does not + * know how the log list was filtered. The parent passes the array it + * wants exported (typically the filtered view, so "what you see is + * what you export"). + * + * Output formats: + * - TXT: one entry per line, "[timestamp] [level] [module] message" + * - JSON: NDJSON, `logs.map(JSON.stringify).join('\n')` — one JSON + * object per line, friendly to `jq` and log-shippers. + * + * antd 5 removed `Button.Group` in favor of `Space.Compact`; we use the + * latter to match the recommended antd v5 idiom and stay consistent + * with the rest of the codebase (NPSConfigDrawer). + */ + +import React, { useCallback } from 'react'; +import { Button, Space, message as antdMessage } from 'antd'; +import { DownloadOutlined, CodeOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { LogEntry } from './types'; + +export interface LogExporterProps { + logs: LogEntry[]; + /** Filename prefix; default "debug". Final name is `${prefix}-YYYYMMDD-HHmmss.{txt,json}`. */ + filenamePrefix?: string; + /** Optional click hook, fired after a successful export. */ + onExported?: (format: 'txt' | 'json', count: number) => void; +} + +function downloadFile(filename: string, content: string, mime: string): void { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Defer revoke so Safari has a chance to start the download. + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function formatLogLine(l: LogEntry): string { + return `[${l.timestamp}] [${l.level}] [${l.module}] ${l.message}`; +} + +function buildFilename(prefix: string, ext: 'txt' | 'json'): string { + return `${prefix}-${dayjs().format('YYYYMMDD-HHmmss')}.${ext}`; +} + +const LogExporter: React.FC = ({ + logs, + filenamePrefix = 'debug', + onExported, +}) => { + const guardEmpty = useCallback((): boolean => { + if (logs.length === 0) { + antdMessage.warning('没有可导出的日志'); + return false; + } + return true; + }, [logs.length]); + + const onExportTxt = useCallback(() => { + if (!guardEmpty()) return; + const text = logs.map(formatLogLine).join('\n'); + downloadFile( + buildFilename(filenamePrefix, 'txt'), + text, + 'text/plain;charset=utf-8' + ); + onExported?.('txt', logs.length); + }, [logs, filenamePrefix, onExported, guardEmpty]); + + const onExportJson = useCallback(() => { + if (!guardEmpty()) return; + // NDJSON: one JSON object per line. Use plain JSON.stringify (no indent) + // so each line is a single compact record, easy to stream / grep. + const ndjson = logs.map((l) => JSON.stringify(l)).join('\n'); + downloadFile( + buildFilename(filenamePrefix, 'json'), + ndjson, + 'application/x-ndjson;charset=utf-8' + ); + onExported?.('json', logs.length); + }, [logs, filenamePrefix, onExported, guardEmpty]); + + return ( + + + + + ); +}; + +export default LogExporter; diff --git a/src/web/frontend/src/components/Debug/LogFilterBar.tsx b/src/web/frontend/src/components/Debug/LogFilterBar.tsx new file mode 100644 index 0000000..d25413a --- /dev/null +++ b/src/web/frontend/src/components/Debug/LogFilterBar.tsx @@ -0,0 +1,89 @@ +/** + * LogFilterBar - client-side filter controls for the debug log viewer. + * + * Three controls, all controlled by the parent via the `value` / `onChange` + * contract so the filtering logic stays in the page that owns the data: + * + * 1. Module substring match (`Input`) + * 2. Level multi-select (`Select mode="multiple"`) — INFO / WARN / ERROR / DEBUG + * 3. Time range (`DatePicker.RangePicker`) — from / to + * + * Convention: an empty `levels` array means "match all levels" (no filter). + * The parent is responsible for translating this state into a `filter()` + * predicate over the raw log list. + */ + +import React from 'react'; +import { Input, Select, DatePicker, Space } from 'antd'; +import type { Dayjs } from 'dayjs'; + +export const ALL_LOG_LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG'] as const; +export type LogLevelOption = (typeof ALL_LOG_LEVELS)[number]; + +export interface LogFilterValue { + levels: LogLevelOption[]; + module: string; + range: [Dayjs | null, Dayjs | null] | null; +} + +export interface LogFilterBarProps { + value: LogFilterValue; + onChange: (next: LogFilterValue) => void; + /** + * Optional list of known module names. When provided, the Input + * is augmented with a native for browser-side autocomplete. + */ + moduleOptions?: string[]; +} + +const LogFilterBar: React.FC = ({ value, onChange, moduleOptions }) => { + // Stable, collision-resistant id for the native datalist. + const datalistId = React.useMemo( + () => `debug-modules-${Math.random().toString(36).slice(2, 8)}`, + [] + ); + + return ( + + onChange({ ...value, module: e.target.value })} + allowClear + style={{ width: 220 }} + list={moduleOptions && moduleOptions.length > 0 ? datalistId : undefined} + /> + {moduleOptions && moduleOptions.length > 0 && ( + + {moduleOptions.map((m) => ( + + )} + + mode="multiple" + allowClear + maxTagCount="responsive" + placeholder="选择级别 (空 = 全部)" + value={value.levels} + onChange={(levels) => + // Defensive cast: antd's SelectValue widens to string|number|LabeledValue, + // but the parent only ever feeds LogLevelOption values. + onChange({ ...value, levels: levels as LogLevelOption[] }) + } + style={{ minWidth: 220 }} + options={ALL_LOG_LEVELS.map((l) => ({ value: l, label: l }))} + /> + + onChange({ ...value, range: r as [Dayjs | null, Dayjs | null] | null }) + } + allowClear + /> + + ); +}; + +export default LogFilterBar; diff --git a/src/web/frontend/src/components/Debug/types.ts b/src/web/frontend/src/components/Debug/types.ts new file mode 100644 index 0000000..159f487 --- /dev/null +++ b/src/web/frontend/src/components/Debug/types.ts @@ -0,0 +1,16 @@ +/** + * Shared types for the Debug page and its sub-components. + * + * Kept in a leaf file (no React imports) so both the page and the + * extracted components can import it without risking circular deps. + */ + +export type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG' | string; + +export interface LogEntry { + id: string; + timestamp: string; + level: LogLevel; + module: string; + message: string; +} diff --git a/src/web/frontend/src/components/EmotionPanel.tsx b/src/web/frontend/src/components/EmotionPanel.tsx new file mode 100644 index 0000000..39c7587 --- /dev/null +++ b/src/web/frontend/src/components/EmotionPanel.tsx @@ -0,0 +1,431 @@ +/** + * EmotionPanel - Plutchik 8 维情感雷达 + * + * 功能: + * - 使用 echarts-for-react 渲染 8 维雷达图(joy/trust/fear/surprise/sadness/disgust/anger/anticipation) + * - 显示当前实时值(实色)+ 历史最高值(淡色背景) + * - 配色:积极情感(joy/trust/anticipation)暖色,消极情感(fear/sadness/disgust/anger)冷色,surprise 中性色 + * - 数据通过 props 传入;useEmotionWebSocket hook 订阅 /ws/event 过滤 type="emotion_update" + * - 数据变化时 ECharts 自动平滑过渡(setOption 增量更新) + * - 数据为空时显示骨架屏 + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import ReactECharts from 'echarts-for-react'; +import { Skeleton, Empty, Card, Tag } from 'antd'; +import { useWebSocket, WebSocketStatus } from '../hooks/useWebSocket'; + +// ============= 类型定义 ============= + +/** Plutchik 8 维情感数据 */ +export interface EmotionData { + joy: number; + trust: number; + fear: number; + surprise: number; + sadness: number; + disgust: number; + anger: number; + anticipation: number; +} + +/** EmotionPanel 组件 Props */ +export interface EmotionPanelProps { + /** 实时情感数据(每维 0~maxValue) */ + data: EmotionData; + /** 雷达最大值,默认 10 */ + maxValue?: number; + /** 卡片标题 */ + title?: string; + /** 历史最高值(淡色背景叠加),可选 */ + historicalMax?: EmotionData; + /** 高度,默认 360 */ + height?: number; + /** 后端 event WS 地址(可选,默认 ws:///ws/event) */ + wsUrl?: string; +} + +/** /ws/event 消息类型 */ +export interface EmotionUpdateEvent { + type: 'emotion_update'; + /** 当前情感数据 */ + data: EmotionData; + /** 可选历史最高 */ + historical_max?: EmotionData; + /** 可选时间戳 */ + timestamp?: string; +} + +// ============= 常量 ============= + +/** 8 维情感(顺时针排列,按 Plutchik 顺序) */ +export const EMOTION_KEYS = [ + 'joy', + 'trust', + 'fear', + 'surprise', + 'sadness', + 'disgust', + 'anger', + 'anticipation', +] as const; + +/** 中文标签 */ +export const EMOTION_LABELS_CN: Record = { + joy: '喜悦', + trust: '信任', + fear: '恐惧', + surprise: '惊讶', + sadness: '悲伤', + disgust: '厌恶', + anger: '愤怒', + anticipation: '期待', +}; + +/** + * 配色策略: + * - 积极情感(joy / trust / anticipation)→ 暖色(橙/金/桃) + * - 消极情感(fear / sadness / disgust / anger)→ 冷色(蓝/灰/青/紫) + * - surprise → 中性色 + */ +const POSITIVE_COLOR = '#fa8c16'; // 暖橙 +const NEUTRAL_COLOR = '#8c8c8c'; // 中性灰 +const NEGATIVE_COLOR = '#1677ff'; // 冷蓝(antd 主色) + +function colorForEmotion(key: keyof EmotionData): string { + switch (key) { + case 'joy': + case 'trust': + case 'anticipation': + return POSITIVE_COLOR; + case 'surprise': + return NEUTRAL_COLOR; + case 'fear': + case 'sadness': + case 'disgust': + case 'anger': + return NEGATIVE_COLOR; + default: + return NEUTRAL_COLOR; + } +} + +const DEFAULT_WS_URL = (() => { + if (typeof window === 'undefined') return 'ws://localhost:8000/ws/event'; + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.hostname}:8000/ws/event`; +})(); + +const DEFAULT_MAX = 10; +const DEFAULT_HEIGHT = 360; + +// ============= 工具函数 ============= + +function clamp(value: number, min: number, max: number): number { + if (Number.isNaN(value)) return min; + return Math.max(min, Math.min(max, value)); +} + +function safeNumber(value: unknown, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function normalizeData(data: Partial | null | undefined): EmotionData { + return { + joy: safeNumber(data?.joy), + trust: safeNumber(data?.trust), + fear: safeNumber(data?.fear), + surprise: safeNumber(data?.surprise), + sadness: safeNumber(data?.sadness), + disgust: safeNumber(data?.disgust), + anger: safeNumber(data?.anger), + anticipation: safeNumber(data?.anticipation), + }; +} + +function isEmotionDataEmpty(data: EmotionData): boolean { + return EMOTION_KEYS.every((k) => data[k] === 0); +} + +// ============= Hook: useEmotionWebSocket ============= + +export interface UseEmotionWebSocketResult { + /** 最新一次 emotion_update 携带的实时数据 */ + data: EmotionData | null; + /** 历史最高(来自后端) */ + historicalMax: EmotionData | null; + /** WebSocket 连接状态 */ + status: WebSocketStatus; + /** 收到的全部 emotion_update 事件(按时间顺序) */ + events: EmotionUpdateEvent[]; + /** 手动重连 */ + reconnect: () => void; +} + +/** + * useEmotionWebSocket - 订阅 /ws/event,过滤 type="emotion_update" 消息。 + * + * 任何 WebSocket 消息只要 type === "emotion_update" 即被解析为 EmotionUpdateEvent; + * 不符合的消息会被忽略,组件不会因此重渲染。 + */ +export function useEmotionWebSocket( + url: string = DEFAULT_WS_URL +): UseEmotionWebSocketResult { + const [data, setData] = useState(null); + const [historicalMax, setHistoricalMax] = useState(null); + const [events, setEvents] = useState([]); + + const handleMessage = useCallback((parsed: unknown) => { + if (!parsed || typeof parsed !== 'object') return; + const msg = parsed as Record; + if (msg.type !== 'emotion_update') return; + if (!msg.data || typeof msg.data !== 'object') return; + + const next: EmotionUpdateEvent = { + type: 'emotion_update', + data: normalizeData(msg.data as Partial), + historical_max: msg.historical_max + ? normalizeData(msg.historical_max as Partial) + : undefined, + timestamp: typeof msg.timestamp === 'string' ? msg.timestamp : undefined, + }; + + setData(next.data); + if (next.historical_max) { + setHistoricalMax(next.historical_max); + } + setEvents((prev) => [...prev, next]); + }, []); + + const { status, reconnectNow } = useWebSocket(url, { + onMessage: handleMessage, + }); + + return { data, historicalMax, status, events, reconnect: reconnectNow }; +} + +// ============= 主组件 ============= + +const EmotionPanel: React.FC = ({ + data, + maxValue = DEFAULT_MAX, + title = '情感雷达(Plutchik 8 维)', + historicalMax, + height = DEFAULT_HEIGHT, + wsUrl, +}) => { + // 可选:组件内部订阅 WS(仅在显式传入 wsUrl 时启用) + // 传入空字符串时 useWebSocket 会直接进入 error 状态,不会发起连接 + const { data: wsData, historicalMax: wsHistoricalMax, status } = useEmotionWebSocket( + wsUrl ?? '' + ); + + // 优先使用 props 传入的数据;否则回退到 WS 推送的数据 + const liveData = useMemo(() => { + if (data && !isEmotionDataEmpty(data)) return normalizeData(data); + if (wsData) return wsData; + return normalizeData(data); + }, [data, wsData]); + + const maxData = useMemo(() => { + if (historicalMax) return normalizeData(historicalMax); + if (wsHistoricalMax) return wsHistoricalMax; + return null; + }, [historicalMax, wsHistoricalMax]); + + // ============= ECharts 配置 ============= + + const indicator = useMemo( + () => + EMOTION_KEYS.map((key) => ({ + name: EMOTION_LABELS_CN[key], + max: maxValue, + })), + [maxValue] + ); + + const option = useMemo(() => { + const currentValues = EMOTION_KEYS.map((k) => clamp(liveData[k], 0, maxValue)); + const maxValues = maxData + ? EMOTION_KEYS.map((k) => clamp(maxData[k], 0, maxValue)) + : currentValues; + + return { + // 全局动画(平滑过渡) + animationDuration: 600, + animationEasing: 'cubicOut' as const, + animationDurationUpdate: 600, + animationEasingUpdate: 'cubicOut' as const, + + color: [...EMOTION_KEYS.map((k) => colorForEmotion(k))], + + title: { + text: title, + left: 'center', + top: 4, + textStyle: { + fontSize: 14, + fontWeight: 500, + color: '#262626', + }, + }, + + tooltip: { + trigger: 'item', + confine: true, + formatter: (params: any) => { + const idx = params?.dataIndex ?? 0; + const key = EMOTION_KEYS[idx]; + const val = currentValues[idx]; + const max = maxValues[idx]; + return `
+
${EMOTION_LABELS_CN[key]}(${key})
+
当前值:${val.toFixed(2)}
+
历史最高:${max.toFixed(2)}
+
`; + }, + }, + + legend: { + show: true, + bottom: 0, + data: ['当前值', '历史最高'], + textStyle: { color: '#595959' }, + }, + + radar: { + indicator, + shape: 'polygon' as const, + radius: '65%', + center: ['50%', '54%'], + splitNumber: 4, + axisName: { + color: '#262626', + fontSize: 12, + }, + splitLine: { + lineStyle: { color: '#d9d9d9' }, + }, + splitArea: { + areaStyle: { + color: ['rgba(250, 250, 250, 0.4)', 'rgba(250, 250, 250, 0.7)'], + }, + }, + axisLine: { + lineStyle: { color: '#bfbfbf' }, + }, + }, + + series: [ + // 历史最高(淡色背景) + ...(maxData + ? [ + { + name: '历史最高', + type: 'radar' as const, + symbol: 'circle' as const, + symbolSize: 4, + lineStyle: { + color: '#bfbfbf', + width: 1, + type: 'dashed' as const, + }, + areaStyle: { + color: 'rgba(191, 191, 191, 0.15)', + }, + itemStyle: { color: '#bfbfbf' }, + data: [ + { + value: maxValues, + name: '历史最高', + }, + ], + }, + ] + : []), + // 当前值(实色) + { + name: '当前值', + type: 'radar' as const, + symbol: 'circle' as const, + symbolSize: 6, + lineStyle: { + color: '#1677ff', + width: 2, + }, + areaStyle: { + color: 'rgba(22, 119, 255, 0.25)', + }, + itemStyle: { color: '#1677ff' }, + data: [ + { + value: currentValues, + name: '当前值', + }, + ], + }, + ], + }; + }, [indicator, liveData, maxData, maxValue, title]); + + // 强制 useEffect + setOption 平滑过渡 + const echartsRef = useRef(null); + useEffect(() => { + const inst = echartsRef.current?.getEchartsInstance?.(); + if (!inst) return; + inst.setOption(option, { notMerge: false, lazyUpdate: true }); + }, [option]); + + const noData = + isEmotionDataEmpty(liveData) && + (maxData === null || isEmotionDataEmpty(maxData)); + + const statusTag = (() => { + switch (status) { + case 'open': + return WS 已连接; + case 'connecting': + return 连接中; + case 'closed': + return 已断开; + case 'error': + return 错误; + default: + return null; + } + })(); + + return ( + + {noData ? ( + + ) : ( + { + echartsRef.current = e; + }} + option={option} + notMerge={false} + lazyUpdate + style={{ height, width: '100%' }} + opts={{ renderer: 'canvas' }} + /> + )} + {noData && ( + + )} + + ); +}; + +export default EmotionPanel; diff --git a/src/web/frontend/src/components/EventTable.tsx b/src/web/frontend/src/components/EventTable.tsx new file mode 100644 index 0000000..4a9662c --- /dev/null +++ b/src/web/frontend/src/components/EventTable.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { Table, Tag, Button, Space, Typography, Descriptions, Empty } from 'antd'; +import type { ColumnsType, ExpandableConfig } from 'antd/es/table/interface'; +import { CheckOutlined, InboxOutlined, EyeOutlined } from '@ant-design/icons'; +import type { Event, EventStatus, EventType } from '../types/event'; + +const { Text, Paragraph } = Typography; + +export interface EventTableProps { + events: Event[]; + onMarkRead?: (uuid: string) => void; + onArchive?: (uuid: string) => void; + loading?: boolean; +} + +const typeColorMap: Record = { + notification: 'blue', + task: 'geekblue', + system: 'purple', +}; + +const typeLabelMap: Record = { + notification: 'notification', + task: 'task', + system: 'system', +}; + +const statusColorMap: Record = { + pending: 'gold', + completed: 'green', + failed: 'red', +}; + +const statusLabelMap: Record = { + pending: 'pending', + completed: 'completed', + failed: 'failed', +}; + +const formatTimestamp = (iso: string): string => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +}; + +const EventTable: React.FC = ({ events, onMarkRead, onArchive, loading = false }) => { + const columns: ColumnsType = [ + { + title: '时间', + dataIndex: 'timestamp', + key: 'timestamp', + width: 180, + sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + defaultSortOrder: 'descend', + render: (value: string) => {formatTimestamp(value)}, + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 130, + filters: (Object.keys(typeLabelMap) as EventType[]).map((k) => ({ text: typeLabelMap[k], value: k })), + onFilter: (value, record) => record.type === value, + render: (value: EventType) => {typeLabelMap[value]}, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 110, + filters: (Object.keys(statusLabelMap) as EventStatus[]).map((k) => ({ text: statusLabelMap[k], value: k })), + onFilter: (value, record) => record.status === value, + render: (value: EventStatus) => {statusLabelMap[value]}, + }, + { + title: '来源', + dataIndex: 'source', + key: 'source', + width: 200, + ellipsis: true, + sorter: (a, b) => a.source.localeCompare(b.source), + }, + { + title: '消息', + dataIndex: 'message', + key: 'message', + ellipsis: true, + sorter: (a, b) => a.message.localeCompare(b.message), + }, + { + title: '操作', + key: 'actions', + width: 200, + fixed: 'right', + render: (_value, record) => ( + + + + + ), + }, + ]; + + const expandable: ExpandableConfig = { + expandedRowRender: (record) => ( +
+ Payload 详情}> + + {record.uuid} + + + {record.timestamp} + + + +
{JSON.stringify(record.payload, null, 2)}
+
+
+
+
+ ), + rowExpandable: () => true, + }; + + return ( + + rowKey="uuid" + columns={columns} + dataSource={events} + loading={loading} + size="middle" + bordered + pagination={{ + pageSize: 20, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + pageSizeOptions: ['10', '20', '50', '100'], + }} + scroll={{ x: 1000 }} + expandable={expandable} + locale={{ + emptyText: , + }} + /> + ); +}; + +export default EventTable; diff --git a/src/web/frontend/src/components/KnowledgeGraph.tsx b/src/web/frontend/src/components/KnowledgeGraph.tsx new file mode 100644 index 0000000..be70745 --- /dev/null +++ b/src/web/frontend/src/components/KnowledgeGraph.tsx @@ -0,0 +1,171 @@ +import { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import type { Entity, EntityCategory, Relation } from '../types/knowledge'; + +/** + * KnowledgeGraph —— 用 ECharts 渲染的实体关系图。 + * + * 设计要点: + * - 节点 = Entity,边 = Relation + * - 节点颜色按 category 映射 (固定调色板) + * - 边宽度按 mention_count 线性映射 + * - 节点点击通过 onNodeClick 回调把 uuid 抛给父组件 + * - 使用力导向布局 (force / repulsion) + */ + +export interface KnowledgeGraphProps { + entities: Entity[]; + relations: Relation[]; + /** 当前高亮的实体 uuid(用于给节点加边框) */ + selectedUuid?: string; + onNodeClick?: (uuid: string) => void; + /** 容器高度,默认 480 */ + height?: number; +} + +const CATEGORY_COLORS: Record = { + person: '#1677ff', + project: '#722ed1', + concept: '#13c2c2', + location: '#52c41a', + organization: '#fa8c16', + event: '#eb2f96', +}; + +const CATEGORY_LABELS: Record = { + person: '人物', + project: '项目', + concept: '概念', + location: '地点', + organization: '组织', + event: '事件', +}; + +/** + * 把 mention_count 映射为 ECharts edge 线宽 (1 ~ 8 px)。 + * mention_count 越大线越粗,直观体现关联强度。 + */ +function widthForMention(count: number, maxCount: number): number { + if (maxCount <= 0) return 1; + const ratio = count / maxCount; + return Math.max(1, Math.min(8, 1 + ratio * 7)); +} + +export default function KnowledgeGraph({ + entities, + relations, + selectedUuid, + onNodeClick, + height = 480, +}: KnowledgeGraphProps): JSX.Element { + const option = useMemo(() => { + const maxMention = relations.reduce( + (m, r) => Math.max(m, r.mention_count), + 0, + ); + + const nodes = entities.map((e) => ({ + id: e.uuid, + name: e.name, + symbolSize: Math.max(20, Math.min(60, 20 + e.mention_count * 0.4)), + category: e.category, + // 用 itemStyle 同时表达"分类色"和"选中高亮" + itemStyle: { + color: CATEGORY_COLORS[e.category], + borderColor: selectedUuid === e.uuid ? '#000' : '#fff', + borderWidth: selectedUuid === e.uuid ? 3 : 1.5, + shadowBlur: 6, + shadowColor: 'rgba(0,0,0,0.15)', + }, + label: { show: true, fontSize: 12 }, + value: e.confidence, + })); + + const links = relations.map((r) => ({ + source: r.source, + target: r.target, + name: r.relation, + lineStyle: { + width: widthForMention(r.mention_count, maxMention), + color: '#bfbfbf', + curveness: 0.1, + }, + value: r.mention_count, + })); + + return { + tooltip: { + trigger: 'item', + formatter: (params: { dataType?: string; data?: Record }) => { + if (params.dataType === 'node' && params.data) { + const d = params.data as { name: string; category: EntityCategory; value: number }; + return `${d.name}
类型: ${CATEGORY_LABELS[d.category]}
置信度: ${( + d.value * 100 + ).toFixed(0)}%`; + } + if (params.dataType === 'edge' && params.data) { + const d = params.data as { name: string; value: number }; + return `关系: ${d.name}
提及次数: ${d.value}`; + } + return ''; + }, + }, + legend: [ + { + data: Object.keys(CATEGORY_LABELS).map((k) => ({ + name: CATEGORY_LABELS[k as EntityCategory], + icon: 'circle', + })), + textStyle: { fontSize: 11 }, + top: 0, + }, + ], + series: [ + { + type: 'graph', + layout: 'force', + roam: true, + draggable: true, + animation: true, + data: nodes, + links, + categories: Object.keys(CATEGORY_COLORS).map((k) => ({ + name: CATEGORY_LABELS[k as EntityCategory], + itemStyle: { color: CATEGORY_COLORS[k as EntityCategory] }, + })), + force: { + repulsion: 260, + edgeLength: [60, 140], + gravity: 0.05, + friction: 0.2, + }, + emphasis: { + focus: 'adjacency', + lineStyle: { width: 4 }, + }, + }, + ], + } as Record; + }, [entities, relations, selectedUuid]); + + const onEvents = useMemo( + () => ({ + click: (event: { dataType?: string; data?: { id?: string } }) => { + if (event.dataType === 'node' && event.data?.id && onNodeClick) { + onNodeClick(event.data.id); + } + }, + }), + [onNodeClick], + ); + + return ( + + ); +} diff --git a/src/web/frontend/src/components/Layout.tsx b/src/web/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..c56a36d --- /dev/null +++ b/src/web/frontend/src/components/Layout.tsx @@ -0,0 +1,126 @@ +/** + * AppLayout - 全局壳布局(Sider + Header + Content) + * + * Stage B.1 移动端降级: + * - 监听 window.innerWidth,当 < 768px 时不渲染主布局 + * - 改用 antd Result 组件显示"请使用桌面端访问"提示 + * - 提供"刷新重试"按钮,方便用户切回桌面后手动验证 + * - SSR 安全:用 typeof window 兜底,避免在非浏览器环境崩溃 + * + * 注意:本降级是软提示,实际渲染层(echarts 等)对窄屏并不友好,因此 + * 显式拦截比"勉强渲染"更稳妥。 + */ + +import React, { useEffect, useState } from 'react'; +import { Layout, Menu, Badge, Result, Button } from 'antd'; +import { + MessageOutlined, + BugOutlined, + BookOutlined, + CalendarOutlined, + AlertOutlined, + StarOutlined, + DatabaseOutlined, + ThunderboltOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; + +const { Sider, Header, Content } = Layout; + +/** 移动端判断阈值(像素) */ +const MOBILE_BREAKPOINT = 768; + +type MenuItem = { + key: string; + label: string; + icon: React.ReactNode; +}; + +const menuItems: MenuItem[] = [ + { key: '/chat', label: 'Chat', icon: }, + { key: '/debug', label: 'Debug', icon: }, + { key: '/knowledge', label: 'Knowledge', icon: }, + { key: '/schedule', label: 'Schedule', icon: }, + { key: '/event', label: 'Event', icon: }, + { key: '/nps', label: 'NPS', icon: }, + { key: '/database', label: 'Database', icon: }, + { key: '/creative', label: 'Creative', icon: }, + { key: '/settings', label: '系统设置', icon: }, +]; + +/** SSR 安全的初始窗口宽度(默认按桌面端,避免水合不一致) */ +function getInitialWidth(): number { + if (typeof window === 'undefined') return 1280; + return window.innerWidth; +} + +export default function AppLayout() { + const navigate = useNavigate(); + const location = useLocation(); + + // 移动端检测:< 768px 视为移动端 + const [isMobile, setIsMobile] = useState( + () => getInitialWidth() < MOBILE_BREAKPOINT + ); + + useEffect(() => { + if (typeof window === 'undefined') return; + const handler = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + }, []); + + const selectedKey = + menuItems.find((item) => location.pathname.startsWith(item.key))?.key ?? '/chat'; + + // 移动端降级:不渲染主布局,改用 Result 提示用户 + if (isMobile) { + return ( +
+ window.location.reload()}> + 刷新重试 + + } + /> +
+ ); + } + + return ( + + +
+ Neo Agent +
+ ({ + key: item.key, + icon: item.icon, + label: item.label, + }))} + onClick={({ key }) => navigate(key)} + /> + + +
+
Neo Agent 控制台
+
+ +
+
+ + + +
+ + ); +} diff --git a/src/web/frontend/src/components/MessageList.tsx b/src/web/frontend/src/components/MessageList.tsx new file mode 100644 index 0000000..ab5b811 --- /dev/null +++ b/src/web/frontend/src/components/MessageList.tsx @@ -0,0 +1,147 @@ +/** + * MessageList - 虚拟滚动消息列表 + * + * Stage B.1 升级: + * - 使用 react-window FixedSizeList 渲染,可承载 1000+ 条消息而不卡顿 + * - 保留所有原 props 接口(messages / renderItem / renderMarkdown / autoScroll / + * className / emptyText),确保调用方零侵入 + * - 通过 ResizeObserver 自动测量容器高度 + * - 自动滚动到底部:autoScroll=true 时,新消息或最后一条消息变化时,RAF 内 + * 调用 listRef.scrollToItem(messages.length - 1) + * - 消息行高 80px(由 ChatBubble 内容决定,溢出由 ChatBubble 内部处理) + */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Empty } from 'antd'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; +import type { ChatMessage } from '../hooks/useChatStream'; +import { ChatBubble, ChatBubbleProps } from './ChatBubble'; + +export interface MessageListProps { + messages: ChatMessage[]; + /** Optional custom renderer; defaults to ChatBubble */ + renderItem?: (message: ChatMessage) => React.ReactNode; + /** Optional markdown render fn passed through to ChatBubble */ + renderMarkdown?: ChatBubbleProps['renderMarkdown']; + /** Auto-scroll to bottom on new messages; default true */ + autoScroll?: boolean; + /** Optional className for the scroll container */ + className?: string; + /** Placeholder shown when messages is empty */ + emptyText?: string; + /** 单条消息行高(px),默认 80。ChatBubble 高度可能大于该值时由 ChatBubble 内部滚动 */ + itemSize?: number; +} + +const DEFAULT_ITEM_SIZE = 80; + +const MessageListComponent: React.FC = ({ + messages, + renderItem, + renderMarkdown, + autoScroll = true, + className, + emptyText = '还没有对话,开始聊点什么吧…', + itemSize = DEFAULT_ITEM_SIZE, +}) => { + const containerRef = useRef(null); + const listRef = useRef(null); + const prevCountRef = useRef(messages.length); + const prevLastIdRef = useRef(null); + + // 用 ResizeObserver 动态获取容器高度,作为 FixedSizeList 的 height + const [containerHeight, setContainerHeight] = useState(400); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + // 初始化一次 + setContainerHeight(el.clientHeight || 400); + if (typeof ResizeObserver === 'undefined') { + // 兜底:监听 window resize + const handler = () => setContainerHeight(el.clientHeight || 400); + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + } + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const h = entry.contentRect.height; + if (h > 0) setContainerHeight(h); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Determine the id of the last message (for "follow streaming" behaviour). + const lastId = useMemo(() => { + if (messages.length === 0) return null; + return messages[messages.length - 1].id; + }, [messages]); + + // 自动滚动到底部:仅在消息数量变化或最后一条消息 id 变化时触发 + useEffect(() => { + if (!autoScroll) return; + if (messages.length === 0) return; + const isNewMessage = messages.length !== prevCountRef.current; + const isLastChanged = lastId !== prevLastIdRef.current; + if (isNewMessage || isLastChanged) { + // 下一帧让 DOM 先行渲染,再滚到末尾 + requestAnimationFrame(() => { + try { + listRef.current?.scrollToItem(messages.length - 1, 'end'); + } catch { + /* 忽略 scrollToItem 边界异常 */ + } + }); + } + prevCountRef.current = messages.length; + prevLastIdRef.current = lastId; + }, [messages.length, lastId, autoScroll, messages]); + + if (messages.length === 0) { + return ( +
+ +
+ ); + } + + // react-window 行渲染器:每行用 ChatBubble 渲染当前消息 + const Row: React.FC = ({ index, style }) => { + const msg = messages[index]; + return ( +
+ {renderItem ? ( + renderItem(msg) + ) : ( + + )} +
+ ); + }; + + return ( +
+ + {Row} + +
+ ); +}; + +export const MessageList = React.memo(MessageListComponent); +export default MessageList; diff --git a/src/web/frontend/src/components/NPSConfigDrawer.tsx b/src/web/frontend/src/components/NPSConfigDrawer.tsx new file mode 100644 index 0000000..a14d5dd --- /dev/null +++ b/src/web/frontend/src/components/NPSConfigDrawer.tsx @@ -0,0 +1,242 @@ +import React, { useEffect } from 'react'; +import { + Drawer, + Form, + Input, + Select, + Switch, + Button, + Space, + Divider, + Typography, + Empty, + Card, +} from 'antd'; +import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons'; +import type { NPSType, NPSParam, NPSConfigFormValues } from '../types/nps'; + +const { TextArea } = Input; +const { Text } = Typography; + +export interface NPSConfigDrawerProps { + open: boolean; + onClose: () => void; + onSubmit: (values: NPSConfigFormValues) => void; + initialValues?: Partial; + title?: string; + submitText?: string; +} + +const defaultValues: NPSConfigFormValues = { + name: '', + description: '', + nps_type: 'function', + params: [], + enabled: true, +}; + +const ParamKeyValueEditor: React.FC = () => { + const form = Form.useFormInstance(); + const params: NPSParam[] = (form.getFieldValue('params') as NPSParam[] | undefined) ?? []; + + const updateParams = (next: NPSParam[]) => { + form.setFieldValue('params', next); + }; + + const addRow = () => { + updateParams([...params, { key: '', value: '' }]); + }; + + const removeRow = (idx: number) => { + updateParams(params.filter((_, i) => i !== idx)); + }; + + const updateRow = (idx: number, patch: Partial) => { + updateParams(params.map((p, i) => (i === idx ? { ...p, ...patch } : p))); + }; + + return ( +
+ {params.length === 0 && ( + 暂无参数,点击下方按钮添加} + style={{ margin: '12px 0' }} + /> + )} + + {params.map((p, idx) => ( + + updateRow(idx, { key: e.target.value })} + /> + updateRow(idx, { value: e.target.value })} + /> + +
+ ); +}; + +const NPSConfigDrawer: React.FC = ({ + open, + onClose, + onSubmit, + initialValues, + title = '注册新 NPS', + submitText = '提交', +}) => { + const [form] = Form.useForm(); + + useEffect(() => { + if (open) { + const merged: NPSConfigFormValues = { + ...defaultValues, + ...initialValues, + params: (initialValues?.params ?? []) as NPSParam[], + }; + form.setFieldsValue(merged); + } else { + form.resetFields(); + } + }, [open, initialValues, form]); + + const handleFinish = (values: NPSConfigFormValues) => { + onSubmit(values); + }; + + const handleReset = () => { + const merged: NPSConfigFormValues = { + ...defaultValues, + ...initialValues, + params: (initialValues?.params ?? []) as NPSParam[], + }; + form.setFieldsValue(merged); + }; + + return ( + + + + + } + footer={ +
+ + + + +
+ } + > + + form={form} + layout="vertical" + onFinish={handleFinish} + initialValues={defaultValues} + requiredMark="optional" + > + + + + + +