fix(calendar): 日历能力缺失修复 + UI 布局优化 + 会话404处理
P0: calendar_tool reminder_rules 未传入 create_event,提醒功能完全失效。P1: chat.ts deleteConversation 未清理 pending + 404 递归保护。P2: app.py 系统提示重复段落 + gui_mode F821 + SystemMonitorPanel flex 布局。P3: portal send_json 快照 + WS connected 清除 is_local + 移除死代码。验证: ruff+pytest 98passed+typecheck 通过。
This commit is contained in:
parent
31c65e01b8
commit
43e9025c6d
|
|
@ -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,
|
||||
EventType,
|
||||
Invitation,
|
||||
ReminderRule,
|
||||
Tag,
|
||||
_now_iso,
|
||||
)
|
||||
|
|
@ -106,6 +107,7 @@ class CalendarService:
|
|||
is_invited: bool = False,
|
||||
conversation_id: str | None = None,
|
||||
tag_ids: list[str] | None = None,
|
||||
reminder_rules: list[ReminderRule] | None = None,
|
||||
) -> CalendarEvent:
|
||||
"""Create a calendar event with UUID, timestamps, tags, and cloned reminders."""
|
||||
_validate_iso(start_time)
|
||||
|
|
@ -159,6 +161,19 @@ class CalendarService:
|
|||
)
|
||||
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}")
|
||||
return event
|
||||
|
||||
|
|
|
|||
|
|
@ -296,6 +296,27 @@ class SqliteConversationStore:
|
|||
result.append(conv)
|
||||
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(
|
||||
self,
|
||||
max_sessions: int = 50,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ from agentkit.server.routes import (
|
|||
bitable as bitable_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.middleware import RateLimitMiddleware
|
||||
from agentkit.server.task_store import create_task_store
|
||||
|
|
@ -200,6 +200,9 @@ async def lifespan(app: FastAPI):
|
|||
"中文内容优先使用 baidu_search 工具,英文/国际内容使用 web_search。"
|
||||
"在能够搜索到真相的情况下,绝不猜测或编造答案。"
|
||||
"始终优先搜索而不是给出可能不正确的信息。\n\n"
|
||||
"日历与提醒:你可以使用 calendar 工具为用户创建日程事件和提醒。"
|
||||
"当用户说'提醒我'、'预约'、'安排'、'下周一'等时,主动调用 calendar/create_event 把事件和提醒时间记下来,"
|
||||
"而不是告诉用户你没有这个能力。\n\n"
|
||||
"技能安装:当需要查找或安装技能时,先用 skill_search 搜索确认技能名称和来源,"
|
||||
"再用 skill_install 安装。不要用 shell 执行 npm install 或 npx skills install。"
|
||||
)
|
||||
|
|
@ -272,8 +275,14 @@ async def lifespan(app: FastAPI):
|
|||
except Exception:
|
||||
logger.exception("Failed to register DocumentTool")
|
||||
|
||||
# Override system prompt with memory-injected version
|
||||
agent._system_prompt = effective_system_prompt
|
||||
# Override system prompt with memory-injected version + available tools
|
||||
# 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")
|
||||
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")
|
||||
except Exception as 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
|
||||
if not hasattr(app.state, "memory_store") or app.state.memory_store is None:
|
||||
from agentkit.memory.profile import MemoryStore
|
||||
|
|
@ -407,6 +416,7 @@ async def lifespan(app: FastAPI):
|
|||
calendar_scheduler = None
|
||||
try:
|
||||
from agentkit.calendar.db import init_calendar_db
|
||||
from agentkit.calendar.reminders import ReminderDispatcher
|
||||
from agentkit.calendar.scheduler import ReminderScheduler
|
||||
from agentkit.calendar.service import CalendarService
|
||||
from agentkit.tools.calendar_tool import CalendarTool
|
||||
|
|
@ -414,15 +424,56 @@ async def lifespan(app: FastAPI):
|
|||
await init_calendar_db()
|
||||
cal_service = CalendarService()
|
||||
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()
|
||||
app.state.calendar_scheduler = calendar_scheduler
|
||||
# Register CalendarTool so ReAct agents can create/query events.
|
||||
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")
|
||||
# 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:
|
||||
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:
|
||||
pass # Already registered
|
||||
logger.exception("Failed to register CalendarTool")
|
||||
logger.info("Calendar subsystem initialized (service + reminder scheduler)")
|
||||
except Exception:
|
||||
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.
|
||||
#
|
||||
# JWT auth is only active when AGENTKIT_JWT_SECRET is explicitly set.
|
||||
# When unset, the middleware runs in dev mode (no JWT enforcement) but
|
||||
# the auth routes still issue tokens signed with an ephemeral secret.
|
||||
jwt_secret = get_jwt_secret()
|
||||
# When unset, generate ONE ephemeral secret for the process lifetime so
|
||||
# that login (signing), whoami (verifying), and the middleware all use
|
||||
# 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] = {}
|
||||
try:
|
||||
from agentkit.server.middleware import _load_client_keys
|
||||
|
|
|
|||
|
|
@ -146,6 +146,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||
async def dispatch(self, request: Request, call_next):
|
||||
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
|
||||
if self._is_whitelisted(path):
|
||||
return await call_next(request)
|
||||
|
|
@ -171,6 +183,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||
path.startswith("/api/v1/ws")
|
||||
or path.startswith("/ws")
|
||||
or path.startswith("/api/v1/chat/ws")
|
||||
or path.startswith("/api/v1/portal/ws")
|
||||
):
|
||||
token = request.query_params.get("token")
|
||||
if token:
|
||||
|
|
|
|||
|
|
@ -66,7 +66,10 @@ declare module 'vue' {
|
|||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
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']
|
||||
BitableGrid: typeof import('./src/components/bitable/BitableGrid.vue')['default']
|
||||
BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default']
|
||||
BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.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']
|
||||
ChatSidebar: typeof import('./src/components/chat/ChatSidebar.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']
|
||||
ConditionNode: typeof import('./src/components/workflow/ConditionNode.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']
|
||||
DocumentCard: typeof import('./src/components/chat/messages/DocumentCard.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']
|
||||
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.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']
|
||||
ExpertMessage: typeof import('./src/components/chat/ExpertMessage.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']
|
||||
FilePreview: typeof import('./src/components/chat/FilePreview.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']
|
||||
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']
|
||||
KBSettings: typeof import('./src/components/kb/KBSettings.vue')['default']
|
||||
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
|
||||
ListView: typeof import('./src/components/calendar/ListView.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']
|
||||
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.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']
|
||||
RiskFlagCard: typeof import('./src/components/chat/messages/RiskFlagCard.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
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']
|
||||
Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.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']
|
||||
SkillCard: typeof import('./src/components/skills/SkillCard.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']
|
||||
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.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']
|
||||
TeamPlanCard: typeof import('./src/components/chat/messages/TeamPlanCard.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']
|
||||
UserBubble: typeof import('./src/components/chat/messages/UserBubble.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']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,35 @@ export interface IApiError {
|
|||
*/
|
||||
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 = ''
|
||||
|
||||
/** 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> {
|
||||
const effectiveUrl = this._resolveUrl(path)
|
||||
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). */
|
||||
|
|
@ -248,13 +281,17 @@ export class BaseApiClient {
|
|||
if (!(options.body instanceof FormData)) {
|
||||
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> {
|
||||
const error: IApiError = {
|
||||
status: response.status,
|
||||
message: response.statusText,
|
||||
message: HTTP_STATUS_ZH[response.status] ?? response.statusText,
|
||||
}
|
||||
try {
|
||||
const body = await response.json()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ class ApiClient extends BaseApiClient {
|
|||
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) */
|
||||
async getTask(taskId: string): Promise<ITaskRecord> {
|
||||
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
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
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')
|
||||
return tauriInvoke<T>(cmd, args)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ export interface IConversation {
|
|||
messages: IChatMessage[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** 仅本地存在、尚未同步到服务端的临时会话 */
|
||||
is_local?: boolean
|
||||
}
|
||||
|
||||
/** Capability info */
|
||||
|
|
|
|||
|
|
@ -103,7 +103,17 @@ function onEdit(event: ICalendarEvent): void {
|
|||
|
||||
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
||||
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 {
|
||||
|
|
@ -116,6 +126,9 @@ function onEdit(event: ICalendarEvent): void {
|
|||
|
||||
.calendar-drawer__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--border-color-split);
|
||||
padding-top: var(--space-3);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
<template>
|
||||
<div class="calendar-grid">
|
||||
<FullCalendar :options="calendarOptions" />
|
||||
<div ref="gridRef" class="calendar-grid">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import zhCnLocale from '@fullcalendar/core/locales/zh-cn'
|
||||
import type {
|
||||
CalendarOptions,
|
||||
EventInput,
|
||||
|
|
@ -22,6 +23,11 @@ import type { ICalendarEvent } from '@/api/calendar'
|
|||
|
||||
const store = useCalendarStore()
|
||||
|
||||
const gridRef = ref<HTMLElement>()
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar>>()
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create', start?: Date, end?: Date): void
|
||||
(e: 'edit', event: ICalendarEvent): void
|
||||
|
|
@ -60,14 +66,36 @@ function handleEventClick(arg: EventClickArg): void {
|
|||
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>(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
locale: zhCnLocale,
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
},
|
||||
buttonText: {
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周',
|
||||
day: '日',
|
||||
},
|
||||
selectable: true,
|
||||
editable: true,
|
||||
height: '100%',
|
||||
|
|
@ -80,7 +108,8 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||
|
||||
<style scoped>
|
||||
.calendar-grid {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,8 +179,14 @@ async function loadConfigs(): Promise<void> {
|
|||
try {
|
||||
const resp = await calendarApi.listExternalConfigs()
|
||||
configs.value = resp.configs || []
|
||||
} catch (err) {
|
||||
notification.error({ message: '加载外部日历配置失败' })
|
||||
} catch (err: unknown) {
|
||||
// 后端尚未注册 /external-configs 端点;404 时静默返回空列表
|
||||
const status = (err as { status?: number }).status
|
||||
if (status === 404 || status === 503) {
|
||||
configs.value = []
|
||||
} else {
|
||||
notification.error({ message: '加载外部日历配置失败' })
|
||||
}
|
||||
console.warn('listExternalConfigs failed:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@
|
|||
<MessageOutlined class="chat-sidebar__item-icon" />
|
||||
<span class="chat-sidebar__item-title">{{ conv.title }}</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>
|
||||
|
|
@ -31,8 +46,14 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Button as AButton, Empty as AEmpty } from 'ant-design-vue'
|
||||
import { PlusOutlined, MessageOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { Button as AButton, Empty as AEmpty, Popconfirm as APopconfirm } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
MessageOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { IConversation } from '@/api/types'
|
||||
|
||||
interface IProps {
|
||||
|
|
@ -45,6 +66,7 @@ defineProps<IProps>()
|
|||
const emit = defineEmits<{
|
||||
create: []
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
}>()
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
|
@ -57,6 +79,10 @@ function handleSelect(id: string): void {
|
|||
emit('select', id)
|
||||
}
|
||||
|
||||
function handleDelete(id: string): void {
|
||||
emit('delete', id)
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
|
|
@ -174,6 +200,31 @@ function formatRelativeTime(dateStr: string): string {
|
|||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
:title="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||
@click="toggle"
|
||||
>
|
||||
<LeftOutlined v-if="!collapsed" />
|
||||
<RightOutlined v-else />
|
||||
<RightOutlined v-if="!collapsed" />
|
||||
<LeftOutlined v-else />
|
||||
</button>
|
||||
|
||||
<div v-if="!collapsed" class="right-panel__content">
|
||||
|
|
@ -51,8 +51,7 @@ defineExpose({ toggle, collapsed })
|
|||
.right-panel__toggle {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
margin: 6px 0 0 0;
|
||||
align-self: stretch;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="splash-screen">
|
||||
<div class="splash-content">
|
||||
<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 class="splash-progress-bar">
|
||||
<div class="splash-progress-inner"></div>
|
||||
|
|
|
|||
|
|
@ -1,96 +1,106 @@
|
|||
<template>
|
||||
<div class="system-monitor">
|
||||
<div class="system-monitor__tabs" role="tablist">
|
||||
<button
|
||||
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">
|
||||
<a-spin size="small" />
|
||||
<span>加载监控数据中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="system-monitor__error">
|
||||
<WarningOutlined />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="system-monitor__body">
|
||||
<div class="system-monitor__section-title">系统概览</div>
|
||||
<div class="system-monitor__metrics">
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">服务状态</div>
|
||||
<div class="system-monitor__metric-value" :class="`status-${health.status}`">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
<div class="system-monitor__metric-delta">
|
||||
v{{ health.version || '—' }}
|
||||
</div>
|
||||
<div class="system-monitor__content">
|
||||
<div v-if="activeTab === 'monitor'" class="system-monitor__monitor">
|
||||
<div v-if="loading" class="system-monitor__loading">
|
||||
<a-spin size="small" />
|
||||
<span>加载监控数据中...</span>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">任务总数</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.tasks.total_tasks }}</div>
|
||||
<div class="system-monitor__metric-delta">
|
||||
完成 {{ metrics.tasks.completed_tasks }} / 失败 {{ metrics.tasks.failed_tasks }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">在线 Agent</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.agents.total_agents }}</div>
|
||||
<div class="system-monitor__metric-delta">已注册技能 {{ metrics.skills.total_skills }}</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">待处理任务</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.tasks.pending_tasks }}</div>
|
||||
<div class="system-monitor__metric-delta" :class="metrics.tasks.pending_tasks > 0 ? 'down' : 'up'">
|
||||
{{ metrics.tasks.pending_tasks > 0 ? '队列中有任务' : '队列空闲' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-monitor__split">
|
||||
<div class="system-monitor__column">
|
||||
<div class="system-monitor__section-title">服务状态</div>
|
||||
<div class="system-monitor__services">
|
||||
<div
|
||||
v-for="s in serviceList"
|
||||
:key="s.name"
|
||||
class="system-monitor__service"
|
||||
>
|
||||
<span class="system-monitor__service-dot" :class="s.ok ? 'ok' : 'error'" />
|
||||
<span class="system-monitor__service-name">{{ s.name }}</span>
|
||||
<span class="system-monitor__service-status">{{ s.ok ? '正常' : '异常' }}</span>
|
||||
<span class="system-monitor__service-time">{{ s.detail }}</span>
|
||||
<div v-else-if="error" class="system-monitor__error">
|
||||
<WarningOutlined />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="system-monitor__body">
|
||||
<div class="system-monitor__section-title">系统概览</div>
|
||||
<div class="system-monitor__metrics">
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">服务状态</div>
|
||||
<div class="system-monitor__metric-value" :class="`status-${health.status}`">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
<div class="system-monitor__metric-delta">
|
||||
v{{ health.version || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">任务总数</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.tasks.total_tasks }}</div>
|
||||
<div class="system-monitor__metric-delta">
|
||||
完成 {{ metrics.tasks.completed_tasks }} / 失败 {{ metrics.tasks.failed_tasks }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">在线 Agent</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.agents.total_agents }}</div>
|
||||
<div class="system-monitor__metric-delta">已注册技能 {{ metrics.skills.total_skills }}</div>
|
||||
</div>
|
||||
<div class="system-monitor__metric">
|
||||
<div class="system-monitor__metric-label">待处理任务</div>
|
||||
<div class="system-monitor__metric-value">{{ metrics.tasks.pending_tasks }}</div>
|
||||
<div class="system-monitor__metric-delta" :class="metrics.tasks.pending_tasks > 0 ? 'down' : 'up'">
|
||||
{{ metrics.tasks.pending_tasks > 0 ? '队列中有任务' : '队列空闲' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-monitor__column">
|
||||
<div class="system-monitor__section-title">错误趋势(7天)</div>
|
||||
<div class="system-monitor__chart-placeholder">
|
||||
<LineChartOutlined />
|
||||
<span>历史趋势图表需接入日志/指标存储</span>
|
||||
<div class="system-monitor__split">
|
||||
<div class="system-monitor__column">
|
||||
<div class="system-monitor__section-title">服务状态</div>
|
||||
<div class="system-monitor__services">
|
||||
<div
|
||||
v-for="s in serviceList"
|
||||
:key="s.name"
|
||||
class="system-monitor__service"
|
||||
>
|
||||
<span class="system-monitor__service-dot" :class="s.ok ? 'ok' : 'error'" />
|
||||
<span class="system-monitor__service-name">{{ s.name }}</span>
|
||||
<span class="system-monitor__service-status">{{ s.ok ? '正常' : '异常' }}</span>
|
||||
<span class="system-monitor__service-time">{{ s.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-monitor__column">
|
||||
<div class="system-monitor__section-title">错误趋势(7天)</div>
|
||||
<div class="system-monitor__chart-placeholder">
|
||||
<LineChartOutlined />
|
||||
<span>历史趋势图表需接入日志/指标存储</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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'" />
|
||||
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
||||
</div>
|
||||
|
||||
<SkillsTab v-else-if="activeTab === 'skills'" />
|
||||
<SystemTab v-else-if="activeTab === 'system'" />
|
||||
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
||||
<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>
|
||||
|
||||
|
|
@ -101,8 +111,11 @@ import { Spin as ASpin } from 'ant-design-vue'
|
|||
import {
|
||||
DashboardOutlined,
|
||||
AppstoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
FolderOpenOutlined,
|
||||
WarningOutlined,
|
||||
LineChartOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
|
|
@ -115,6 +128,8 @@ import {
|
|||
import SkillsTab from './tabs/SkillsTab.vue'
|
||||
import SystemTab from './tabs/SystemTab.vue'
|
||||
import KnowledgeTab from './tabs/KnowledgeTab.vue'
|
||||
import CalendarTab from './tabs/CalendarTab.vue'
|
||||
import DocumentsTab from './tabs/DocumentsTab.vue'
|
||||
|
||||
interface Tab {
|
||||
key: string
|
||||
|
|
@ -124,7 +139,10 @@ interface Tab {
|
|||
|
||||
const tabs: Tab[] = [
|
||||
{ key: 'monitor', label: '监控', icon: DashboardOutlined as Component },
|
||||
{ key: 'engines', label: '引擎', icon: ThunderboltOutlined 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: 'knowledge', label: '知识库', icon: BookOutlined as Component },
|
||||
]
|
||||
|
|
@ -223,49 +241,74 @@ onUnmounted(() => {
|
|||
<style scoped>
|
||||
.system-monitor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3) var(--space-1);
|
||||
border-left: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.system-monitor__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.system-monitor__tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.system-monitor__tab--active {
|
||||
color: var(--accent-team);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-bottom-color: var(--accent-team);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.system-monitor__tab-icon {
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.system-monitor__loading,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@
|
|||
:text="wsConnected ? '已连接' : '未连接'"
|
||||
/>
|
||||
</div>
|
||||
<a-tooltip title="多维表格">
|
||||
<button class="top-nav__icon-btn" @click="router.push('/bitable')">
|
||||
<TableOutlined />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
|
||||
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
|
||||
<BulbOutlined v-if="isDark" />
|
||||
|
|
@ -56,6 +61,7 @@ import {
|
|||
MenuUnfoldOutlined,
|
||||
BulbOutlined,
|
||||
TeamOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
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 v-if="skillsStore.isLoading" class="skills-tab__loading">
|
||||
<a-spin size="small" />
|
||||
<span>加载技能列表...</span>
|
||||
<span>加载{{ title }}...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="skillsStore.error" class="skills-tab__error">
|
||||
|
|
@ -10,14 +10,14 @@
|
|||
<span>{{ skillsStore.error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="skillsStore.skills.length === 0" class="skills-tab__empty">
|
||||
<AppstoreOutlined />
|
||||
<span>暂无已注册技能</span>
|
||||
<div v-else-if="visibleSkills.length === 0" class="skills-tab__empty">
|
||||
<component :is="emptyIcon" />
|
||||
<span>{{ emptyText }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="skills-tab__list">
|
||||
<div
|
||||
v-for="skill in skillsStore.skills"
|
||||
v-for="skill in visibleSkills"
|
||||
:key="skill.name"
|
||||
class="skills-tab__item"
|
||||
:class="`skills-tab__item--${skill.category || 'business_skill'}`"
|
||||
|
|
@ -25,14 +25,7 @@
|
|||
>
|
||||
<div class="skills-tab__item-header">
|
||||
<component :is="iconFor(skill)" class="skills-tab__item-icon" />
|
||||
<span class="skills-tab__item-name">{{ skill.name }}</span>
|
||||
<a-tag
|
||||
class="skills-tab__item-type"
|
||||
:color="isEngine(skill) ? 'purple' : 'blue'"
|
||||
:title="isEngine(skill) ? '执行引擎模板' : '业务领域技能'"
|
||||
>
|
||||
{{ isEngine(skill) ? '引擎' : '技能' }}
|
||||
</a-tag>
|
||||
<span class="skills-tab__item-name" :title="skill.name">{{ skill.name }}</span>
|
||||
<a-badge
|
||||
class="skills-tab__item-status"
|
||||
:status="skill.status === 'active' ? 'success' : 'default'"
|
||||
|
|
@ -41,7 +34,7 @@
|
|||
</div>
|
||||
<p class="skills-tab__item-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
<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 }}
|
||||
</a-tag>
|
||||
<span v-if="skill.capabilities.length > 3" class="skills-tab__item-more">
|
||||
|
|
@ -60,18 +53,51 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { AppstoreOutlined, ThunderboltOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useSkillsStore } from '@/stores/skills'
|
||||
import type { ISkillInfo } from '@/api/skills'
|
||||
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 detailVisible = ref(false)
|
||||
|
||||
function isEngine(skill: ISkillInfo): boolean {
|
||||
return skill.category === 'agent_template'
|
||||
}
|
||||
const isEngine = (skill: ISkillInfo) => 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) {
|
||||
return isEngine(skill) ? ThunderboltOutlined : AppstoreOutlined
|
||||
|
|
@ -88,7 +114,9 @@ function closeDetail(): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
skillsStore.fetchSkills()
|
||||
if (skillsStore.skills.length === 0) {
|
||||
skillsStore.fetchSkills()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -137,31 +165,35 @@ onMounted(() => {
|
|||
|
||||
.skills-tab__item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skills-tab__item-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-team);
|
||||
font-size: var(--font-sm);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.skills-tab__item--agent_template .skills-tab__item-icon {
|
||||
color: var(--accent-board);
|
||||
}
|
||||
|
||||
.skills-tab__item-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.skills-tab__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skills-tab__item-status {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skills-tab__item-desc {
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@
|
|||
import { ref } from 'vue'
|
||||
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)
|
||||
|
||||
function handleRetry(): void {
|
||||
retried.value = true
|
||||
errorDetail.value = '正在重试…'
|
||||
setTimeout(() => {
|
||||
errorDetail.value = 'Connection refused: 无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
||||
errorDetail.value = '连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<template #bodyCell="{ column, record }: { column: TableColumnType<ISessionInfo>; record: ISessionInfo }">
|
||||
<template v-if="column.key === '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>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
<a-input
|
||||
:value="nodeData.agent"
|
||||
@update:value="updateField('agent', $event)"
|
||||
placeholder="default"
|
||||
placeholder="默认"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="超时时间(秒)">
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ const routes: RouteRecordRaw[] = [
|
|||
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.
|
||||
// ``requiresAdmin`` is set on the parent so the guard checks it for
|
||||
// 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"
|
||||
@create="chatStore.createConversation"
|
||||
@select="chatStore.selectConversation"
|
||||
@delete="chatStore.deleteConversation"
|
||||
/>
|
||||
<div class="chat-view__main">
|
||||
<div v-if="!chatStore.currentConversationId" class="chat-view__empty">
|
||||
|
|
@ -86,10 +87,6 @@
|
|||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<DocumentPanel
|
||||
v-if="chatStore.currentConversationId"
|
||||
:conversation-id="chatStore.currentConversationId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -111,7 +108,6 @@ import ChatMessage from '@/components/chat/ChatMessage.vue'
|
|||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
|
||||
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
|
||||
import DocumentPanel from '@/components/chat/DocumentPanel.vue'
|
||||
|
||||
const ATypographyText = ATypography.Text
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import uuid
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -178,6 +179,60 @@ class Conversation:
|
|||
_WS_HEARTBEAT_TIMEOUT = float(os.environ.get("AGENTKIT_WS_TIMEOUT", "120"))
|
||||
_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.
|
||||
# Preserves the original EQ contract so CLI and other subscribers that
|
||||
# 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
|
||||
# after a restart don't surface the default placeholder.
|
||||
first_user = await _conversation_store.get_first_user_message(c.id)
|
||||
title = _derive_conversation_title_from_content(
|
||||
first_user.content if first_user else None
|
||||
)
|
||||
title = _derive_conversation_title_from_content(first_user.content if first_user else None)
|
||||
result.append(
|
||||
{
|
||||
"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:
|
||||
"""Derive title from a list of Message objects (SessionManager format)."""
|
||||
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")
|
||||
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
|
||||
conv: Conversation | None = None
|
||||
# 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)}})
|
||||
except Exception:
|
||||
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 agentkit.calendar.models import ReminderRule
|
||||
from agentkit.calendar.service import CalendarService
|
||||
from agentkit.tools.base import Tool
|
||||
|
||||
|
|
@ -27,7 +28,8 @@ class CalendarTool(Tool):
|
|||
super().__init__(
|
||||
name="calendar",
|
||||
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."
|
||||
),
|
||||
input_schema={
|
||||
|
|
@ -92,6 +94,15 @@ class CalendarTool(Tool):
|
|||
"type": "string",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Range start, ISO 8601 UTC (query_events).",
|
||||
|
|
@ -147,6 +158,33 @@ class CalendarTool(Tool):
|
|||
is_all_day = kwargs.get("is_all_day", False)
|
||||
rrule = kwargs.get("rrule")
|
||||
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)
|
||||
event_type_id: str | None = None
|
||||
|
|
@ -174,6 +212,7 @@ class CalendarTool(Tool):
|
|||
source="agent",
|
||||
conversation_id=conversation_id,
|
||||
tag_ids=tag_ids,
|
||||
reminder_rules=reminder_rules,
|
||||
)
|
||||
return {"success": True, "event": event.to_dict()}
|
||||
except Exception as e:
|
||||
|
|
|
|||
Loading…
Reference in New Issue