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:
chiguyong 2026-06-28 14:24:58 +08:00
parent 31c65e01b8
commit 43e9025c6d
30 changed files with 2007 additions and 971 deletions

View File

@ -0,0 +1,40 @@
{
"rules": [
{
"path": "**/*.py",
"rule": "代码评审 10 维度(项目 AGENTS.md + ponytail 规则1) 正确性 — 边界条件、异常路径、空值处理2) 安全性 — API Key 比较必须 hmac.compare_digest恒定时间JWT HS256 access 15min/refresh 7dRBAC 三级权限位,专家名必须 _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 Config4) 并发异步 — HandoffTransport 队列有界 maxsize=1024关闭用 sentinel 模式async def 含 yield 禁止在首个 yield 前用 return必须 return; yield 模式或重构),禁止阻塞调用在 async 函数中5) 性能 — N+1 查询、未加索引、未用缓存、内存泄漏、未关闭资源6) 可维护性 — 命名、重复代码、圈复杂度、函数过长7) 错误处理 — 信任边界必须校验输入,防止数据丢失,禁止裸 except8) 依赖配置 — 不得擅自改 pyproject.toml 版本,配置优先级 CLI>yaml>env>.env>默认9) 测试 — 非平凡逻辑必须留下一个可运行检查assert demo 或小测试文件,无框架无 fixtures10) 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 — 每个领域一个 storestate/getters/actions 规范禁止全局可变状态4) 禁止 require() 调用,必须 ES import5) Ant Design Vue 4 组件规范 — 按需引入a-* 组件名6) 异步处理 — async/await禁止 .then 链过深错误边界处理7) 响应式 — ref/reactive 正确使用,禁止解构 reactive 丢失响应性computed 优先于 methods8) 安全 — XSS 防护v-html 慎用用户输入转义JWT 存储位置安全9) 性能 — 懒加载路由defineAsyncComponent避免大列表未虚拟化watch 深度慎用10) 可维护性 — 命名规范camelCase 变量、PascalCase 组件),文件组织按领域,无重复代码。"
},
{
"path": "**/*.vue",
"rule": "Vue3 SFC 评审维度1) <script setup lang=\"ts\"> 必须使用,禁止 Options API2) 模板 — 单根节点Vue3 允许多根但建议单根v-for 必须 :key 唯一且非 indexv-if 与 v-for 不同级3) Props — defineProps 必带类型与 required 标注defineEmits 必带类型4) 响应式 — ref/reactive/computed 正确区分watch 与 watchEffect 按需使用避免深监听大对象5) 生命周期 — onMounted 异步需 try/catchonUnmounted 必须清理定时器、事件监听、WebSocket6) Ant Design Vue — 组件按需引入message/notification/modal API 调用规范7) 样式 — scoped 必加避免全局污染CSS 变量优先于硬编码颜色8) 安全 — v-html 必须确认来源可信,用户输入绝不直接 v-html9) 可访问性 — 表单 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 中直接操作 DOM6) 跨 store 调用必须通过 useXxxStore()禁止循环依赖7) 持久化需明确策略localStorage/sessionStorage/无8) WebSocket 连接必须在 store 中管理生命周期onUnmounted 时关闭)。"
},
{
"path": "**/auth/**/*.py",
"rule": "认证安全专项最高优先级1) API Key 比较必须 hmac.compare_digest禁止 == 比较密钥/token2) JWT — HS256access 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 模型校验请求体/查询参数,禁止裸 dict4) 响应模型 response_model 显式声明5) 异步 — async def 优先IO 密集用 awaitCPU 密集用 run_in_threadpool6) 错误处理 — HTTPException 明确 status_code 与 detail禁止裸 raise Exception7) WebSocket — 连接管理(心跳/超时/断线重连广播需并发控制8) 输入校验 — 路径参数/查询参数边界值,防止注入。"
},
{
"path": "**/calendar/**/*.py",
"rule": "日历服务专项1) 时区处理必须显式Asia/Shanghai 或 UTC禁止 naive datetime2) 重复事件 RRULE 解析正确性3) 跨时区会议时间一致性4) 飞书/Outlook 同步 — 冲突检测、增量同步、失败重试5) 数据持久化 — 事务边界回滚策略6) 并发 — 同一日历并发写入需锁7) 错误恢复 — 同步中断可续传。"
},
{
"path": "**/sqlite_conversation_store.py",
"rule": "SQLite 会话存储专项1) SQL 参数化查询(禁止 f-string/format 拼接 SQL2) 事务管理 — 显式 begin/commit/rollbackasync with3) 连接池 — Singleton 或 per-request禁止每次新建4) 并发写入 — WAL 模式或串行化5) 数据完整性 — 外键约束、NOT NULL、唯一索引6) 迁移 — schema 版本管理向后兼容7) 性能 — 索引覆盖查询,分页 LIMIT/OFFSET 或 cursor8) 错误处理 — IntegrityError 捕获并返回明确错误。"
},
{
"path": "**/middleware.py",
"rule": "中间件专项1) 执行顺序 — 认证→授权→限流→日志→业务2) 异步 — async def禁止阻塞3) 上下文传递 — request.state.user 等规范字段4) 错误 — 中间件异常必须转为 HTTP 响应禁止吞异常5) 性能 — 中间件链不宜过长热路径避免重计算6) CORS — 白名单显式,禁止 *7) 日志 — 敏感信息脱敏token/密码)。"
}
]
}

View File

@ -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 frontmattermodule/tags/problem_type 可搜索),结构清晰(标题层级 ≤4 级),有目录/索引5) 受众适配 — 区分新人 onboarding / 专家参考 / 运维部署提供前置条件与后续步骤6) 示例有效性 — 代码块必须带语言标签bash/python/ts/vue命令必须可复制运行含完整路径与参数示例输出与实际一致7) 链接完整性 — 内部链接(相对路径)指向真实存在文件,外部链接有效,无 4048) 安全合规 — 不泄露密钥/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_typebug_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.md2) 必须包含 — 背景与目标 / 现状分析 / 设计方案 / 任务拆解KTD 编号)/ 风险与回滚 / 验收标准3) 任务拆解必须有 KTD 编号、负责人、依赖关系、验收标准4) 设计方案必须含架构图或流程图Mermaid/ASCII5) 风险评估必须含回滚策略6) 引用的现有代码/接口必须真实存在7) 验收标准必须可量化(性能指标/测试覆盖/功能点8) 中文撰写。"
},
{
"path": "**/AGENTS.md",
"rule": "项目上下文文档专项AGENTS.md1) 规则部分必须可执行每条规则对应代码可验证点2) 技术栈版本必须与 pyproject.toml/package.json 一致3) 命令必须可复制运行含正确路径与参数4) 架构描述必须与当前代码结构一致模块映射表、路由表、Agent 层级、专家团队模式5) WebSocket 协议消息类型必须与代码中定义一致6) 配置优先级必须与代码实现一致7) 约定部分技能配置路径、专家模板、测试位置必须真实存在8) 边界部分必须明确禁止事项。"
},
{
"path": "**/CONCEPTS.md",
"rule": "领域词汇表专项CONCEPTS.md1) 每个术语必须包含 — 名称中英、定义、代码位置、相关术语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必须有效。"
}
]
}

View File

@ -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 布局 |

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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']
} }
} }

View File

@ -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()

View File

@ -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}`)

View File

@ -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)

View File

@ -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 */

View File

@ -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);

View File

@ -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;
} }

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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,

View File

@ -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'

View File

@ -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>

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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="超时时间(秒)">

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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: