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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,6 +94,8 @@ export interface IConversation {
messages: IChatMessage[]
created_at: string
updated_at: string
/** 仅本地存在、尚未同步到服务端的临时会话 */
is_local?: boolean
}
/** Capability info */

View File

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

View File

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

View File

@ -179,8 +179,14 @@ async function loadConfigs(): Promise<void> {
try {
const resp = await calendarApi.listExternalConfigs()
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: '加载外部日历配置失败' })
}
console.warn('listExternalConfigs failed:', err)
} finally {
loading.value = false

View File

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

View File

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

View File

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

View File

@ -1,22 +1,7 @@
<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 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>
@ -88,10 +73,35 @@
</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'" />
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
</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>
<script setup lang="ts">
@ -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,

View File

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

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 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(() => {
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 {

View File

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

View File

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

View File

@ -35,7 +35,7 @@
<a-input
:value="nodeData.agent"
@update:value="updateField('agent', $event)"
placeholder="default"
placeholder="默认"
/>
</a-form-item>
<a-form-item label="超时时间(秒)">

View File

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

View File

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

View File

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

View File

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