Merge branch 'feat/calendar-ui-fixes' — 日历能力缺失修复 + UI布局优化 + 会话404处理 + ce-code-review 修复
This commit is contained in:
commit
8ae8ed4e9b
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"path": "**/*.py",
|
||||||
|
"rule": "代码评审 10 维度(项目 AGENTS.md + ponytail 规则):1) 正确性 — 边界条件、异常路径、空值处理;2) 安全性 — API Key 比较必须 hmac.compare_digest(恒定时间),JWT HS256 access 15min/refresh 7d,RBAC 三级权限位,专家名必须 _EXPERT_NAME_RE=re.compile(r\"^[a-zA-Z0-9_-]{1,64}$\") 校验,bcrypt rounds=12,禁止 SQL 注入/路径穿越;3) 类型安全 — 禁止 any,必须类型注解,所有数据模型用 pydantic>=2.0 且 model_config=ConfigDict(...) 而非 class Config;4) 并发异步 — HandoffTransport 队列有界 maxsize=1024,关闭用 sentinel 模式,async def 含 yield 禁止在首个 yield 前用 return(必须 return; yield 模式或重构),禁止阻塞调用在 async 函数中;5) 性能 — N+1 查询、未加索引、未用缓存、内存泄漏、未关闭资源;6) 可维护性 — 命名、重复代码、圈复杂度、函数过长;7) 错误处理 — 信任边界必须校验输入,防止数据丢失,禁止裸 except;8) 依赖配置 — 不得擅自改 pyproject.toml 版本,配置优先级 CLI>yaml>env>.env>默认;9) 测试 — 非平凡逻辑必须留下一个可运行检查(assert demo 或小测试文件,无框架无 fixtures);10) Ponytail 原则 — 无未请求的抽象、无未请求的新依赖、删除优先于新增、复杂处用 ponytail: 注释标记已知上限与升级路径。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/*.ts",
|
||||||
|
"rule": "前端 TS 评审维度(AGENTS.md 前端规范):1) 类型安全 — 禁止 any,必须显式类型注解,interface/type 优先;2) Vue3 Composition API — 禁止 Options API,必须 <script setup lang=\"ts\">;3) Pinia store — 每个领域一个 store,state/getters/actions 规范,禁止全局可变状态;4) 禁止 require() 调用,必须 ES import;5) Ant Design Vue 4 组件规范 — 按需引入,a-* 组件名;6) 异步处理 — async/await,禁止 .then 链过深,错误边界处理;7) 响应式 — ref/reactive 正确使用,禁止解构 reactive 丢失响应性,computed 优先于 methods;8) 安全 — XSS 防护(v-html 慎用),用户输入转义,JWT 存储位置安全;9) 性能 — 懒加载路由(defineAsyncComponent),避免大列表未虚拟化,watch 深度慎用;10) 可维护性 — 命名规范(camelCase 变量、PascalCase 组件),文件组织按领域,无重复代码。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/*.vue",
|
||||||
|
"rule": "Vue3 SFC 评审维度:1) <script setup lang=\"ts\"> 必须使用,禁止 Options API;2) 模板 — 单根节点(Vue3 允许多根但建议单根),v-for 必须 :key 唯一且非 index,v-if 与 v-for 不同级;3) Props — defineProps 必带类型与 required 标注,defineEmits 必带类型;4) 响应式 — ref/reactive/computed 正确区分,watch 与 watchEffect 按需使用,避免深监听大对象;5) 生命周期 — onMounted 异步需 try/catch,onUnmounted 必须清理(定时器、事件监听、WebSocket);6) Ant Design Vue — 组件按需引入,message/notification/modal API 调用规范;7) 样式 — scoped 必加,避免全局污染,CSS 变量优先于硬编码颜色;8) 安全 — v-html 必须确认来源可信,用户输入绝不直接 v-html;9) 可访问性 — 表单 label/for,按钮 aria-label,键盘导航;10) 性能 — 大列表虚拟滚动,图片懒加载,组件懒加载(defineAsyncComponent)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/stores/*.ts",
|
||||||
|
"rule": "Pinia store 专项评审:1) defineStore id 唯一且语义化;2) state 必须是函数返回对象(setup store)或对象(option store),不可混用;3) getters 必须有返回类型注解;4) actions 异步必须 try/catch 并更新错误状态;5) 禁止在 store 中直接操作 DOM;6) 跨 store 调用必须通过 useXxxStore(),禁止循环依赖;7) 持久化需明确策略(localStorage/sessionStorage/无);8) WebSocket 连接必须在 store 中管理生命周期(onUnmounted 时关闭)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/auth/**/*.py",
|
||||||
|
"rule": "认证安全专项(最高优先级):1) API Key 比较必须 hmac.compare_digest,禁止 == 比较密钥/token;2) JWT — HS256,access 15min/refresh 7d,过期校验,刷新令牌轮换;3) 密码 — bcrypt rounds=12,禁止明文存储;4) RBAC — 三级(member/operator/admin)+ 权限位,越权检查;5) 终端安全 — 6 层白名单(blocklist→shell-ops→builtin→global→user→session→danger),危险命令需管理员审批;6) 会话 — 登出必须失效 token,并发会话控制;7) CSRF — 状态变更接口必须校验;8) 速率限制 — 登录/敏感接口必须限流。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/routes/*.py",
|
||||||
|
"rule": "FastAPI 路由专项:1) 路径必须 /api/v1 前缀;2) 依赖注入 — 认证/权限用 Depends(),禁止在函数体内校验;3) Pydantic 模型校验请求体/查询参数,禁止裸 dict;4) 响应模型 response_model 显式声明;5) 异步 — async def 优先,IO 密集用 await,CPU 密集用 run_in_threadpool;6) 错误处理 — HTTPException 明确 status_code 与 detail,禁止裸 raise Exception;7) WebSocket — 连接管理(心跳/超时/断线重连),广播需并发控制;8) 输入校验 — 路径参数/查询参数边界值,防止注入。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/calendar/**/*.py",
|
||||||
|
"rule": "日历服务专项:1) 时区处理必须显式(Asia/Shanghai 或 UTC),禁止 naive datetime;2) 重复事件 RRULE 解析正确性;3) 跨时区会议时间一致性;4) 飞书/Outlook 同步 — 冲突检测、增量同步、失败重试;5) 数据持久化 — 事务边界,回滚策略;6) 并发 — 同一日历并发写入需锁;7) 错误恢复 — 同步中断可续传。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/sqlite_conversation_store.py",
|
||||||
|
"rule": "SQLite 会话存储专项:1) SQL 参数化查询(禁止 f-string/format 拼接 SQL);2) 事务管理 — 显式 begin/commit/rollback,async with;3) 连接池 — Singleton 或 per-request,禁止每次新建;4) 并发写入 — WAL 模式或串行化;5) 数据完整性 — 外键约束、NOT NULL、唯一索引;6) 迁移 — schema 版本管理,向后兼容;7) 性能 — 索引覆盖查询,分页 LIMIT/OFFSET 或 cursor;8) 错误处理 — IntegrityError 捕获并返回明确错误。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/middleware.py",
|
||||||
|
"rule": "中间件专项:1) 执行顺序 — 认证→授权→限流→日志→业务;2) 异步 — async def,禁止阻塞;3) 上下文传递 — request.state.user 等规范字段;4) 错误 — 中间件异常必须转为 HTTP 响应,禁止吞异常;5) 性能 — 中间件链不宜过长,热路径避免重计算;6) CORS — 白名单显式,禁止 *;7) 日志 — 敏感信息脱敏(token/密码)。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"path": "**/*.md",
|
||||||
|
"rule": "文档评审 10 维度(项目 AGENTS.md + CONCEPTS.md 词汇表规范):1) 准确性 — 与代码实现一致,无过时信息(API 路径/命令/版本号必须可验证),引用的模块路径/类名/函数名必须真实存在;2) 完整性 — 覆盖关键模块、API、配置项、边界条件、错误处理、部署步骤,无关键遗漏;3) 一致性 — 术语统一(必须与 CONCEPTS.md 领域词汇表对齐:专家团队状态机 FORMING→PLANNING→EXECUTING→SYNTHESIZING→COMPLETED→DISSOLVED、ExecutionMode DIRECT_CHAT/REACT/SKILL_REACT/REWOO/REFLEXION/PLAN_EXEC/TEAM_COLLAB、RequestPreprocessor 三层路由等),命名规范(中英混排空格、代码用反引号);4) 可维护性 — docs/solutions/ 必须有 YAML frontmatter(module/tags/problem_type 可搜索),结构清晰(标题层级 ≤4 级),有目录/索引;5) 受众适配 — 区分新人 onboarding / 专家参考 / 运维部署,提供前置条件与后续步骤;6) 示例有效性 — 代码块必须带语言标签(bash/python/ts/vue),命令必须可复制运行(含完整路径与参数),示例输出与实际一致;7) 链接完整性 — 内部链接(相对路径)指向真实存在文件,外部链接有效,无 404;8) 安全合规 — 不泄露密钥/token/内部域名,敏感配置用占位符(<api-key>),不暴露内部架构细节给外部;9) 格式规范 — Markdown 语法正确,标题层级连续不跳级,列表缩进一致,表格对齐,frontmatter YAML 合法;10) 版本同步 — 引用的版本号必须与 pyproject.toml / package.json 一致,命令(agentkit/pip/npm)必须与实际 CLI 一致。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/docs/solutions/**/*.md",
|
||||||
|
"rule": "解决方案文档专项(docs/solutions/):1) YAML frontmatter 必须包含 module(模块路径)、tags(标签数组)、problem_type(bug_fix/best_practice/architecture_pattern 之一),且可被搜索工具检索;2) 结构必须包含 — 问题描述/根因分析/解决方案/验证方法/影响范围/后续改进;3) 根因分析必须给出代码定位(文件:行号);4) 解决方案必须含代码示例(修复前/修复后对比);5) 验证方法必须可复现(命令或测试);6) 引用的类名/函数名/模块路径必须与当前代码库一致;7) 中文撰写,代码注释中文;8) 标题命名规范 — <模块>-<问题简述>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/docs/plans/**/*.md",
|
||||||
|
"rule": "计划文档专项(docs/plans/):1) 文件名格式 YYYY-MM-DD-NNN-<type>-<name>-plan.md;2) 必须包含 — 背景与目标 / 现状分析 / 设计方案 / 任务拆解(KTD 编号)/ 风险与回滚 / 验收标准;3) 任务拆解必须有 KTD 编号、负责人、依赖关系、验收标准;4) 设计方案必须含架构图或流程图(Mermaid/ASCII);5) 风险评估必须含回滚策略;6) 引用的现有代码/接口必须真实存在;7) 验收标准必须可量化(性能指标/测试覆盖/功能点);8) 中文撰写。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/AGENTS.md",
|
||||||
|
"rule": "项目上下文文档专项(AGENTS.md):1) 规则部分必须可执行(每条规则对应代码可验证点);2) 技术栈版本必须与 pyproject.toml/package.json 一致;3) 命令必须可复制运行(含正确路径与参数);4) 架构描述必须与当前代码结构一致(模块映射表、路由表、Agent 层级、专家团队模式);5) WebSocket 协议消息类型必须与代码中定义一致;6) 配置优先级必须与代码实现一致;7) 约定部分(技能配置路径、专家模板、测试位置)必须真实存在;8) 边界部分必须明确禁止事项。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/CONCEPTS.md",
|
||||||
|
"rule": "领域词汇表专项(CONCEPTS.md):1) 每个术语必须包含 — 名称(中英)、定义、代码位置、相关术语;2) 术语必须与代码中实际使用一致(类名/枚举值/状态名);3) 分类清晰(实体/流程/状态概念);4) 无重复或矛盾术语;5) 中文定义,英文术语保留原文;6) 引用的代码位置必须真实存在。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/.trae/rules/*.md",
|
||||||
|
"rule": "规则文件专项(.trae/rules/):1) 规则必须明确可执行(非模糊建议);2) 必须包含正确/错误示例对比;3) 规则之间无冲突;4) 与 AGENTS.md 一致;5) 例外情况必须明确标注;6) 简洁聚焦,单一规则文件解决单一领域问题。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "**/README.md",
|
||||||
|
"rule": "README 专项:1) 必须包含 — 项目简介 / 快速开始 / 安装步骤 / 基本用法 / 配置说明 / 文档索引 / 贡献指南链接;2) 安装命令必须可复制运行;3) 项目简介必须准确反映当前功能;4) 文档索引链接必须有效;5) License 与版本信息必须准确;6) 徽章(badges)必须有效。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
title: "日历能力缺失修复 + UI 布局优化 + 会话 404 处理"
|
||||||
|
date: 2026-06-28
|
||||||
|
category: docs/solutions/logic-errors
|
||||||
|
module: tools/calendar_tool, server/app, server/routes/portal, frontend/stores/chat
|
||||||
|
problem_type: logic_error
|
||||||
|
component: service_object
|
||||||
|
symptoms:
|
||||||
|
- "LLM 回复'我没有提醒功能',不调用 calendar 工具创建事件"
|
||||||
|
- "calendar_tool 构建 reminder_rules 但未传入 service.create_event,提醒规则被静默丢弃"
|
||||||
|
- "default agent 在 CalendarTool 注册前创建,系统提示中缺少 calendar 工具描述"
|
||||||
|
- "系统提示出现重复的 '## 可用工具' 段落"
|
||||||
|
- "deleteConversation 未清理 pendingConversations,删除进行中的会话导致状态泄漏"
|
||||||
|
- "404 处理器递归调用 selectConversation 无保护,多会话过期时级联触发 N 次网络请求"
|
||||||
|
- "右侧面板收起按钮未垂直居中"
|
||||||
|
- "SystemMonitorPanel monitor 标签页缺少 flex 布局,滚动和垂直居中失效"
|
||||||
|
- "WS connected 事件在 ID 不变时未清除 is_local 标志"
|
||||||
|
- "gui_mode 变量在 lifespan 中未定义(F821)"
|
||||||
|
root_cause: logic_error
|
||||||
|
resolution_type: code_fix
|
||||||
|
severity: high
|
||||||
|
tags: [code-review, calendar, reminder, websocket, system-prompt, ui-layout, state-management]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 日历能力缺失修复 + UI 布局优化 + 会话 404 处理
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
用户报告三个问题:(1) AI 无法响应定时提醒请求("下周一提醒我准备项目启动会材料"),回复"我没有这个能力";(2) 右侧面板收起按钮未垂直居中;(3) 会话 404 错误导致 UI 异常。代码审查发现 16 个问题(1 P0、2 P1、6 P2、7 P3),核心根因是 CalendarTool 未正确接入 ReAct agent 的工具链。
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- **日历能力完全失效(P0)**:`calendar_tool.py` 第 164-175 行构建了 `reminder_rules`,但第 190-203 行调用 `service.create_event` 时未传递该参数——提醒规则被静默丢弃,提醒永远不会触发。
|
||||||
|
- **LLM 看不到 calendar 工具(P2)**:`default` agent 在 CalendarTool 注册前创建,缓存的工具集和系统提示中都没有 `calendar`。即使后续注册了 CalendarTool 到 `tool_registry`,agent 的 `_system_prompt` 已固化。
|
||||||
|
- **系统提示重复段落(P2)**:附加 CalendarTool 时读取已含"## 可用工具"的 `_system_prompt` 作为 `base_prompt`,再拼接新的"## 可用工具",产生两个重复段落。
|
||||||
|
- **删除会话状态泄漏(P1)**:`deleteConversation` 未调用 `markConversationDone`,pending 状态残留。后端迟到的 result 事件可能被错误路由到新会话。
|
||||||
|
- **404 级联(P1)**:多个会话过期时,404 处理器递归调用 `selectConversation` 无保护,导致 N 次顺序网络请求和 UI 卡顿。
|
||||||
|
- **monitor 标签页布局失效(P2)**:容器 div 缺少 `display: flex; flex-direction: column`,子元素的 `flex: 1` 和 `overflow-y: auto` 不生效。
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
### 日历能力断裂的完整因果链
|
||||||
|
|
||||||
|
1. `lifespan` 启动时先创建 `default` agent(第 177 行),此时 CalendarTool 尚未注册
|
||||||
|
2. agent 的 `_system_prompt` 在创建时通过 `_build_tools_description` 固化,不含 calendar 工具
|
||||||
|
3. 日历子系统初始化(第 438 行)注册 CalendarTool 到 `app.state.tool_registry`
|
||||||
|
4. 但 `default` agent 已缓存的 `_tool_registry` 和 `_system_prompt` 不会自动更新
|
||||||
|
5. LLM 在 ReAct 循环中看不到 calendar 工具,回复"我没有这个能力"
|
||||||
|
6. 即使 LLM 调用了 calendar 工具,`reminder_rules` 未传入 `create_event`,提醒不会触发
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
|
||||||
|
1. **P0**:在 `calendar_tool.py` 的 `create_event` 调用中添加 `reminder_rules=reminder_rules` 参数;添加 `reminder_offset_minutes` 范围校验(0-43200)
|
||||||
|
2. **P2**:在 `app.py` 中注册 CalendarTool 后,将其追加到 `default` agent 的 `_tool_registry`,剥离已有"## 可用工具"段落后重新拼接系统提示
|
||||||
|
3. **P1**:`deleteConversation` 添加 `markConversationDone(id)` 清理 pending 状态,跳过 `is_local` 会话的 API 调用,删除当前会话时自动切换
|
||||||
|
4. **P1**:404 处理器添加 `_is404Recovering` 递归保护标志
|
||||||
|
5. **P2**:`SystemMonitorPanel` monitor 容器添加 `display: flex; flex-direction: column`
|
||||||
|
6. **P3**:`PortalConnectionManager.send_json` 遍历前快照列表;WS `connected` 无条件清除 `is_local`;移除 `if(calendarStore)` 死代码
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `ruff check` 通过(修复了 `gui_mode` F821 未定义错误)
|
||||||
|
- `pytest tests/unit/calendar/` — 98 passed
|
||||||
|
- `npm run typecheck` — 通过
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| 文件 | 修复内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `src/agentkit/tools/calendar_tool.py` | P0: 传递 reminder_rules + 范围校验 |
|
||||||
|
| `src/agentkit/server/app.py` | P2: 附加 CalendarTool 到 default agent + 剥离重复段落 + 修复 gui_mode F821 |
|
||||||
|
| `src/agentkit/server/frontend/src/stores/chat.ts` | P1: deleteConversation 清理 + 404 递归保护 + WS connected 清除 is_local + 移除死代码 |
|
||||||
|
| `src/agentkit/server/routes/portal.py` | P3: send_json 快照列表 |
|
||||||
|
| `src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue` | P2: monitor flex 布局 |
|
||||||
|
|
@ -39,6 +39,7 @@ from agentkit.calendar.models import (
|
||||||
CalendarEvent,
|
CalendarEvent,
|
||||||
EventType,
|
EventType,
|
||||||
Invitation,
|
Invitation,
|
||||||
|
ReminderRule,
|
||||||
Tag,
|
Tag,
|
||||||
_now_iso,
|
_now_iso,
|
||||||
)
|
)
|
||||||
|
|
@ -106,6 +107,7 @@ class CalendarService:
|
||||||
is_invited: bool = False,
|
is_invited: bool = False,
|
||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
tag_ids: list[str] | None = None,
|
tag_ids: list[str] | None = None,
|
||||||
|
reminder_rules: list[ReminderRule] | None = None,
|
||||||
) -> CalendarEvent:
|
) -> CalendarEvent:
|
||||||
"""Create a calendar event with UUID, timestamps, tags, and cloned reminders."""
|
"""Create a calendar event with UUID, timestamps, tags, and cloned reminders."""
|
||||||
_validate_iso(start_time)
|
_validate_iso(start_time)
|
||||||
|
|
@ -159,6 +161,19 @@ class CalendarService:
|
||||||
)
|
)
|
||||||
await insert_reminder_rule(cloned, self.db_path)
|
await insert_reminder_rule(cloned, self.db_path)
|
||||||
|
|
||||||
|
# Insert explicit per-event reminder rules supplied by the caller (e.g. agent tool)
|
||||||
|
if reminder_rules:
|
||||||
|
for rule in reminder_rules:
|
||||||
|
await insert_reminder_rule(
|
||||||
|
dataclasses.replace(
|
||||||
|
rule,
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
event_id=event.id,
|
||||||
|
event_type_id=None,
|
||||||
|
),
|
||||||
|
self.db_path,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
|
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,27 @@ class SqliteConversationStore:
|
||||||
result.append(conv)
|
result.append(conv)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def delete_conversation(self, conversation_id: str) -> bool:
|
||||||
|
"""Delete a conversation and all its messages.
|
||||||
|
|
||||||
|
Returns ``True`` if the conversation existed and was deleted,
|
||||||
|
``False`` if it was not found.
|
||||||
|
"""
|
||||||
|
db = await self._ensure_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id FROM conversations WHERE id = ?", (conversation_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||||
|
)
|
||||||
|
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||||
|
await db.commit()
|
||||||
|
self._cache.pop(conversation_id, None)
|
||||||
|
return True
|
||||||
|
|
||||||
async def restore_from_store(
|
async def restore_from_store(
|
||||||
self,
|
self,
|
||||||
max_sessions: int = 50,
|
max_sessions: int = 50,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ from agentkit.server.routes import (
|
||||||
bitable as bitable_routes,
|
bitable as bitable_routes,
|
||||||
channels as channels_routes,
|
channels as channels_routes,
|
||||||
)
|
)
|
||||||
from agentkit.server.auth.jwt_utils import get_jwt_secret
|
from agentkit.server.auth.jwt_utils import get_jwt_secret, get_or_create_jwt_secret
|
||||||
from agentkit.server.auth.middleware import AuthMiddleware
|
from agentkit.server.auth.middleware import AuthMiddleware
|
||||||
from agentkit.server.middleware import RateLimitMiddleware
|
from agentkit.server.middleware import RateLimitMiddleware
|
||||||
from agentkit.server.task_store import create_task_store
|
from agentkit.server.task_store import create_task_store
|
||||||
|
|
@ -200,6 +200,9 @@ async def lifespan(app: FastAPI):
|
||||||
"中文内容优先使用 baidu_search 工具,英文/国际内容使用 web_search。"
|
"中文内容优先使用 baidu_search 工具,英文/国际内容使用 web_search。"
|
||||||
"在能够搜索到真相的情况下,绝不猜测或编造答案。"
|
"在能够搜索到真相的情况下,绝不猜测或编造答案。"
|
||||||
"始终优先搜索而不是给出可能不正确的信息。\n\n"
|
"始终优先搜索而不是给出可能不正确的信息。\n\n"
|
||||||
|
"日历与提醒:你可以使用 calendar 工具为用户创建日程事件和提醒。"
|
||||||
|
"当用户说'提醒我'、'预约'、'安排'、'下周一'等时,主动调用 calendar/create_event 把事件和提醒时间记下来,"
|
||||||
|
"而不是告诉用户你没有这个能力。\n\n"
|
||||||
"技能安装:当需要查找或安装技能时,先用 skill_search 搜索确认技能名称和来源,"
|
"技能安装:当需要查找或安装技能时,先用 skill_search 搜索确认技能名称和来源,"
|
||||||
"再用 skill_install 安装。不要用 shell 执行 npm install 或 npx skills install。"
|
"再用 skill_install 安装。不要用 shell 执行 npm install 或 npx skills install。"
|
||||||
)
|
)
|
||||||
|
|
@ -272,8 +275,14 @@ async def lifespan(app: FastAPI):
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register DocumentTool")
|
logger.exception("Failed to register DocumentTool")
|
||||||
|
|
||||||
# Override system prompt with memory-injected version
|
# Override system prompt with memory-injected version + available tools
|
||||||
agent._system_prompt = effective_system_prompt
|
# so the LLM knows which tools it can use (calendar, documents, etc.)
|
||||||
|
tool_desc = agent._build_tools_description(agent._tool_registry.list_tools())
|
||||||
|
agent._system_prompt = (
|
||||||
|
f"{effective_system_prompt}\n\n## 可用工具\n{tool_desc}"
|
||||||
|
if tool_desc
|
||||||
|
else effective_system_prompt
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("GUI mode: created default chat agent with memory + tools")
|
logger.info("GUI mode: created default chat agent with memory + tools")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -317,7 +326,7 @@ async def lifespan(app: FastAPI):
|
||||||
logger.info(f"GUI mode: {len(skill_registry.list_skills())} skills registered")
|
logger.info(f"GUI mode: {len(skill_registry.list_skills())} skills registered")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"GUI mode: failed to load skills: {e}")
|
logger.warning(f"GUI mode: failed to load skills: {e}")
|
||||||
elif gui_mode:
|
elif os.environ.get("AGENTKIT_GUI_MODE"):
|
||||||
# Agent already exists (e.g. from config), still ensure memory store is available
|
# Agent already exists (e.g. from config), still ensure memory store is available
|
||||||
if not hasattr(app.state, "memory_store") or app.state.memory_store is None:
|
if not hasattr(app.state, "memory_store") or app.state.memory_store is None:
|
||||||
from agentkit.memory.profile import MemoryStore
|
from agentkit.memory.profile import MemoryStore
|
||||||
|
|
@ -407,6 +416,7 @@ async def lifespan(app: FastAPI):
|
||||||
calendar_scheduler = None
|
calendar_scheduler = None
|
||||||
try:
|
try:
|
||||||
from agentkit.calendar.db import init_calendar_db
|
from agentkit.calendar.db import init_calendar_db
|
||||||
|
from agentkit.calendar.reminders import ReminderDispatcher
|
||||||
from agentkit.calendar.scheduler import ReminderScheduler
|
from agentkit.calendar.scheduler import ReminderScheduler
|
||||||
from agentkit.calendar.service import CalendarService
|
from agentkit.calendar.service import CalendarService
|
||||||
from agentkit.tools.calendar_tool import CalendarTool
|
from agentkit.tools.calendar_tool import CalendarTool
|
||||||
|
|
@ -414,15 +424,56 @@ async def lifespan(app: FastAPI):
|
||||||
await init_calendar_db()
|
await init_calendar_db()
|
||||||
cal_service = CalendarService()
|
cal_service = CalendarService()
|
||||||
app.state.calendar_service = cal_service
|
app.state.calendar_service = cal_service
|
||||||
calendar_scheduler = ReminderScheduler()
|
|
||||||
|
# Wire portal WebSocket fan-out so calendar reminders reach the user's
|
||||||
|
# open chat tab(s) in real time.
|
||||||
|
async def _calendar_ws_sender(user_id: str, message: dict[str, object]) -> None:
|
||||||
|
await portal.send_to_user(user_id, message)
|
||||||
|
|
||||||
|
calendar_scheduler = ReminderScheduler(
|
||||||
|
dispatcher=ReminderDispatcher(ws_sender=_calendar_ws_sender)
|
||||||
|
)
|
||||||
await calendar_scheduler.start()
|
await calendar_scheduler.start()
|
||||||
app.state.calendar_scheduler = calendar_scheduler
|
app.state.calendar_scheduler = calendar_scheduler
|
||||||
# Register CalendarTool so ReAct agents can create/query events.
|
# Register CalendarTool so ReAct agents can create/query events.
|
||||||
try:
|
try:
|
||||||
app.state.tool_registry.register(CalendarTool(service=cal_service))
|
calendar_tool = CalendarTool(calendar_service=cal_service)
|
||||||
|
app.state.tool_registry.register(calendar_tool)
|
||||||
logger.info("CalendarTool registered for ReAct integration")
|
logger.info("CalendarTool registered for ReAct integration")
|
||||||
|
# The default GUI agent was created above (before the calendar
|
||||||
|
# subsystem was wired up) and cached a tool list that does NOT
|
||||||
|
# include the calendar tool. Re-register it on the default agent
|
||||||
|
# and rebuild the system prompt so the LLM is actually aware
|
||||||
|
# the `calendar` tool exists. Without this, the agent replies
|
||||||
|
# "I don't have a reminder feature" to requests like
|
||||||
|
# "remind me Monday morning to prep the kickoff deck".
|
||||||
|
try:
|
||||||
|
default_agent = app.state.agent_pool.get_agent("default")
|
||||||
|
if default_agent is not None:
|
||||||
|
try:
|
||||||
|
default_agent._tool_registry.register(calendar_tool)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Already registered
|
pass # already registered
|
||||||
|
# Strip any existing "## 可用工具" section to avoid
|
||||||
|
# duplicate tool blocks in the system prompt.
|
||||||
|
base_prompt = getattr(default_agent, "_system_prompt", None) or (
|
||||||
|
default_agent.get_system_prompt() or ""
|
||||||
|
)
|
||||||
|
if "## 可用工具" in base_prompt:
|
||||||
|
base_prompt = base_prompt.rsplit("## 可用工具", 1)[0].rstrip()
|
||||||
|
tool_desc = default_agent._build_tools_description(
|
||||||
|
default_agent._tool_registry.list_tools()
|
||||||
|
)
|
||||||
|
if tool_desc:
|
||||||
|
default_agent._system_prompt = f"{base_prompt}\n\n## 可用工具\n{tool_desc}"
|
||||||
|
logger.info(
|
||||||
|
"CalendarTool attached to default agent (tools=%d)",
|
||||||
|
len(default_agent._tool_registry.list_tools()),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to attach CalendarTool to default agent")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to register CalendarTool")
|
||||||
logger.info("Calendar subsystem initialized (service + reminder scheduler)")
|
logger.info("Calendar subsystem initialized (service + reminder scheduler)")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to initialize calendar subsystem — calendar API unavailable")
|
logger.exception("Failed to initialize calendar subsystem — calendar API unavailable")
|
||||||
|
|
@ -743,9 +794,11 @@ def create_app(
|
||||||
# only sees requests that AuthMiddleware could not authenticate.
|
# only sees requests that AuthMiddleware could not authenticate.
|
||||||
#
|
#
|
||||||
# JWT auth is only active when AGENTKIT_JWT_SECRET is explicitly set.
|
# JWT auth is only active when AGENTKIT_JWT_SECRET is explicitly set.
|
||||||
# When unset, the middleware runs in dev mode (no JWT enforcement) but
|
# When unset, generate ONE ephemeral secret for the process lifetime so
|
||||||
# the auth routes still issue tokens signed with an ephemeral secret.
|
# that login (signing), whoami (verifying), and the middleware all use
|
||||||
jwt_secret = get_jwt_secret()
|
# the same secret. Without this, get_or_create_jwt_secret() would mint
|
||||||
|
# a different random secret on every call and tokens could never verify.
|
||||||
|
jwt_secret = get_jwt_secret() or get_or_create_jwt_secret()
|
||||||
client_keys: dict[str, str] = {}
|
client_keys: dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
from agentkit.server.middleware import _load_client_keys
|
from agentkit.server.middleware import _load_client_keys
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
|
# 0. CORS preflight — OPTIONS requests must never be authenticated.
|
||||||
|
# The browser sends them without credentials; if we return 401
|
||||||
|
# here, CORSMiddleware never sees the request and the browser
|
||||||
|
# blocks the actual request with "Load failed".
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 0b. If an outer middleware already set current_user (e.g. a test
|
||||||
|
# dev-admin injector), defer to it instead of re-authenticating.
|
||||||
|
if getattr(request.state, "current_user", None) is not None:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# 1. Whitelist
|
# 1. Whitelist
|
||||||
if self._is_whitelisted(path):
|
if self._is_whitelisted(path):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
@ -171,6 +183,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
path.startswith("/api/v1/ws")
|
path.startswith("/api/v1/ws")
|
||||||
or path.startswith("/ws")
|
or path.startswith("/ws")
|
||||||
or path.startswith("/api/v1/chat/ws")
|
or path.startswith("/api/v1/chat/ws")
|
||||||
|
or path.startswith("/api/v1/portal/ws")
|
||||||
):
|
):
|
||||||
token = request.query_params.get("token")
|
token = request.query_params.get("token")
|
||||||
if token:
|
if token:
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,10 @@ declare module 'vue' {
|
||||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||||
ATimePicker: typeof import('ant-design-vue/es/time-picker/dayjs')['TimePicker']
|
ATimePicker: typeof import('ant-design-vue/es/time-picker/dayjs')['TimePicker']
|
||||||
|
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||||
|
AttachmentCell: typeof import('./src/components/bitable/AttachmentCell.vue')['default']
|
||||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||||
|
BitableGrid: typeof import('./src/components/bitable/BitableGrid.vue')['default']
|
||||||
BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default']
|
BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default']
|
||||||
BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.vue')['default']
|
BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.vue')['default']
|
||||||
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
|
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
|
||||||
|
|
@ -83,6 +86,7 @@ declare module 'vue' {
|
||||||
ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.vue')['default']
|
ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.vue')['default']
|
||||||
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
|
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default']
|
||||||
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
|
CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default']
|
||||||
|
CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default']
|
||||||
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
|
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
|
||||||
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
|
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
|
||||||
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
|
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
|
||||||
|
|
@ -93,6 +97,7 @@ declare module 'vue' {
|
||||||
DebateSummaryCard: typeof import('./src/components/chat/messages/DebateSummaryCard.vue')['default']
|
DebateSummaryCard: typeof import('./src/components/chat/messages/DebateSummaryCard.vue')['default']
|
||||||
DocumentCard: typeof import('./src/components/chat/messages/DocumentCard.vue')['default']
|
DocumentCard: typeof import('./src/components/chat/messages/DocumentCard.vue')['default']
|
||||||
DocumentPanel: typeof import('./src/components/chat/DocumentPanel.vue')['default']
|
DocumentPanel: typeof import('./src/components/chat/DocumentPanel.vue')['default']
|
||||||
|
DocumentsTab: typeof import('./src/components/layout/tabs/DocumentsTab.vue')['default']
|
||||||
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
|
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
|
||||||
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default']
|
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default']
|
||||||
EventBadge: typeof import('./src/components/calendar/EventBadge.vue')['default']
|
EventBadge: typeof import('./src/components/calendar/EventBadge.vue')['default']
|
||||||
|
|
@ -101,12 +106,17 @@ declare module 'vue' {
|
||||||
ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default']
|
ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default']
|
||||||
ExpertMessage: typeof import('./src/components/chat/ExpertMessage.vue')['default']
|
ExpertMessage: typeof import('./src/components/chat/ExpertMessage.vue')['default']
|
||||||
ExpertTeamView: typeof import('./src/components/chat/ExpertTeamView.vue')['default']
|
ExpertTeamView: typeof import('./src/components/chat/ExpertTeamView.vue')['default']
|
||||||
|
FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default']
|
||||||
|
FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default']
|
||||||
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
|
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
|
||||||
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
|
FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default']
|
||||||
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
|
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
|
||||||
|
FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default']
|
||||||
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
|
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
|
||||||
IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
|
IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
|
||||||
|
ImageCell: typeof import('./src/components/bitable/ImageCell.vue')['default']
|
||||||
InvitationManager: typeof import('./src/components/calendar/InvitationManager.vue')['default']
|
InvitationManager: typeof import('./src/components/calendar/InvitationManager.vue')['default']
|
||||||
|
KBSettings: typeof import('./src/components/kb/KBSettings.vue')['default']
|
||||||
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
|
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
|
||||||
ListView: typeof import('./src/components/calendar/ListView.vue')['default']
|
ListView: typeof import('./src/components/calendar/ListView.vue')['default']
|
||||||
MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default']
|
MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default']
|
||||||
|
|
@ -123,7 +133,9 @@ declare module 'vue' {
|
||||||
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
|
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
|
||||||
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
|
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
|
||||||
ReminderConfig: typeof import('./src/components/calendar/ReminderConfig.vue')['default']
|
ReminderConfig: typeof import('./src/components/calendar/ReminderConfig.vue')['default']
|
||||||
|
ReviewResultCard: typeof import('./src/components/chat/messages/ReviewResultCard.vue')['default']
|
||||||
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']
|
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']
|
||||||
|
RiskFlagCard: typeof import('./src/components/chat/messages/RiskFlagCard.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Scene1Welcome: typeof import('./src/components/preview/scenes/Scene1Welcome.vue')['default']
|
Scene1Welcome: typeof import('./src/components/preview/scenes/Scene1Welcome.vue')['default']
|
||||||
|
|
@ -133,6 +145,7 @@ declare module 'vue' {
|
||||||
Scene5ToolCall: typeof import('./src/components/preview/scenes/Scene5ToolCall.vue')['default']
|
Scene5ToolCall: typeof import('./src/components/preview/scenes/Scene5ToolCall.vue')['default']
|
||||||
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default']
|
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default']
|
||||||
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
|
SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default']
|
||||||
|
SegmentPreview: typeof import('./src/components/kb/SegmentPreview.vue')['default']
|
||||||
SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
|
SideNav: typeof import('./src/components/layout/SideNav.vue')['default']
|
||||||
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
|
SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default']
|
||||||
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']
|
SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default']
|
||||||
|
|
@ -144,6 +157,8 @@ declare module 'vue' {
|
||||||
SyncSettings: typeof import('./src/components/calendar/SyncSettings.vue')['default']
|
SyncSettings: typeof import('./src/components/calendar/SyncSettings.vue')['default']
|
||||||
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default']
|
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default']
|
||||||
SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default']
|
SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default']
|
||||||
|
TableCreateModal: typeof import('./src/components/bitable/TableCreateModal.vue')['default']
|
||||||
|
TableViewList: typeof import('./src/components/bitable/TableViewList.vue')['default']
|
||||||
TeamModal: typeof import('./src/components/chat/TeamModal.vue')['default']
|
TeamModal: typeof import('./src/components/chat/TeamModal.vue')['default']
|
||||||
TeamPlanCard: typeof import('./src/components/chat/messages/TeamPlanCard.vue')['default']
|
TeamPlanCard: typeof import('./src/components/chat/messages/TeamPlanCard.vue')['default']
|
||||||
TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default']
|
TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default']
|
||||||
|
|
@ -156,6 +171,8 @@ declare module 'vue' {
|
||||||
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
|
UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default']
|
||||||
UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default']
|
UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default']
|
||||||
UserSessionsPanel: typeof import('./src/components/admin/UserSessionsPanel.vue')['default']
|
UserSessionsPanel: typeof import('./src/components/admin/UserSessionsPanel.vue')['default']
|
||||||
|
ViewConfigPanel: typeof import('./src/components/bitable/ViewConfigPanel.vue')['default']
|
||||||
|
ViewSwitcher: typeof import('./src/components/bitable/ViewSwitcher.vue')['default']
|
||||||
WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default']
|
WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,35 @@ export interface IApiError {
|
||||||
*/
|
*/
|
||||||
export const CLIENT_VERSION = '0.5.0'
|
export const CLIENT_VERSION = '0.5.0'
|
||||||
|
|
||||||
|
/** 中文 HTTP 状态码提示,覆盖所有用户可见的错误状态。 */
|
||||||
|
const HTTP_STATUS_ZH: Record<number, string> = {
|
||||||
|
400: '请求参数错误',
|
||||||
|
401: '认证失败,请重新登录',
|
||||||
|
403: '没有操作权限',
|
||||||
|
404: '资源不存在',
|
||||||
|
408: '请求超时',
|
||||||
|
409: '资源冲突',
|
||||||
|
413: '请求数据过大',
|
||||||
|
422: '数据验证失败',
|
||||||
|
429: '请求过于频繁,请稍后重试',
|
||||||
|
500: '服务器内部错误',
|
||||||
|
501: '功能未实现',
|
||||||
|
502: '网关错误',
|
||||||
|
503: '服务暂不可用',
|
||||||
|
504: '网关超时',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 fetch 网络错误(WebKit 的 "Load failed"、Chrome 的 "Failed to fetch")
|
||||||
|
* 转换为中文提示,保持原始错误作为 detail。
|
||||||
|
*/
|
||||||
|
function wrapNetworkError(e: unknown): never {
|
||||||
|
if (e instanceof TypeError && /load failed|failed to fetch/i.test(e.message)) {
|
||||||
|
throw new Error('网络连接失败,请检查服务是否已启动')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
let _dynamicBaseURL = ''
|
let _dynamicBaseURL = ''
|
||||||
|
|
||||||
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
|
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
|
||||||
|
|
@ -231,7 +260,11 @@ export class BaseApiClient {
|
||||||
private async _send(path: string, options: RequestInit): Promise<Response> {
|
private async _send(path: string, options: RequestInit): Promise<Response> {
|
||||||
const effectiveUrl = this._resolveUrl(path)
|
const effectiveUrl = this._resolveUrl(path)
|
||||||
const headers = this._buildHeaders(options)
|
const headers = this._buildHeaders(options)
|
||||||
return fetch(effectiveUrl, { ...options, headers })
|
try {
|
||||||
|
return await fetch(effectiveUrl, { ...options, headers })
|
||||||
|
} catch (e) {
|
||||||
|
wrapNetworkError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Low-level fetch with caller-supplied headers (overrides the default Authorization). */
|
/** Low-level fetch with caller-supplied headers (overrides the default Authorization). */
|
||||||
|
|
@ -248,13 +281,17 @@ export class BaseApiClient {
|
||||||
if (!(options.body instanceof FormData)) {
|
if (!(options.body instanceof FormData)) {
|
||||||
merged['Content-Type'] = 'application/json'
|
merged['Content-Type'] = 'application/json'
|
||||||
}
|
}
|
||||||
return fetch(effectiveUrl, { ...options, headers: merged })
|
try {
|
||||||
|
return await fetch(effectiveUrl, { ...options, headers: merged })
|
||||||
|
} catch (e) {
|
||||||
|
wrapNetworkError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _buildError(response: Response): Promise<IApiError> {
|
private async _buildError(response: Response): Promise<IApiError> {
|
||||||
const error: IApiError = {
|
const error: IApiError = {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
message: response.statusText,
|
message: HTTP_STATUS_ZH[response.status] ?? response.statusText,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await response.json()
|
const body = await response.json()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ class ApiClient extends BaseApiClient {
|
||||||
return this.request<IConversation>(`/conversations/${id}`)
|
return this.request<IConversation>(`/conversations/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a conversation and all its messages */
|
||||||
|
async deleteConversation(id: string): Promise<void> {
|
||||||
|
await this.request(`/conversations/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
/** Get a task by ID (uses /api/v1/tasks prefix) */
|
/** Get a task by ID (uses /api/v1/tasks prefix) */
|
||||||
async getTask(taskId: string): Promise<ITaskRecord> {
|
async getTask(taskId: string): Promise<ITaskRecord> {
|
||||||
return this.request<ITaskRecord>(`/api/v1/tasks/${taskId}`)
|
return this.request<ITaskRecord>(`/api/v1/tasks/${taskId}`)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function isTauri(): boolean {
|
||||||
// Lazy-loaded invoke — only import when in Tauri environment
|
// Lazy-loaded invoke — only import when in Tauri environment
|
||||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
throw new Error(`invoke('${cmd}') called outside Tauri environment`)
|
throw new Error(`invoke('${cmd}') 在 Tauri 环境外被调用`)
|
||||||
}
|
}
|
||||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
|
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
|
||||||
return tauriInvoke<T>(cmd, args)
|
return tauriInvoke<T>(cmd, args)
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ export interface IConversation {
|
||||||
messages: IChatMessage[]
|
messages: IChatMessage[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
/** 仅本地存在、尚未同步到服务端的临时会话 */
|
||||||
|
is_local?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capability info */
|
/** Capability info */
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,17 @@ function onEdit(event: ICalendarEvent): void {
|
||||||
|
|
||||||
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-drawer__tabs :deep(.ant-tabs-content) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-drawer__tabs :deep(.ant-tabs-tabpane) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-drawer__toolbar {
|
.calendar-drawer__toolbar {
|
||||||
|
|
@ -116,6 +126,9 @@ function onEdit(event: ICalendarEvent): void {
|
||||||
|
|
||||||
.calendar-drawer__content {
|
.calendar-drawer__content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-top: 1px solid var(--border-color-split);
|
border-top: 1px solid var(--border-color-split);
|
||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="calendar-grid">
|
<div ref="gridRef" class="calendar-grid">
|
||||||
<FullCalendar :options="calendarOptions" />
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import FullCalendar from '@fullcalendar/vue3'
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
import interactionPlugin from '@fullcalendar/interaction'
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
|
import zhCnLocale from '@fullcalendar/core/locales/zh-cn'
|
||||||
import type {
|
import type {
|
||||||
CalendarOptions,
|
CalendarOptions,
|
||||||
EventInput,
|
EventInput,
|
||||||
|
|
@ -22,6 +23,11 @@ import type { ICalendarEvent } from '@/api/calendar'
|
||||||
|
|
||||||
const store = useCalendarStore()
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
const gridRef = ref<HTMLElement>()
|
||||||
|
const calendarRef = ref<InstanceType<typeof FullCalendar>>()
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'create', start?: Date, end?: Date): void
|
(e: 'create', start?: Date, end?: Date): void
|
||||||
(e: 'edit', event: ICalendarEvent): void
|
(e: 'edit', event: ICalendarEvent): void
|
||||||
|
|
@ -60,14 +66,36 @@ function handleEventClick(arg: EventClickArg): void {
|
||||||
emit('edit', ev)
|
emit('edit', ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
calendarRef.value?.getApi()?.updateSize()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (gridRef.value) {
|
||||||
|
resizeObserver.observe(gridRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
const calendarOptions = computed<CalendarOptions>(() => ({
|
const calendarOptions = computed<CalendarOptions>(() => ({
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||||
|
locale: zhCnLocale,
|
||||||
initialView: 'dayGridMonth',
|
initialView: 'dayGridMonth',
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||||
},
|
},
|
||||||
|
buttonText: {
|
||||||
|
today: '今天',
|
||||||
|
month: '月',
|
||||||
|
week: '周',
|
||||||
|
day: '日',
|
||||||
|
},
|
||||||
selectable: true,
|
selectable: true,
|
||||||
editable: true,
|
editable: true,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|
@ -80,7 +108,8 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,14 @@ async function loadConfigs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const resp = await calendarApi.listExternalConfigs()
|
const resp = await calendarApi.listExternalConfigs()
|
||||||
configs.value = resp.configs || []
|
configs.value = resp.configs || []
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
// 后端尚未注册 /external-configs 端点;404 时静默返回空列表
|
||||||
|
const status = (err as { status?: number }).status
|
||||||
|
if (status === 404 || status === 503) {
|
||||||
|
configs.value = []
|
||||||
|
} else {
|
||||||
notification.error({ message: '加载外部日历配置失败' })
|
notification.error({ message: '加载外部日历配置失败' })
|
||||||
|
}
|
||||||
console.warn('listExternalConfigs failed:', err)
|
console.warn('listExternalConfigs failed:', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,21 @@
|
||||||
<MessageOutlined class="chat-sidebar__item-icon" />
|
<MessageOutlined class="chat-sidebar__item-icon" />
|
||||||
<span class="chat-sidebar__item-title">{{ conv.title }}</span>
|
<span class="chat-sidebar__item-title">{{ conv.title }}</span>
|
||||||
<span class="chat-sidebar__item-time">{{ formatRelativeTime(conv.updated_at) }}</span>
|
<span class="chat-sidebar__item-time">{{ formatRelativeTime(conv.updated_at) }}</span>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定删除此对话?"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm.stop="handleDelete(conv.id)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="chat-sidebar__item-delete"
|
||||||
|
title="删除对话"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</a-popconfirm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31,8 +46,14 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { Button as AButton, Empty as AEmpty } from 'ant-design-vue'
|
import { Button as AButton, Empty as AEmpty, Popconfirm as APopconfirm } from 'ant-design-vue'
|
||||||
import { PlusOutlined, MessageOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import type { IConversation } from '@/api/types'
|
import type { IConversation } from '@/api/types'
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
|
@ -45,6 +66,7 @@ defineProps<IProps>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
create: []
|
create: []
|
||||||
select: [id: string]
|
select: [id: string]
|
||||||
|
delete: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
|
@ -57,6 +79,10 @@ function handleSelect(id: string): void {
|
||||||
emit('select', id)
|
emit('select', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string): void {
|
||||||
|
emit('delete', id)
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
@ -174,6 +200,31 @@ function formatRelativeTime(dateStr: string): string {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item-delete {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item:hover .chat-sidebar__item-delete {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item-delete:hover {
|
||||||
|
background: var(--color-error-bg, rgba(255, 77, 79, 0.1));
|
||||||
|
color: var(--color-error, #ff4d4f);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-sidebar__toggle {
|
.chat-sidebar__toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
:title="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
:title="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<LeftOutlined v-if="!collapsed" />
|
<RightOutlined v-if="!collapsed" />
|
||||||
<RightOutlined v-else />
|
<LeftOutlined v-else />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="!collapsed" class="right-panel__content">
|
<div v-if="!collapsed" class="right-panel__content">
|
||||||
|
|
@ -51,8 +51,7 @@ defineExpose({ toggle, collapsed })
|
||||||
.right-panel__toggle {
|
.right-panel__toggle {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
height: 36px;
|
align-self: stretch;
|
||||||
margin: 6px 0 0 0;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="splash-screen">
|
<div class="splash-screen">
|
||||||
<div class="splash-content">
|
<div class="splash-content">
|
||||||
<div class="splash-logo">Fischer AgentKit</div>
|
<div class="splash-logo">Fischer AgentKit</div>
|
||||||
<div class="splash-subtitle">AI Agent Framework</div>
|
<div class="splash-subtitle">AI 智能体框架</div>
|
||||||
<div v-if="!error" class="splash-progress">
|
<div v-if="!error" class="splash-progress">
|
||||||
<div class="splash-progress-bar">
|
<div class="splash-progress-bar">
|
||||||
<div class="splash-progress-inner"></div>
|
<div class="splash-progress-inner"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="system-monitor">
|
<div class="system-monitor">
|
||||||
<div class="system-monitor__tabs" role="tablist">
|
<div class="system-monitor__content">
|
||||||
<button
|
<div v-if="activeTab === 'monitor'" class="system-monitor__monitor">
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
type="button"
|
|
||||||
class="system-monitor__tab"
|
|
||||||
role="tab"
|
|
||||||
:aria-selected="activeTab === tab.key"
|
|
||||||
:class="{ 'system-monitor__tab--active': activeTab === tab.key }"
|
|
||||||
@click="activeTab = tab.key"
|
|
||||||
>
|
|
||||||
<component :is="tab.icon" class="system-monitor__tab-icon" />
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'monitor'">
|
|
||||||
<div v-if="loading" class="system-monitor__loading">
|
<div v-if="loading" class="system-monitor__loading">
|
||||||
<a-spin size="small" />
|
<a-spin size="small" />
|
||||||
<span>加载监控数据中...</span>
|
<span>加载监控数据中...</span>
|
||||||
|
|
@ -88,10 +73,35 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SkillsTab v-else-if="activeTab === 'skills'" />
|
<SkillsTab v-else-if="activeTab === 'engines'" category="agent_template" />
|
||||||
|
<SkillsTab v-else-if="activeTab === 'skills'" category="business_skill" />
|
||||||
|
<DocumentsTab v-else-if="activeTab === 'documents'" />
|
||||||
|
<CalendarTab v-else-if="activeTab === 'calendar'" />
|
||||||
<SystemTab v-else-if="activeTab === 'system'" />
|
<SystemTab v-else-if="activeTab === 'system'" />
|
||||||
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航">
|
||||||
|
<a-tooltip
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:title="tab.label"
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="system-monitor__tab"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === tab.key"
|
||||||
|
:aria-label="tab.label"
|
||||||
|
:class="{ 'system-monitor__tab--active': activeTab === tab.key }"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="system-monitor__tab-icon" />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -101,8 +111,11 @@ import { Spin as ASpin } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
@ -115,6 +128,8 @@ import {
|
||||||
import SkillsTab from './tabs/SkillsTab.vue'
|
import SkillsTab from './tabs/SkillsTab.vue'
|
||||||
import SystemTab from './tabs/SystemTab.vue'
|
import SystemTab from './tabs/SystemTab.vue'
|
||||||
import KnowledgeTab from './tabs/KnowledgeTab.vue'
|
import KnowledgeTab from './tabs/KnowledgeTab.vue'
|
||||||
|
import CalendarTab from './tabs/CalendarTab.vue'
|
||||||
|
import DocumentsTab from './tabs/DocumentsTab.vue'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
key: string
|
key: string
|
||||||
|
|
@ -124,7 +139,10 @@ interface Tab {
|
||||||
|
|
||||||
const tabs: Tab[] = [
|
const tabs: Tab[] = [
|
||||||
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
|
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
|
||||||
|
{ key: 'engines', label: '引擎', icon: ThunderboltOutlined as Component },
|
||||||
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
|
{ key: 'skills', label: '技能', icon: AppstoreOutlined as Component },
|
||||||
|
{ key: 'documents', label: '文档', icon: FolderOpenOutlined as Component },
|
||||||
|
{ key: 'calendar', label: '日程', icon: CalendarOutlined as Component },
|
||||||
{ key: 'system', label: '系统', icon: SettingOutlined as Component },
|
{ key: 'system', label: '系统', icon: SettingOutlined as Component },
|
||||||
{ key: 'knowledge', label: '知识库', icon: BookOutlined as Component },
|
{ key: 'knowledge', label: '知识库', icon: BookOutlined as Component },
|
||||||
]
|
]
|
||||||
|
|
@ -223,49 +241,74 @@ onUnmounted(() => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.system-monitor {
|
.system-monitor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-monitor__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 所有 Tab 内容组件自动填充高度 */
|
||||||
|
.system-monitor__content > * {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* monitor 标签页需要自己的 flex 列布局以支持子元素滚动和居中 */
|
||||||
|
.system-monitor__monitor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.system-monitor__tabs {
|
.system-monitor__tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
flex-direction: column;
|
||||||
padding: 14px 16px;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--border-color);
|
gap: var(--space-1);
|
||||||
font-size: var(--font-sm);
|
padding: var(--space-3) var(--space-1);
|
||||||
color: var(--text-tertiary);
|
border-left: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__tab {
|
.system-monitor__tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__tab:hover {
|
.system-monitor__tab:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__tab--active {
|
.system-monitor__tab--active {
|
||||||
color: var(--accent-team);
|
color: var(--accent-team);
|
||||||
font-weight: var(--font-weight-medium);
|
background: var(--color-primary-light);
|
||||||
border-bottom-color: var(--accent-team);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__tab-icon {
|
.system-monitor__tab-icon {
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__loading,
|
.system-monitor__loading,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@
|
||||||
:text="wsConnected ? '已连接' : '未连接'"
|
:text="wsConnected ? '已连接' : '未连接'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<a-tooltip title="多维表格">
|
||||||
|
<button class="top-nav__icon-btn" @click="router.push('/bitable')">
|
||||||
|
<TableOutlined />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
|
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
|
||||||
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
|
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
|
||||||
<BulbOutlined v-if="isDark" />
|
<BulbOutlined v-if="isDark" />
|
||||||
|
|
@ -56,6 +61,7 @@ import {
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
BulbOutlined,
|
BulbOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
TableOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
<template>
|
||||||
|
<div class="documents-tab">
|
||||||
|
<div v-if="!conversationId" class="documents-tab__empty">
|
||||||
|
<FolderOpenOutlined />
|
||||||
|
<span>未选择对话</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="documents-tab__header">
|
||||||
|
<FolderOpenOutlined />
|
||||||
|
<span>文档列表</span>
|
||||||
|
<a-badge
|
||||||
|
v-if="documents.length > 0"
|
||||||
|
:count="documents.length"
|
||||||
|
:number-style="{ backgroundColor: '#1890ff' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="documents-tab__body">
|
||||||
|
<a-spin v-if="loading" size="small" />
|
||||||
|
<a-empty
|
||||||
|
v-else-if="documents.length === 0"
|
||||||
|
description="暂无文档"
|
||||||
|
:image="simpleImage"
|
||||||
|
/>
|
||||||
|
<div v-else class="documents-tab__list">
|
||||||
|
<div
|
||||||
|
v-for="doc in documents"
|
||||||
|
:key="doc.id"
|
||||||
|
class="documents-tab__item"
|
||||||
|
>
|
||||||
|
<DocumentCard :document="doc" />
|
||||||
|
<div class="documents-tab__item-time">{{ formatTime(doc.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { FolderOpenOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { Empty } from 'ant-design-vue'
|
||||||
|
import DocumentCard from '@/components/chat/messages/DocumentCard.vue'
|
||||||
|
import { useDocumentsStore } from '@/stores/documents'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
|
||||||
|
const documentsStore = useDocumentsStore()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
const conversationId = computed(() => chatStore.currentConversationId)
|
||||||
|
const documents = computed(() =>
|
||||||
|
conversationId.value ? documentsStore.getDocuments(conversationId.value) : [],
|
||||||
|
)
|
||||||
|
const loading = computed(() =>
|
||||||
|
conversationId.value ? documentsStore.loadingConversations.has(conversationId.value) : false,
|
||||||
|
)
|
||||||
|
|
||||||
|
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
conversationId,
|
||||||
|
(newId) => {
|
||||||
|
if (newId) {
|
||||||
|
documentsStore.fetchDocuments(newId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.documents-tab {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-tab__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-tab__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-tab__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-tab__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-tab__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-tab__item-time {
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding-left: var(--space-1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="skills-tab">
|
<div class="skills-tab">
|
||||||
<div v-if="skillsStore.isLoading" class="skills-tab__loading">
|
<div v-if="skillsStore.isLoading" class="skills-tab__loading">
|
||||||
<a-spin size="small" />
|
<a-spin size="small" />
|
||||||
<span>加载技能列表...</span>
|
<span>加载{{ title }}...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="skillsStore.error" class="skills-tab__error">
|
<div v-else-if="skillsStore.error" class="skills-tab__error">
|
||||||
|
|
@ -10,14 +10,14 @@
|
||||||
<span>{{ skillsStore.error }}</span>
|
<span>{{ skillsStore.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="skillsStore.skills.length === 0" class="skills-tab__empty">
|
<div v-else-if="visibleSkills.length === 0" class="skills-tab__empty">
|
||||||
<AppstoreOutlined />
|
<component :is="emptyIcon" />
|
||||||
<span>暂无已注册技能</span>
|
<span>{{ emptyText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="skills-tab__list">
|
<div v-else class="skills-tab__list">
|
||||||
<div
|
<div
|
||||||
v-for="skill in skillsStore.skills"
|
v-for="skill in visibleSkills"
|
||||||
:key="skill.name"
|
:key="skill.name"
|
||||||
class="skills-tab__item"
|
class="skills-tab__item"
|
||||||
:class="`skills-tab__item--${skill.category || 'business_skill'}`"
|
:class="`skills-tab__item--${skill.category || 'business_skill'}`"
|
||||||
|
|
@ -25,14 +25,7 @@
|
||||||
>
|
>
|
||||||
<div class="skills-tab__item-header">
|
<div class="skills-tab__item-header">
|
||||||
<component :is="iconFor(skill)" class="skills-tab__item-icon" />
|
<component :is="iconFor(skill)" class="skills-tab__item-icon" />
|
||||||
<span class="skills-tab__item-name">{{ skill.name }}</span>
|
<span class="skills-tab__item-name" :title="skill.name">{{ skill.name }}</span>
|
||||||
<a-tag
|
|
||||||
class="skills-tab__item-type"
|
|
||||||
:color="isEngine(skill) ? 'purple' : 'blue'"
|
|
||||||
:title="isEngine(skill) ? '执行引擎模板' : '业务领域技能'"
|
|
||||||
>
|
|
||||||
{{ isEngine(skill) ? '引擎' : '技能' }}
|
|
||||||
</a-tag>
|
|
||||||
<a-badge
|
<a-badge
|
||||||
class="skills-tab__item-status"
|
class="skills-tab__item-status"
|
||||||
:status="skill.status === 'active' ? 'success' : 'default'"
|
:status="skill.status === 'active' ? 'success' : 'default'"
|
||||||
|
|
@ -41,7 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="skills-tab__item-desc">{{ skill.description || '暂无描述' }}</p>
|
<p class="skills-tab__item-desc">{{ skill.description || '暂无描述' }}</p>
|
||||||
<div class="skills-tab__item-tags">
|
<div class="skills-tab__item-tags">
|
||||||
<a-tag v-for="cap in skill.capabilities.slice(0, 3)" :key="cap">
|
<a-tag v-for="cap in skill.capabilities.slice(0, 3)" :key="cap" size="small">
|
||||||
{{ cap }}
|
{{ cap }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<span v-if="skill.capabilities.length > 3" class="skills-tab__item-more">
|
<span v-if="skill.capabilities.length > 3" class="skills-tab__item-more">
|
||||||
|
|
@ -60,18 +53,51 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { AppstoreOutlined, ThunderboltOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import { useSkillsStore } from '@/stores/skills'
|
import { useSkillsStore } from '@/stores/skills'
|
||||||
import type { ISkillInfo } from '@/api/skills'
|
import type { ISkillInfo } from '@/api/skills'
|
||||||
import SkillDetail from '@/components/skills/SkillDetail.vue'
|
import SkillDetail from '@/components/skills/SkillDetail.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 'agent_template' = 执行引擎, 'business_skill' = 业务技能, undefined = 全部 */
|
||||||
|
category?: 'agent_template' | 'business_skill'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
category: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const skillsStore = useSkillsStore()
|
const skillsStore = useSkillsStore()
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
|
|
||||||
function isEngine(skill: ISkillInfo): boolean {
|
const isEngine = (skill: ISkillInfo) => skill.category === 'agent_template'
|
||||||
return skill.category === 'agent_template'
|
|
||||||
}
|
const visibleSkills = computed(() => {
|
||||||
|
if (!props.category) return skillsStore.skills
|
||||||
|
return skillsStore.skills.filter((s) => s.category === props.category)
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.category === 'agent_template') return '执行引擎列表'
|
||||||
|
if (props.category === 'business_skill') return '技能列表'
|
||||||
|
return '技能列表'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyIcon = computed(() =>
|
||||||
|
props.category === 'agent_template' ? ThunderboltOutlined : AppstoreOutlined
|
||||||
|
)
|
||||||
|
|
||||||
|
const emptyText = computed(() => {
|
||||||
|
if (props.category === 'agent_template') return '暂无已注册执行引擎'
|
||||||
|
if (props.category === 'business_skill') return '暂无已注册业务技能'
|
||||||
|
return '暂无已注册技能'
|
||||||
|
})
|
||||||
|
|
||||||
function iconFor(skill: ISkillInfo) {
|
function iconFor(skill: ISkillInfo) {
|
||||||
return isEngine(skill) ? ThunderboltOutlined : AppstoreOutlined
|
return isEngine(skill) ? ThunderboltOutlined : AppstoreOutlined
|
||||||
|
|
@ -88,7 +114,9 @@ function closeDetail(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (skillsStore.skills.length === 0) {
|
||||||
skillsStore.fetchSkills()
|
skillsStore.fetchSkills()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -137,31 +165,35 @@ onMounted(() => {
|
||||||
|
|
||||||
.skills-tab__item-header {
|
.skills-tab__item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-tab__item-icon {
|
.skills-tab__item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--accent-team);
|
color: var(--accent-team);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-tab__item--agent_template .skills-tab__item-icon {
|
.skills-tab__item--agent_template .skills-tab__item-icon {
|
||||||
color: var(--accent-board);
|
color: var(--accent-board);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-tab__item-type {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skills-tab__item-name {
|
.skills-tab__item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-tab__item-status {
|
.skills-tab__item-status {
|
||||||
margin-left: auto;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-tab__item-desc {
|
.skills-tab__item-desc {
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,14 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages'
|
import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages'
|
||||||
|
|
||||||
const errorDetail = ref('Connection refused: 无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。')
|
const errorDetail = ref('连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。')
|
||||||
const retried = ref(false)
|
const retried = ref(false)
|
||||||
|
|
||||||
function handleRetry(): void {
|
function handleRetry(): void {
|
||||||
retried.value = true
|
retried.value = true
|
||||||
errorDetail.value = '正在重试…'
|
errorDetail.value = '正在重试…'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
errorDetail.value = 'Connection refused: 无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
errorDetail.value = '连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
<template #bodyCell="{ column, record }: { column: TableColumnType<ISessionInfo>; record: ISessionInfo }">
|
<template #bodyCell="{ column, record }: { column: TableColumnType<ISessionInfo>; record: ISessionInfo }">
|
||||||
<template v-if="column.key === 'device'">
|
<template v-if="column.key === 'device'">
|
||||||
<div class="active-sessions-panel__device">
|
<div class="active-sessions-panel__device">
|
||||||
<strong>{{ record.device_label || 'Unknown device' }}</strong>
|
<strong>{{ record.device_label || '未知设备' }}</strong>
|
||||||
<a-tag v-if="record.is_current" color="blue" class="active-sessions-panel__tag">
|
<a-tag v-if="record.is_current" color="blue" class="active-sessions-panel__tag">
|
||||||
当前会话
|
当前会话
|
||||||
</a-tag>
|
</a-tag>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
<a-input
|
<a-input
|
||||||
:value="nodeData.agent"
|
:value="nodeData.agent"
|
||||||
@update:value="updateField('agent', $event)"
|
@update:value="updateField('agent', $event)"
|
||||||
placeholder="default"
|
placeholder="默认"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="超时时间(秒)">
|
<a-form-item label="超时时间(秒)">
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,14 @@ const routes: RouteRecordRaw[] = [
|
||||||
meta: { title: 'Computer Use' },
|
meta: { title: 'Computer Use' },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Bitable 多维表格 (独立全屏视图)
|
||||||
|
{
|
||||||
|
path: '/bitable',
|
||||||
|
name: 'bitable',
|
||||||
|
component: () => import('@/views/BitableView.vue'),
|
||||||
|
meta: { title: '多维表格' },
|
||||||
|
},
|
||||||
|
|
||||||
// Admin console (U9) — AdminLayout wraps all /admin/* child routes.
|
// Admin console (U9) — AdminLayout wraps all /admin/* child routes.
|
||||||
// ``requiresAdmin`` is set on the parent so the guard checks it for
|
// ``requiresAdmin`` is set on the parent so the guard checks it for
|
||||||
// every child route (Vue Router merges parent meta into matched records).
|
// every child route (Vue Router merges parent meta into matched records).
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,6 +6,7 @@
|
||||||
:current-id="chatStore.currentConversationId"
|
:current-id="chatStore.currentConversationId"
|
||||||
@create="chatStore.createConversation"
|
@create="chatStore.createConversation"
|
||||||
@select="chatStore.selectConversation"
|
@select="chatStore.selectConversation"
|
||||||
|
@delete="chatStore.deleteConversation"
|
||||||
/>
|
/>
|
||||||
<div class="chat-view__main">
|
<div class="chat-view__main">
|
||||||
<div v-if="!chatStore.currentConversationId" class="chat-view__empty">
|
<div v-if="!chatStore.currentConversationId" class="chat-view__empty">
|
||||||
|
|
@ -86,10 +87,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<DocumentPanel
|
|
||||||
v-if="chatStore.currentConversationId"
|
|
||||||
:conversation-id="chatStore.currentConversationId"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -111,7 +108,6 @@ import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||||
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
|
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
|
||||||
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
|
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
|
||||||
import DocumentPanel from '@/components/chat/DocumentPanel.vue'
|
|
||||||
|
|
||||||
const ATypographyText = ATypography.Text
|
const ATypographyText = ATypography.Text
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
|
|
@ -178,6 +179,60 @@ class Conversation:
|
||||||
_WS_HEARTBEAT_TIMEOUT = float(os.environ.get("AGENTKIT_WS_TIMEOUT", "120"))
|
_WS_HEARTBEAT_TIMEOUT = float(os.environ.get("AGENTKIT_WS_TIMEOUT", "120"))
|
||||||
_conversation_store = SqliteConversationStore(db_path=_CONVERSATIONS_DB_PATH)
|
_conversation_store = SqliteConversationStore(db_path=_CONVERSATIONS_DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Active portal WebSocket connections by user_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PortalConnectionManager:
|
||||||
|
"""Track active portal WebSocket connections by authenticated user_id.
|
||||||
|
|
||||||
|
Used by the calendar reminder scheduler (and other user-scoped push
|
||||||
|
features) to deliver real-time messages to a user's open chat tab(s).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# user_id -> list of active WebSocket connections
|
||||||
|
self._connections: dict[str, list[WebSocket]] = {}
|
||||||
|
|
||||||
|
def add(self, user_id: str, ws: WebSocket) -> None:
|
||||||
|
self._connections.setdefault(user_id, []).append(ws)
|
||||||
|
|
||||||
|
def remove(self, user_id: str, ws: WebSocket) -> None:
|
||||||
|
conns = self._connections.get(user_id)
|
||||||
|
if conns is None:
|
||||||
|
return
|
||||||
|
self._connections[user_id] = [w for w in conns if w is not ws]
|
||||||
|
if not self._connections[user_id]:
|
||||||
|
del self._connections[user_id]
|
||||||
|
|
||||||
|
async def send_json(self, user_id: str, message: dict[str, Any]) -> None:
|
||||||
|
"""Broadcast a JSON message to all connections for *user_id*.
|
||||||
|
|
||||||
|
Removes stale connections that fail to send.
|
||||||
|
"""
|
||||||
|
conns = list(self._connections.get(user_id, []))
|
||||||
|
if not conns:
|
||||||
|
return
|
||||||
|
stale: list[WebSocket] = []
|
||||||
|
for ws in conns:
|
||||||
|
try:
|
||||||
|
await ws.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
stale.append(ws)
|
||||||
|
for ws in stale:
|
||||||
|
self.remove(user_id, ws)
|
||||||
|
|
||||||
|
|
||||||
|
portal_connection_manager = PortalConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
async def send_to_user(user_id: str, message: dict[str, Any]) -> None:
|
||||||
|
"""Public helper to push a message to all portal WebSockets for a user."""
|
||||||
|
await portal_connection_manager.send_json(user_id, message)
|
||||||
|
|
||||||
|
|
||||||
# P1 #9 fix: ReAct event type -> TurnEventType mapping for EQ subscribers.
|
# P1 #9 fix: ReAct event type -> TurnEventType mapping for EQ subscribers.
|
||||||
# Preserves the original EQ contract so CLI and other subscribers that
|
# Preserves the original EQ contract so CLI and other subscribers that
|
||||||
# filter on TurnEventType constants (e.g. 'turn.thinking') keep working.
|
# filter on TurnEventType constants (e.g. 'turn.thinking') keep working.
|
||||||
|
|
@ -669,9 +724,7 @@ async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_
|
||||||
# Re-derive title from the persisted user message so cache misses
|
# Re-derive title from the persisted user message so cache misses
|
||||||
# after a restart don't surface the default placeholder.
|
# after a restart don't surface the default placeholder.
|
||||||
first_user = await _conversation_store.get_first_user_message(c.id)
|
first_user = await _conversation_store.get_first_user_message(c.id)
|
||||||
title = _derive_conversation_title_from_content(
|
title = _derive_conversation_title_from_content(first_user.content if first_user else None)
|
||||||
first_user.content if first_user else None
|
|
||||||
)
|
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
|
|
@ -736,6 +789,15 @@ async def get_conversation(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/portal/conversations/{conversation_id}")
|
||||||
|
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)):
|
||||||
|
"""Delete a conversation and all its messages."""
|
||||||
|
deleted = await _conversation_store.delete_conversation(conversation_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found")
|
||||||
|
return {"deleted": True, "id": conversation_id}
|
||||||
|
|
||||||
|
|
||||||
def _derive_title_from_messages(messages: list) -> str:
|
def _derive_title_from_messages(messages: list) -> str:
|
||||||
"""Derive title from a list of Message objects (SessionManager format)."""
|
"""Derive title from a list of Message objects (SessionManager format)."""
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
|
|
@ -931,6 +993,13 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
await websocket.close(code=4001, reason="Invalid or missing api_key")
|
await websocket.close(code=4001, reason="Invalid or missing api_key")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Track authenticated portal connections for user-scoped push (calendar
|
||||||
|
# reminders, etc.). user_id is None for API-key / dev-mode clients.
|
||||||
|
current_user = getattr(websocket.state, "current_user", None) or {}
|
||||||
|
ws_user_id: str | None = current_user.get("user_id")
|
||||||
|
if ws_user_id:
|
||||||
|
portal_connection_manager.add(ws_user_id, websocket)
|
||||||
|
|
||||||
# Wait for first chat message before creating conversation
|
# Wait for first chat message before creating conversation
|
||||||
conv: Conversation | None = None
|
conv: Conversation | None = None
|
||||||
# task_id is per-user-message; tracked here so the outer except can emit task.failed
|
# task_id is per-user-message; tracked here so the outer except can emit task.failed
|
||||||
|
|
@ -1658,3 +1727,7 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
|
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
# Remove from user-scoped push tracking on any disconnect/error/return.
|
||||||
|
if ws_user_id:
|
||||||
|
portal_connection_manager.remove(ws_user_id, websocket)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from agentkit.calendar.models import ReminderRule
|
||||||
from agentkit.calendar.service import CalendarService
|
from agentkit.calendar.service import CalendarService
|
||||||
from agentkit.tools.base import Tool
|
from agentkit.tools.base import Tool
|
||||||
|
|
||||||
|
|
@ -27,7 +28,8 @@ class CalendarTool(Tool):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name="calendar",
|
name="calendar",
|
||||||
description=(
|
description=(
|
||||||
"Create, query, update, and delete calendar events. "
|
"Create, query, update, and delete calendar events and reminders. "
|
||||||
|
"Use create_event to schedule events and set reminders (e.g. 'remind me Monday morning'). "
|
||||||
"Actions: create_event, query_events, update_event, delete_event."
|
"Actions: create_event, query_events, update_event, delete_event."
|
||||||
),
|
),
|
||||||
input_schema={
|
input_schema={
|
||||||
|
|
@ -92,6 +94,15 @@ class CalendarTool(Tool):
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Conversation ID to associate with the event (create_event).",
|
"description": "Conversation ID to associate with the event (create_event).",
|
||||||
},
|
},
|
||||||
|
"reminder_offset_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Minutes before event start to fire a reminder. Negative means before, e.g. -15 = 15 min before (create_event).",
|
||||||
|
},
|
||||||
|
"reminder_channels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Channels for the reminder, e.g. [\"client\"] (create_event).",
|
||||||
|
},
|
||||||
"start_date": {
|
"start_date": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Range start, ISO 8601 UTC (query_events).",
|
"description": "Range start, ISO 8601 UTC (query_events).",
|
||||||
|
|
@ -147,6 +158,33 @@ class CalendarTool(Tool):
|
||||||
is_all_day = kwargs.get("is_all_day", False)
|
is_all_day = kwargs.get("is_all_day", False)
|
||||||
rrule = kwargs.get("rrule")
|
rrule = kwargs.get("rrule")
|
||||||
conversation_id = kwargs.get("conversation_id")
|
conversation_id = kwargs.get("conversation_id")
|
||||||
|
reminder_offset = kwargs.get("reminder_offset_minutes")
|
||||||
|
reminder_channels = kwargs.get("reminder_channels") or ["client"]
|
||||||
|
|
||||||
|
# Build explicit reminder rules if requested
|
||||||
|
reminder_rules: list[ReminderRule] | None = None
|
||||||
|
if reminder_offset is not None:
|
||||||
|
try:
|
||||||
|
offset_int = int(reminder_offset)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"reminder_offset_minutes must be an integer, got {reminder_offset!r}",
|
||||||
|
}
|
||||||
|
if offset_int < 0 or offset_int > 43200: # 最多 30 天
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"reminder_offset_minutes must be in [0, 43200], got {offset_int}",
|
||||||
|
}
|
||||||
|
reminder_rules = [
|
||||||
|
ReminderRule(
|
||||||
|
id="",
|
||||||
|
event_id=None,
|
||||||
|
event_type_id=None,
|
||||||
|
offset_minutes=offset_int,
|
||||||
|
channels=list(reminder_channels),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
# Resolve event_type_name → event_type_id (look up or create)
|
# Resolve event_type_name → event_type_id (look up or create)
|
||||||
event_type_id: str | None = None
|
event_type_id: str | None = None
|
||||||
|
|
@ -174,6 +212,7 @@ class CalendarTool(Tool):
|
||||||
source="agent",
|
source="agent",
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
tag_ids=tag_ids,
|
tag_ids=tag_ids,
|
||||||
|
reminder_rules=reminder_rules,
|
||||||
)
|
)
|
||||||
return {"success": True, "event": event.to_dict()}
|
return {"success": True, "event": event.to_dict()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue