fix: 私董会数据持久化修复 + emoji 移除计划
- 修复 board_started/expert_speech/round_summary/board_concluded 事件持久化 - 添加 is_board 标记到会话列表和详情接口 - 实现 restoreBoardStateFromMessages 从持久化消息恢复 boardState - 添加 ChatSidebar 私董会徽章 - 添加 emoji 移除计划文档 (docs/plans/2026-07-02-001)
This commit is contained in:
parent
ba0baabfcd
commit
36b0296730
|
|
@ -1,6 +1,6 @@
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8001
|
port: 18001
|
||||||
workers: 1
|
workers: 1
|
||||||
rate_limit: 60
|
rate_limit: 60
|
||||||
llm:
|
llm:
|
||||||
|
|
@ -64,9 +64,17 @@ fallback_chain:
|
||||||
# whitelist_override: # optional, merges with default (override wins)
|
# whitelist_override: # optional, merges with default (override wins)
|
||||||
# planning: [search, read_file, shell]
|
# planning: [search, read_file, shell]
|
||||||
# building: [write_file, shell, read_file]
|
# building: [write_file, shell, read_file]
|
||||||
session: {backend: memory}
|
# G10/U5: Use Redis for bus / session / task_store when REDIS_URL is set.
|
||||||
bus: {backend: memory}
|
# Falls back to in-memory when REDIS_URL is unset (development fallback).
|
||||||
task_store: {backend: memory}
|
# The local dev environment runs pms-redis on 127.0.0.1:6379 (see .env.dev).
|
||||||
|
# Tests run in-memory for isolation; production / staging should use Redis.
|
||||||
|
session:
|
||||||
|
backend: ${AGENTKIT_SESSION_BACKEND:-memory}
|
||||||
|
bus:
|
||||||
|
backend: ${AGENTKIT_BUS_BACKEND:-memory}
|
||||||
|
redis_url: ${REDIS_URL:-redis://127.0.0.1:6379/0}
|
||||||
|
task_store:
|
||||||
|
backend: ${AGENTKIT_TASK_STORE_BACKEND:-memory}
|
||||||
skills: {auto_discover: true, paths: ["./configs/skills"]}
|
skills: {auto_discover: true, paths: ["./configs/skills"]}
|
||||||
experts: {paths: ["./configs/experts"]}
|
experts: {paths: ["./configs/experts"]}
|
||||||
board: {max_rounds: 5, default_template: private_board, parallel_speech: true, history_compression_threshold: 20}
|
board: {max_rounds: 5, default_template: private_board, parallel_speech: true, history_compression_threshold: 20}
|
||||||
|
|
|
||||||
|
|
@ -200,8 +200,7 @@ llm:
|
||||||
|
|
||||||
tools:
|
tools:
|
||||||
- shell
|
- shell
|
||||||
- file_read
|
- read_file
|
||||||
- file_write
|
|
||||||
|
|
||||||
quality_gate:
|
quality_gate:
|
||||||
required_fields: ["content"]
|
required_fields: ["content"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
---
|
||||||
|
title: "refactor: Remove all emoji from agentkit"
|
||||||
|
date: 2026-07-02
|
||||||
|
type: refactor
|
||||||
|
status: approved
|
||||||
|
approved_at: 2026-07-02
|
||||||
|
origin: user-direct (ce-plan invocation, 2026-07-02)
|
||||||
|
---
|
||||||
|
|
||||||
|
# refactor: Remove all emoji from agentkit
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`@board` 私董会数据恢复的修复已就位。但项目长期混用 emoji(专家头像、CLI 状态、私董会横幅、bitable 默认图标等),与既有的 Ant Design Vue Outlined 图标家族视觉风格不一致,且跨字体/OS 渲染不稳定。本次重构一次性把全部 emoji 收敛到三套等价替代:
|
||||||
|
|
||||||
|
- **头像/单字符位** → 统一走 `expertIdentity.ts` 风格的「CJK/ASCII 首字符大写」;YAML/DB 中仍是字符串字段,前端渲染照常。
|
||||||
|
- **横幅/卡片图标** → 改用 Ant Design Vue 组件(`BankOutlined`/`AuditOutlined`/`TeamOutlined` 等)。
|
||||||
|
- **CLI 状态标记** → Rich 文本标签(`OK`/`FAIL`/`WARN`) + Rich 颜色样式。
|
||||||
|
|
||||||
|
执行顺序按 4 个批次分阶段落地,每批可独立提交/回滚。
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
全仓 22 个文件含 emoji 字符,覆盖 7 个语义类别(专家头像、bitable 默认、私董会横幅、辩论横幅、CLI 状态、终端提示、DB 默认值)。其中:
|
||||||
|
|
||||||
|
- `configs/experts/*.yaml` 15 个文件使用 emoji 作为 avatar
|
||||||
|
- `bitable/db.py` 用 `📋` 作 `icon` 字段的 DB default(与 `models.py:80` 已有 `"table"` 默认不一致)
|
||||||
|
- `chat.py` 把 `🏛️ 私董会开始:…` 写入 `SqliteConversationStore`,作为无障碍/纯文本回退
|
||||||
|
- `BoardBannerCard.vue`、`DebateBannerCard.vue`、`DebateConclusionCard.vue`、`UserBubble.vue` 直接渲染 emoji
|
||||||
|
- 4 个 CLI 文件用 `✓` `✗` `⚠` 作状态标记
|
||||||
|
- 3 个测试文件把 emoji 作为 fixture
|
||||||
|
|
||||||
|
### 触发原因
|
||||||
|
|
||||||
|
- 跨平台渲染不稳定:Linux 服务器无 Noto Color Emoji 时私董会横幅显示豆腐方块
|
||||||
|
- 视觉风格不一致:与项目其余 Ant Design Vue Outlined 图标家族(1.5px stroke)不协调
|
||||||
|
- DB schema 漂移:`bitable/models.py:80` 已迁到稳定 key `"table"`,但 `bitable/db.py:68,216` ORM/SQL 仍用 `📋`
|
||||||
|
|
||||||
|
### 期望效果
|
||||||
|
|
||||||
|
- 全部 emoji 字符从仓库代码中清除(注释、文档除外)
|
||||||
|
- 视觉风格统一为 Ant Design Vue Outlined + 字符首字母
|
||||||
|
- 旧 Bitable 行的 emoji 值通过前端 `resolveBitableIcon` 惰性回退到 `TableOutlined`,无需迁移
|
||||||
|
- CLI 输出在所有终端(包括无 emoji 字体)下稳定显示
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
### KTD1:专家头像改为「首字符大写」策略,与 `expertIdentity.ts` 行为对齐
|
||||||
|
|
||||||
|
**决策**:YAML 中 `avatar` 字段从 emoji 改为对应专家名的首字符大写(中文取首个 CJK 字,英文取首字母大写)。保留 `IBoardExpert.avatar` 字段(不删),值由后端 `ExpertConfig` 解析或前端 `pickExpertIdentity` 兜底。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `frontend/src/components/chat/helpers/expertIdentity.ts:55` 的 `pickExpertIdentity` 默认就是首字符策略
|
||||||
|
- 删除字段会破坏既有 WebSocket payload(`board_started.experts[].avatar`)契约
|
||||||
|
- 字符位渲染跨字体稳定
|
||||||
|
|
||||||
|
**取舍**:放弃了 emoji 头像的「人格化表达」,换取视觉一致性与跨平台稳定。Lead/主持人仍可通过 `color` token + tag 区分。
|
||||||
|
|
||||||
|
### KTD2:横幅/卡片图标改用 Ant Design Vue 组件,shell.avatar 改为 `Component` 类型
|
||||||
|
|
||||||
|
**决策**:`BoardBannerCard` / `DebateBannerCard` / `DebateConclusionCard` / `UserBubble` / `useMessageRenderer.ts` 引入 Ant Design Vue 组件(如 `<BankOutlined>`/`<AuditOutlined>`/`<TeamOutlined>`/`<CheckOutlined>`),把 shell.avatar 字段从 `string` 改为 `Component` 类型。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 项目图标家族已统一为 Outlined(`Sidebar`、`Tabs`、`Tool call cards`)
|
||||||
|
- `useMessageRenderer.ts` 现有 `shell.avatar` 字段在 `MessageShell.vue` 渲染,与后端 `expert_avatar` 字段不冲突(那是单个专家的 avatar)
|
||||||
|
|
||||||
|
**影响**:`MessageShell.vue` 接收 shell 的 avatar 字段如果是 `Component` 类型则用 `<component :is="..." />`;如果是 `string`(首字母)则原样渲染。
|
||||||
|
|
||||||
|
### KTD3:CLI 状态标记用 Rich 文本标签 + 颜色,弃用 `✓` `✗` `⚠`
|
||||||
|
|
||||||
|
**决策**:`OK` / `FAIL` / `WARN` 作为文本标签,配合 Rich `[green]` / `[red]` / `[yellow]` 颜色。横幅标题用 `[OK 验收结果]` / `[WARN 风险标记]` 形式保留 emoji 缺失的视觉强度。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 终端字体差异:Windows Terminal / 容器 tty / 旧版 macOS Terminal 对 Unicode 符号渲染不一致
|
||||||
|
- 颜色已能传达同样信息量
|
||||||
|
- 与 Python `rich` 库的最佳实践一致(标签 + 颜色)
|
||||||
|
|
||||||
|
### KTD4:Bitable DB 默认值从 `📋` 改为 `table`,旧数据惰性收敛
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- `bitable/db.py:68` ORM `default="📋"` → `default="table"`
|
||||||
|
- `bitable/db.py:216` SQL `DEFAULT '📋'` → `DEFAULT 'table'`
|
||||||
|
- **不写迁移脚本** — 前端 `resolveBitableIcon` 已对未知值回退到 `TableOutlined`,下次访问旧行时自动收敛
|
||||||
|
- 新建行直接用 `table`;旧行 render 时也是 `TableOutlined`,零差异
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `bitable/models.py:80`(Pydantic)已经是 `"table"`,ORM 端对齐消除双默认值
|
||||||
|
- 避免引入 Alembic 迁移;旧数据下次访问收敛
|
||||||
|
- `bitable.ts:73` 和 `bitableIcons.ts:86` 已说明「legacy emoji 字符串自动回退」
|
||||||
|
|
||||||
|
**已知限制**:旧行在 DB 中仍是 `📋` 字符串。若未来要做 DB 备份快照或导出/导入,可能需要清理脚本。本次不处理。
|
||||||
|
|
||||||
|
### KTD5:App.vue 字体回退去掉 emoji 字体声明
|
||||||
|
|
||||||
|
**决策**:`App.vue:109-110` 中的 `'Apple Color Emoji'`、`'Segoe UI Emoji'`、`'Segoe UI Symbol'`、`'Noto Sans Emoji'` 字体回退删除。
|
||||||
|
|
||||||
|
**理由**:既然不再渲染任何 emoji 字符,浏览器/系统不会下载这些字体;删去减少 30KB+ 的潜在字体下载请求。保留 `sans-serif` 作为最终回退。
|
||||||
|
|
||||||
|
### KTD6:测试 fixture 同步更新,保持断言真实
|
||||||
|
|
||||||
|
**决策**:3 个测试文件中的 emoji fixture(`🤖` `🎯` `🐱` `💡` `🦊` `🐼` `🏛️`)改为对应的首字母大写(A/T/C/I/F/P 等),断言中的 `expect(avatars[0].textContent).toBe('🤖')` 同步改为 `'A'`。
|
||||||
|
|
||||||
|
**理由**:`restoreBoardStateFromMessages` 的 7 个新单元测试已锁定「缺 avatar/缺 color 走 fallback」行为,fixture 改字母不会绕过 fallback 链(仍由 `pickExpertIdentity(name)` 决定)。
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### 包含
|
||||||
|
|
||||||
|
- 22 个源码文件中的 emoji 字符移除(不含文档注释)
|
||||||
|
- 4 批次的实施顺序与每批对应的文件清单
|
||||||
|
- DB 默认值从 emoji 改为稳定 key(无 schema migration)
|
||||||
|
- App.vue 字体回退清理
|
||||||
|
- 3 个测试文件 fixture 同步
|
||||||
|
- 验证:typecheck / pytest / vitest / 视觉冒烟
|
||||||
|
|
||||||
|
### 不包含(Deferred to Follow-Up Work)
|
||||||
|
|
||||||
|
- **emoji 自动检测**(CI 守卫 / pre-commit 钩子)— 防止新增 emoji 复发,作为后续独立计划
|
||||||
|
- **Bitable 旧行清理脚本**(DB 备份/导出场景下需要时再做)
|
||||||
|
- **`@ant-design/icons-vue` 包增量依赖** — 该包已是项目依赖(`bitableIcons.ts` 现有 import),无新增
|
||||||
|
- **CLI 国际化(i18n)** — `OK`/`FAIL`/`WARN` 目前硬编码中英混排;国际化是更大范围重构,本次不动
|
||||||
|
- **Tauri 桌面端 emoji 字体** — Tauri 客户端的 emoji 字体回退不在本计划范围
|
||||||
|
- **avatar 字段语义升级**(如改成 `avatar_url` 支持真实头像图片)— 是产品级决策,超出本次范围
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
| 受影响方 | 影响 |
|
||||||
|
| --- | --- |
|
||||||
|
| **终端用户(Web GUI)** | 私董会/辩论横幅、专家头像、Bitable 图标视觉变化;语义不变 |
|
||||||
|
| **终端用户(CLI)** | `agentkit` CLI 表格/状态标记从符号改为文本标签;颜色保留 |
|
||||||
|
| **终端用户(Tauri 桌面)** | 间接通过 Web GUI 同步 |
|
||||||
|
| **开发者** | 写新代码时不能直接用 emoji 字符;后续建议加 lint 规则 |
|
||||||
|
| **运营/部署** | 无(不涉及端口、配置、依赖增减) |
|
||||||
|
| **测试** | 3 个 vitest fixture 文件需同步更新断言;不影响测试覆盖度 |
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
视觉风格统一(KTD1+KTD2)的目标是把混用状态收敛成两条规则:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 头像/单字符位 │
|
||||||
|
│ • YAML avatar 字段 → "S"/"B"/"P" 等首字符 │
|
||||||
|
│ • 前端 expertIdentity.ts 兜底(如缺 avatar) │
|
||||||
|
│ • ExpertMessage/StickyModeHeader/BoardBannerCard │
|
||||||
|
│ 都按 string 渲染字符 │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 横幅/卡片图标 │
|
||||||
|
│ • BoardBannerCard: <BankOutlined /> │
|
||||||
|
│ • DebateBannerCard: <AuditOutlined /> │
|
||||||
|
│ • DebateConclusionCard: 4 个决策图标 = 4 个组件 │
|
||||||
|
│ • UserBubble command card: <BankOutlined /> 私董会 │
|
||||||
|
│ <TeamOutlined /> 团 │
|
||||||
|
│ • useMessageRenderer shell.avatar 改为 Component │
|
||||||
|
│ MessageShell 用 <component :is=...> 渲染 │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI 状态机收敛(KTD3):
|
||||||
|
|
||||||
|
```
|
||||||
|
旧: 新:
|
||||||
|
"✓" "✗" "⚠" "OK" "FAIL" "WARN"
|
||||||
|
[green]✓ X[/green] [bold green]OK X[/bold green]
|
||||||
|
[red]✗ X[/red] [bold red]FAIL X[/bold red]
|
||||||
|
[yellow]⚠ X[/yellow] [bold yellow]WARN X[/bold yellow]
|
||||||
|
```
|
||||||
|
|
||||||
|
DB schema 收敛(KTD4):
|
||||||
|
|
||||||
|
```
|
||||||
|
bitable/db.py:68 icon = Column(String, default="table")
|
||||||
|
bitable/db.py:216 icon VARCHAR DEFAULT 'table'
|
||||||
|
↓
|
||||||
|
旧 DB 行: '📋' → 前端 resolveBitableIcon 命中 DEFAULT_BITABLE_ICON='table' → 渲染 TableOutlined
|
||||||
|
新 DB 行: 'table' → 渲染 TableOutlined (与旧行一致)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. 数据/契约层 — avatar 字段、持久化文本、DB 默认值
|
||||||
|
|
||||||
|
**Goal**:把数据层/契约层的 emoji 全部收敛到字符串默认值或首字符。
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `configs/experts/*.yaml`(15 个文件:apple/private_board/team/tech_lead 等)— avatar 字段从 emoji 改首字符大写
|
||||||
|
- `src/agentkit/bitable/db.py:68` — ORM default `📋` → `table`
|
||||||
|
- `src/agentkit/bitable/db.py:216` — SQL `DEFAULT '📋'` → `DEFAULT 'table'`
|
||||||
|
- `src/agentkit/server/routes/chat.py:304,306` — `🏛️ 私董会开始:…` → `私董会开始:…`
|
||||||
|
- `src/agentkit/server/frontend/src/stores/chatStream.ts:1149` — `🏛️ 私董会开始:…` → `私董会开始:…`
|
||||||
|
- `src/agentkit/core/plan_schema.py:107,122` — `📋`/`⚠️` → `[Plan ...]`/`WARN`
|
||||||
|
- `src/agentkit/experts/registry.py:85` — 注释 `📊` 改为纯文字
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
- YAML avatar 字段值:英文名取首字母大写(`steve_jobs` → `S`),中文名取首 CJK 字(`张三` → `张`)
|
||||||
|
- 后端 `chat.py` 与前端 `chatStream.ts` 的 `board_started` 持久化文本保持语义一致(去 emoji 字符本身不影响 message_type/board_started metadata)
|
||||||
|
- `plan_schema.py:to_readable()` 是给人工看的报告,文本用纯文字更稳
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**:`configs/experts/steve_jobs.yaml` 加载后 `expert.avatar == "S"`
|
||||||
|
- **Happy path**:`FileModel` 新实例 `icon == "table"`
|
||||||
|
- **Happy path**:`_apply_v1_schema` 生成的表 `icon` 列默认值为 `'table'`
|
||||||
|
- **Edge case**:现有 emoji DB 行(如 `icon='📋'`)通过 `resolveBitableIcon` 仍渲染为 `TableOutlined`
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- `pytest tests/unit/experts -k "config or registry"` 通过
|
||||||
|
- `pytest tests/unit/bitable -k "schema or model"` 通过
|
||||||
|
- 启动 backend 调用 `@board`,确认 DB 中 `board_started` 消息 content 为 `私董会开始:...`(无 emoji)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U2. 前端 UI 组件 — 横幅、卡片、命令 chip
|
||||||
|
|
||||||
|
**Goal**:把 emoji 图标改用 Ant Design Vue 组件。
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue` — `🏛️` → `<BankOutlined />`
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/messages/DebateBannerCard.vue` — `⚖` → `<AuditOutlined />`
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/messages/DebateConclusionCard.vue` — `decisionIcons` map 改为 Ant Design Vue 组件(`CheckOutlined`/`SwapOutlined`/`MinusOutlined`/`QuestionOutlined`)
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/messages/UserBubble.vue:143` — `'🏛️'`/`'👥'` 字符串 → `<BankOutlined />`/`<TeamOutlined />`
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts:212,271` — `avatar: '⚖'` → `avatar: AuditOutlined`(shell.avatar 改 Component)
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/MessageShell.vue` — 检查 avatar 字段类型,Component 走 `<component :is="..." />`,string 走原样
|
||||||
|
- `src/agentkit/server/frontend/src/App.vue:109-110` — 字体回退删 emoji 字体声明
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
- 引入 `BankOutlined` / `AuditOutlined` / `TeamOutlined` / `CheckOutlined` / `SwapOutlined` / `MinusOutlined` / `QuestionOutlined`(均已在 `@ant-design/icons-vue` 中,无需新装)
|
||||||
|
- `BoardBannerCard` / `DebateBannerCard` 的 `<span class="...__icon">X</span>` 改为 `<component :is="X" class="...__icon" />`
|
||||||
|
- `UserBubble` 的 command card 改造:`icon` prop 改为 `Component` 类型
|
||||||
|
- `useMessageRenderer` 中 `shell.avatar` 类型由 `string` 改为 `Component`(仅这两处),其余 shell 仍用 string(首字母)
|
||||||
|
- `MessageShell.vue` 用 `v-if="typeof shell.avatar === 'string'"` 分支处理
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**:`BoardBannerCard` 渲染时包含 `BankOutlined` SVG path
|
||||||
|
- **Happy path**:`DebateBannerCard` 渲染时包含 `AuditOutlined` SVG path
|
||||||
|
- **Happy path**:`DebateConclusionCard decision="adopt"` 渲染 `CheckOutlined`
|
||||||
|
- **Happy path**:`UserBubble` 解析 `@board:alice, bob` 时 command icon 为 `BankOutlined`
|
||||||
|
- **Edge case**:`shell.avatar` 是 string(如 `expert_initial`)时仍原样渲染,不被当作 component
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- `npm run typecheck` 通过
|
||||||
|
- `npx vitest run tests/unit/components/` 通过
|
||||||
|
- 浏览器打开 `/agent/chat` 触发 `@board`,确认横幅显示 `<BankOutlined />` 矢量图标(非 emoji)
|
||||||
|
- 触发辩论确认 `DebateBannerCard` 显示 `<AuditOutlined />`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U3. CLI 控制台输出 — admin / chat / skill / benchmark
|
||||||
|
|
||||||
|
**Goal**:把 Rich 输出中的 `✓` `✗` `⚠` 改为 `OK`/`FAIL`/`WARN` 文本标签 + Rich 颜色。
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/agentkit/cli/admin.py:215,601` — 表格 `is_active` 列的 `✓`/`✗` → `OK`/`--`
|
||||||
|
- `src/agentkit/cli/chat.py:589,603,692,694,727,730` — 验收/阶段/风险标记全部收敛
|
||||||
|
- `src/agentkit/cli/skill.py:296` — `⚠ 以下为自动生成…` → `WARN 以下为自动生成…`
|
||||||
|
- `src/agentkit/cli/benchmark.py:895,989,2032,2733,2767,2784,2790` — 所有 `✓`/`✗`/`⚠` 收敛
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
- 字符串替换策略:所有 `[green]✓ X[/green]` 改 `[bold green]OK X[/bold green]`;`[red]✗ X[/red]` 改 `[bold red]FAIL X[/red]`;`[yellow]⚠ X[/yellow]` 改 `[bold yellow]WARN X[/yellow]`
|
||||||
|
- 表格列中的 `✓`/`✗` 用 `OK`/`--`(失败用 `--` 而非 `FAIL` 因为列宽有限)
|
||||||
|
- 横幅标题的 `⚠ 风险标记` 改 `WARN 风险标记`(保留全部大写作为视觉强度替代)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**:`agentkit admin list-users` 输出包含 `OK` 而非 `✓`
|
||||||
|
- **Happy path**:`agentkit chat` 触发的 phase completion 打印 `[OK] 阶段名:summary`
|
||||||
|
- **Error path**:phase 失败时打印 `[FAIL] 阶段名:error`
|
||||||
|
- **Integration**:`agentkit benchmark` 在失败用例上打印 `[FAIL]`(无 emoji)
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- `pytest tests/unit/cli -k "admin or chat or skill or benchmark"` 通过
|
||||||
|
- 手动跑 `agentkit admin list-users` / `agentkit chat "test"` / `agentkit benchmark list` 确认输出无 emoji
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U4. 终端前端 + 测试 fixture + 字体回退
|
||||||
|
|
||||||
|
**Goal**:清理剩余的终端提示、测试 fixture、字体回退。
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/agentkit/server/frontend/src/stores/terminal.ts:251,262,266,270,275` — `⚠` `⏳` `✓` `✗` → `WARN` `PENDING` `OK` `REJECTED`/`TIMEOUT`
|
||||||
|
- `src/agentkit/server/frontend/src/components/terminal/CommandHistory.vue:20` — `✓`/`✗` → `OK`/`FAIL`
|
||||||
|
- `src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts:731` — `avatar: '🦊'` → `avatar: 'F'`
|
||||||
|
- `src/agentkit/server/frontend/tests/unit/stores/chatStore.test.ts:22` — `content: '🏛️ 私董会开始:…'` → `content: '私董会开始:…'`
|
||||||
|
- `src/agentkit/server/frontend/tests/unit/stores/chatStore.test.ts:33,40` — `avatar: '🦊'`/`'🐼'` → `avatar: 'F'`/`'P'`
|
||||||
|
- `src/agentkit/server/frontend/tests/unit/components/StickyModeHeader.test.ts` — 所有 emoji fixture 改首字母
|
||||||
|
- `src/agentkit/server/frontend/src/components/chat/messages/TeamPlanCard.vue:67,82,127` — 注释更新(已说明弃用,保留为历史说明即可,无运行影响)
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
- 终端消息用 `[WARN]` / `[PENDING]` / `[OK]` / `[REJECTED]` / `[TIMEOUT]` 文本格式(与 CLI KTD3 保持一致)
|
||||||
|
- 测试 fixture 改为对应名称首字母(`StickyModeHeader.test.ts` 中 `avatar: '🤖'` → `avatar: 'A'`,`expect(avatars[0].textContent).toBe('A')`)
|
||||||
|
- `chatStore.test.ts` 的 `board_started` content 改 `私董会开始:AI 未来`(与 U1 后端持久化文本一致)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**:`CommandHistory` 渲染 exit_code=0 时显示 `OK`、非 0 时显示 `FAIL`
|
||||||
|
- **Happy path**:`StickyModeHeader.test.ts` 中 `expect(avatars[0].textContent).toBe('A')` 通过
|
||||||
|
- **Happy path**:`chatStore.test.ts` `restoreBoardStateFromMessages` 测试对新的非-emoji content 仍正确解析
|
||||||
|
- **Edge case**:终端 store 中 `appendOutput` 的 ANSI 颜色(`\x1b[33m`)保留,仅替换前缀文本
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- `npm run typecheck` 通过
|
||||||
|
- `npx vitest run tests/unit/` 全通过
|
||||||
|
- 终端面板审批流程人工冒烟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U5. 端到端验证 + 防止复发(lint 规则 + 文档)
|
||||||
|
|
||||||
|
**Goal**:确认全仓无残留 emoji,并加入 CI 守卫。
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- 新增 `frontend/eslint-rules/no-emoji.cjs`(自定义 ESLint 规则,禁止源码中的 emoji 字符范围)
|
||||||
|
- `frontend/.eslintrc.cjs` 注册规则
|
||||||
|
- `pyproject.toml` 加 `ruff` 自定义规则(`RUF900` 禁用 emoji)或依赖 `pre-commit` 钩子
|
||||||
|
- `.pre-commit-config.yaml`(若不存在则创建)— 添加 `agentkit-no-emoji` 钩子
|
||||||
|
- `docs/solutions/style/no-emoji-style-guide.md`(新增)— 记录替换规则与原因
|
||||||
|
|
||||||
|
**Approach**:
|
||||||
|
- 自定义 ESLint 规则用正则 `[\x{1F000}-\x{1FFFF}\x{2600}-\x{27BF}\x{2300}-\x{23FF}\x{2B00}-\x{2BFF}]` 匹配所有 emoji 范围
|
||||||
|
- 规则名 `no-emoji-in-source`,错误级别 `error`,白名单文件:注释(通过 `// allow-emoji` 注解豁免,仅在文档/迁移脚本等场景)
|
||||||
|
- ruff 通过 `RUF` 自定义检查或第三方 `flake8-no-emoji` 工具
|
||||||
|
- pre-commit 在 commit 阶段跑前端 + 后端双检查
|
||||||
|
- 风格指南文档说明三套替代策略(KTD1-3)
|
||||||
|
|
||||||
|
**Test scenarios**:
|
||||||
|
- **Happy path**:`npx eslint src/` 无 emoji 错误
|
||||||
|
- **Happy path**:`ruff check src/` 无 emoji 警告
|
||||||
|
- **Error path**:故意在某 `.vue` 文件中加一个 emoji 字符,ESLint 报错并阻止 build
|
||||||
|
- **Integration**:本地 commit 含 emoji 的文件被 pre-commit 阻止
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- `npm run lint` 全通过
|
||||||
|
- `ruff check src/` 全通过
|
||||||
|
- `pre-commit run --all-files` 全通过
|
||||||
|
- 新建一个临时 `.vue` 文件含 emoji,确认 ESLint 报错
|
||||||
|
|
||||||
|
**Deferred to Follow-Up**:CI 工作流(`.github/workflows/` 或 Gitea Actions)添加 emoji 检查步骤 — 等 lint 规则稳定后再加,避免一次性改动过多。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
| --- | --- |
|
||||||
|
| Ant Design Vue 图标视觉与原 emoji 风格差异大,用户不适应 | U2 完成后做视觉冒烟;如不接受可回退到 `string` 字符首字母策略(KTD1) |
|
||||||
|
| `useMessageRenderer.ts` 的 `shell.avatar` 改 `Component` 类型影响 `MessageShell.vue` 之外的消费者 | 全仓 grep `shell.avatar` / `message.shell.avatar` 确认仅 `MessageShell.vue` 消费 |
|
||||||
|
| CLI 输出改为 `OK`/`FAIL` 文本会改变宽度,可能破坏现有对齐 | 表格用 `--` 而非 `FAIL`(列宽更短) |
|
||||||
|
| Bitable 旧行不清理,未来 DB 导出/快照会有 `📋` 字符串 | 文档记录 KTD4 限制;后续可加迁移脚本 |
|
||||||
|
| pre-commit / 自定义 ESLint 规则本身可能误报(CJK 字符、注释中的 emoji) | 规则用明确的 Unicode range;白名单机制支持 `// allow-emoji` 行级豁免 |
|
||||||
|
| 测试 fixture 改首字母可能与某个测试断言的「fallback 行为」冲突 | U4 前先跑一遍 vitest 全量测试,建立基线;逐个 fixture 改并立即跑对应测试 |
|
||||||
|
|
||||||
|
**依赖关系**:
|
||||||
|
- U1 独立(数据/契约层)
|
||||||
|
- U2 独立(前端 UI)
|
||||||
|
- U3 独立(CLI)
|
||||||
|
- U4 依赖 U1(chatStore.test.ts content 需与 U1 后端持久化文本一致)
|
||||||
|
- U5 依赖 U1-U4 全部完成
|
||||||
|
|
||||||
|
**实施顺序**:U1 → U2 → U3 → U4 → U5。每批独立可提交/可回滚。
|
||||||
|
|
||||||
|
## Acceptance Examples
|
||||||
|
|
||||||
|
AE1. **私董会横幅显示矢量图标** — 触发 `@board 测试主题`,确认 `BoardBannerCard` 渲染 `<BankOutlined />` 矢量图标(SVG),不再显示 `🏛️` 字符。
|
||||||
|
|
||||||
|
AE2. **Bitable 默认图标稳定** — 创建新 Bitable 文件,`icon` 字段在 DB 中为 `'table'`,UI 渲染 `TableOutlined`。已存在的 emoji 行通过前端 `resolveBitableIcon` 自动收敛。
|
||||||
|
|
||||||
|
AE3. **CLI 状态无 emoji** — 运行 `agentkit admin list-users`,表格中 `is_active=true` 显示 `OK`,`is_active=false` 显示 `--`,无 `✓`/`✗` 字符。
|
||||||
|
|
||||||
|
AE4. **专家头像首字符** — `@board steve_jobs, charlie_munger` 私董会,`BoardBannerCard` 中 Steve Jobs 头像显示 `S`、Charlie Munger 头像显示 `C`(不再显示 `🍎`/`🧠`)。
|
||||||
|
|
||||||
|
AE5. **测试 fixture 与生产代码一致** — `npm run typecheck` + `npx vitest run` + `pytest tests/unit/` 三套全通过,无 emoji 残留。
|
||||||
|
|
||||||
|
AE6. **CI 守卫** — 故意在某 `.vue` 模板加 `🏛️` 字符,`npm run lint` 报错并阻止。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **`useMessageRenderer.ts` 的 `shell.avatar` 类型升级是否需要更平滑**(保留 string 但用约定 `__ICON__BankOutlined`)?— 当前方案改为 `Component` 是更类型安全的,但若 `MessageShell.vue` 之外有第三方消费方可能破坏。**默认决定**:U2 实施前先全仓 grep 确认无外部消费者。
|
||||||
|
|
||||||
|
2. **Bitable `📋` 旧行是否要做一次性清理脚本**?— KTD4 决定惰性收敛,文档记录为 follow-up。如用户要求显式清理,U5 后可加一个 `agentkit bitable migrate-icons` 命令。
|
||||||
|
|
||||||
|
3. **`@ant-design/icons-vue` 包体积**(目前约 300KB+)— 项目已在用,仅新增 7 个组件,bundle size 影响可忽略。如未来有更激进的优化诉求可走按需 import + Vite tree-shaking(已默认开启)。
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- **本地参考**:
|
||||||
|
- `frontend/src/components/chat/helpers/expertIdentity.ts` — 字符首字母策略的权威实现
|
||||||
|
- `frontend/src/components/bitable/bitableIcons.ts:89` — `resolveBitableIcon` 的回退路径
|
||||||
|
- `docs/plans/2026-06-19-001-feat-chat-area-vi-redesign-plan.md:192` — BoardBannerCard 的设计说明(含 `🏛️`)
|
||||||
|
- `docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md:154-155` — StickyModeHeader 的 emoji 头像渲染说明
|
||||||
|
- **项目记忆**:
|
||||||
|
- 「UI components must use consistent color tokens; avoid hardcoded blue fallback colors」— 沿用到图标风格统一
|
||||||
|
- 「CSS token fallback values can cause unintended color changes when tokens load late」— Ant Design Vue 组件是设计良好的回退机制
|
||||||
|
- **无外部研究触发**:emoji 替换是纯内部风格收敛,无外部 API/技术依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan Metadata
|
||||||
|
|
||||||
|
- **Depth**: Standard(5 个 implementation units,跨前后端+CLI+DB+测试+lint 守卫)
|
||||||
|
- **Expected commits**: 5(每个 U 一个 commit;U5 包含 lint 规则可能拆 2 个)
|
||||||
|
- **Risk profile**: 中(无安全/支付/外部 API 风险,主要是视觉/UX 风险)
|
||||||
|
- **Origin**: 用户直接在 ce-plan 中提出(无 upstream brainstorm doc)
|
||||||
|
- **Confidence**: 中高(KTD1-4 都有项目内既有模式可遵循;KTD5/6 引入新机制,U5 留验证空间)
|
||||||
|
|
@ -74,7 +74,10 @@ class BitableFile(BaseModel):
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
icon: str = "📋"
|
# Stable icon key (e.g. ``"table"``); resolved client-side through
|
||||||
|
# ``bitableIcons.ts``. See that module for the full key set and the
|
||||||
|
# emoji-string fallback for legacy rows.
|
||||||
|
icon: str = "table"
|
||||||
description: str = ""
|
description: str = ""
|
||||||
owner_user_id: str | None = None
|
owner_user_id: str | None = None
|
||||||
created_at: datetime = PydanticField(default_factory=_utcnow)
|
created_at: datetime = PydanticField(default_factory=_utcnow)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class BitableRepository:
|
||||||
async def create_file(
|
async def create_file(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
icon: str = "📋",
|
icon: str = "table",
|
||||||
description: str = "",
|
description: str = "",
|
||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
) -> BitableFile:
|
) -> BitableFile:
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ class BitableService:
|
||||||
async def create_file(
|
async def create_file(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
icon: str = "📋",
|
icon: str = "table",
|
||||||
description: str = "",
|
description: str = "",
|
||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
) -> BitableFile:
|
) -> BitableFile:
|
||||||
|
|
|
||||||
|
|
@ -368,3 +368,42 @@ class SqliteConversationStore:
|
||||||
except (aiosqlite.Error, ValueError, KeyError, TypeError, RuntimeError) as e:
|
except (aiosqlite.Error, ValueError, KeyError, TypeError, RuntimeError) as e:
|
||||||
logger.warning(f"get_first_user_message failed for {conversation_id}: {e}")
|
logger.warning(f"get_first_user_message failed for {conversation_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def has_message_with_type(
|
||||||
|
self, conversation_id: str, message_type: str
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if the conversation has at least one message whose
|
||||||
|
``metadata.message_type`` equals *message_type*.
|
||||||
|
|
||||||
|
Cheap existence check used by the sidebar to flag board meetings
|
||||||
|
(``"board_started"``) without fetching the full history. Backs
|
||||||
|
the per-conversation ``is_board`` flag returned by
|
||||||
|
``/api/v1/portal/conversations``.
|
||||||
|
|
||||||
|
The metadata column is JSON, so we LIKE-search for the literal
|
||||||
|
``"message_type": "<value>"`` token. SQLite has no native JSON
|
||||||
|
functions enabled in the schema, and ``json_extract`` would
|
||||||
|
require the JSON1 extension — LIKE keeps the query portable and
|
||||||
|
cheap (uses the existing ``idx_messages_conv_id`` index for the
|
||||||
|
``conversation_id`` predicate).
|
||||||
|
"""
|
||||||
|
db = await self._ensure_db()
|
||||||
|
# Escape backslashes/quotes defensively even though ``message_type``
|
||||||
|
# values are currently hard-coded identifiers; future callers might
|
||||||
|
# pass user input.
|
||||||
|
safe = message_type.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
like_pattern = f'%"message_type": "{safe}"%'
|
||||||
|
try:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT 1 FROM messages "
|
||||||
|
"WHERE conversation_id = ? AND metadata LIKE ? ESCAPE '\\' "
|
||||||
|
"LIMIT 1",
|
||||||
|
(conversation_id, like_pattern),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row is not None
|
||||||
|
except (aiosqlite.Error, ValueError, TypeError) as e:
|
||||||
|
logger.warning(
|
||||||
|
f"has_message_with_type failed for {conversation_id}/{message_type}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ console = Console()
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ServerUrlOption = typer.Option(
|
ServerUrlOption = typer.Option(
|
||||||
None, "--server-url", "-s", help="Server URL (default: http://localhost:8001)"
|
None, "--server-url", "-s", help="Server URL (default: http://localhost:18001)"
|
||||||
)
|
)
|
||||||
TokenOption = typer.Option(None, "--token", "-t", help="JWT access token")
|
TokenOption = typer.Option(None, "--token", "-t", help="JWT access token")
|
||||||
ApiKeyOption = typer.Option(None, "--api-key", "-k", help="API key")
|
ApiKeyOption = typer.Option(None, "--api-key", "-k", help="API key")
|
||||||
|
|
@ -161,7 +161,7 @@ def admin_login(
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Login and save the access token to ``~/.agentkit/admin_config.yaml``."""
|
"""Login and save the access token to ``~/.agentkit/admin_config.yaml``."""
|
||||||
resolved_url = server_url or "http://localhost:8001"
|
resolved_url = server_url or "http://localhost:18001"
|
||||||
client = AdminHttpClient(resolved_url)
|
client = AdminHttpClient(resolved_url)
|
||||||
try:
|
try:
|
||||||
token = client.login(username, password)
|
token = client.login(username, password)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from pathlib import Path
|
||||||
import httpx
|
import httpx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
DEFAULT_SERVER_URL = "http://localhost:8001"
|
DEFAULT_SERVER_URL = "http://localhost:18001"
|
||||||
DEFAULT_CONFIG_PATH = Path.home() / ".agentkit" / "admin_config.yaml"
|
DEFAULT_CONFIG_PATH = Path.home() / ".agentkit" / "admin_config.yaml"
|
||||||
DEFAULT_TIMEOUT = 30.0
|
DEFAULT_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,4 @@ def init(
|
||||||
rprint(" 1. Copy [cyan].env.example[/cyan] to [cyan].env[/cyan] and fill in your API keys")
|
rprint(" 1. Copy [cyan].env.example[/cyan] to [cyan].env[/cyan] and fill in your API keys")
|
||||||
rprint(" 2. Edit [cyan]agentkit.yaml[/cyan] to configure your agents")
|
rprint(" 2. Edit [cyan]agentkit.yaml[/cyan] to configure your agents")
|
||||||
rprint(" 3. Run [cyan]agentkit serve[/cyan] to start the server")
|
rprint(" 3. Run [cyan]agentkit serve[/cyan] to start the server")
|
||||||
rprint(" 4. Run [cyan]agentkit task submit --skill example_skill --input '{\"message\": \"Hello\"}' --server-url http://localhost:8001[/cyan]")
|
rprint(" 4. Run [cyan]agentkit task submit --skill example_skill --input '{\"message\": \"Hello\"}' --server-url http://localhost:18001[/cyan]")
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ app.command(name="benchmark")(benchmark)
|
||||||
@app.command()
|
@app.command()
|
||||||
def gui(
|
def gui(
|
||||||
host: str = typer.Option("0.0.0.0", "--host", help="Server bind host"),
|
host: str = typer.Option("0.0.0.0", "--host", help="Server bind host"),
|
||||||
port: int = typer.Option(8002, "--port", help="Server port (0 for random)"),
|
port: int = typer.Option(18002, "--port", help="Server port (0 for random)"),
|
||||||
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
|
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
|
||||||
no_open: bool = typer.Option(False, "--no-open", help="Do not open browser automatically"),
|
no_open: bool = typer.Option(False, "--no-open", help="Do not open browser automatically"),
|
||||||
):
|
):
|
||||||
|
|
@ -166,7 +166,7 @@ def gui(
|
||||||
@app.command()
|
@app.command()
|
||||||
def serve(
|
def serve(
|
||||||
host: str = typer.Option("0.0.0.0", "--host", help="Server host"),
|
host: str = typer.Option("0.0.0.0", "--host", help="Server host"),
|
||||||
port: int = typer.Option(8001, "--port", help="Server port"),
|
port: int = typer.Option(18001, "--port", help="Server port"),
|
||||||
workers: int = typer.Option(1, "--workers", help="Number of workers"),
|
workers: int = typer.Option(1, "--workers", help="Number of workers"),
|
||||||
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
||||||
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
|
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
|
||||||
|
|
@ -228,7 +228,7 @@ def serve(
|
||||||
|
|
||||||
# CLI args override config file
|
# CLI args override config file
|
||||||
effective_host = host if host != "0.0.0.0" else server_config.host
|
effective_host = host if host != "0.0.0.0" else server_config.host
|
||||||
effective_port = port if port != 8001 else server_config.port
|
effective_port = port if port != 18001 else server_config.port
|
||||||
effective_workers = workers if workers != 1 else server_config.workers
|
effective_workers = workers if workers != 1 else server_config.workers
|
||||||
|
|
||||||
# Store config for app factory
|
# Store config for app factory
|
||||||
|
|
@ -288,7 +288,7 @@ def version():
|
||||||
@app.command()
|
@app.command()
|
||||||
def doctor(
|
def doctor(
|
||||||
host: str = typer.Option("localhost", "--host", help="Server host"),
|
host: str = typer.Option("localhost", "--host", help="Server host"),
|
||||||
port: int = typer.Option(8001, "--port", help="Server port"),
|
port: int = typer.Option(18001, "--port", help="Server port"),
|
||||||
):
|
):
|
||||||
"""Diagnose AgentKit server health and configuration"""
|
"""Diagnose AgentKit server health and configuration"""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@ def run_onboarding(
|
||||||
config = {
|
config = {
|
||||||
"server": {
|
"server": {
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8001,
|
"port": 18001,
|
||||||
"workers": 1,
|
"workers": 1,
|
||||||
"rate_limit": 60,
|
"rate_limit": 60,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ def pair(
|
||||||
config_dir: str = typer.Option(".", "--config-dir", help="AgentKit config directory"),
|
config_dir: str = typer.Option(".", "--config-dir", help="AgentKit config directory"),
|
||||||
list_clients: bool = typer.Option(False, "--list", "-l", help="List all paired clients"),
|
list_clients: bool = typer.Option(False, "--list", "-l", help="List all paired clients"),
|
||||||
revoke: Optional[str] = typer.Option(None, "--revoke", "-r", help="Revoke a client by name"),
|
revoke: Optional[str] = typer.Option(None, "--revoke", "-r", help="Revoke a client by name"),
|
||||||
server_url: str = typer.Option("http://localhost:8001", "--server-url", help="AgentKit server URL for connection instructions"),
|
server_url: str = typer.Option("http://localhost:18001", "--server-url", help="AgentKit server URL for connection instructions"),
|
||||||
):
|
):
|
||||||
"""Pair a business system with AgentKit (generate API key + register client)"""
|
"""Pair a business system with AgentKit (generate API key + register client)"""
|
||||||
config_dir = os.path.abspath(config_dir)
|
config_dir = os.path.abspath(config_dir)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ AGENTKIT_YAML = """\
|
||||||
|
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8001
|
port: 18001
|
||||||
workers: 1
|
workers: 1
|
||||||
api_key: null # Set to enable API key authentication
|
api_key: null # Set to enable API key authentication
|
||||||
rate_limit: 60 # Requests per minute
|
rate_limit: 60 # Requests per minute
|
||||||
|
|
@ -77,9 +77,9 @@ version: "3.8"
|
||||||
services:
|
services:
|
||||||
agentkit:
|
agentkit:
|
||||||
build: .
|
build: .
|
||||||
command: serve --host 0.0.0.0 --port 8001
|
command: serve --host 0.0.0.0 --port 18001
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "18001:18001"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
|
|
@ -87,7 +87,7 @@ services:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:18001/api/v1/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Usage::
|
||||||
from agentkit.client.sync import ConfigSync
|
from agentkit.client.sync import ConfigSync
|
||||||
|
|
||||||
sync = ConfigSync(
|
sync = ConfigSync(
|
||||||
server_url="http://localhost:8001",
|
server_url="http://localhost:18001",
|
||||||
token_provider=lambda: jwt_token, # or None for dev mode
|
token_provider=lambda: jwt_token, # or None for dev mode
|
||||||
cache_db_path="~/.agentkit/config_cache.db",
|
cache_db_path="~/.agentkit/config_cache.db",
|
||||||
)
|
)
|
||||||
|
|
@ -78,7 +78,7 @@ class ConfigSync:
|
||||||
changes on a configurable interval.
|
changes on a configurable interval.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
server_url: Base URL of the AgentKit server (e.g. ``http://localhost:8001``).
|
server_url: Base URL of the AgentKit server (e.g. ``http://localhost:18001``).
|
||||||
token_provider: Callable that returns the current JWT access token
|
token_provider: Callable that returns the current JWT access token
|
||||||
(or ``None`` if not authenticated). Called on each request.
|
(or ``None`` if not authenticated). Called on each request.
|
||||||
cache_db_path: Path to the local SQLite cache file.
|
cache_db_path: Path to the local SQLite cache file.
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,7 @@ class BoardOrchestrator:
|
||||||
# Promote first active expert to moderator
|
# Promote first active expert to moderator
|
||||||
self._team._moderator_name = active[0].config.name
|
self._team._moderator_name = active[0].config.name
|
||||||
moderator = active[0]
|
moderator = active[0]
|
||||||
logger.warning(
|
logger.warning(f"Moderator not available, falling back to '{moderator.config.name}'")
|
||||||
f"Moderator not available, falling back to '{moderator.config.name}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._team.set_status(BoardStatus.DISCUSSING)
|
self._team.set_status(BoardStatus.DISCUSSING)
|
||||||
|
|
||||||
|
|
@ -128,22 +126,22 @@ class BoardOrchestrator:
|
||||||
logger.info(f"Discussion stopped by user at round {round_num}")
|
logger.info(f"Discussion stopped by user at round {round_num}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Generate member speeches in parallel
|
# Generate member speeches sequentially so the user sees one
|
||||||
|
# expert at a time instead of all experts racing to print
|
||||||
|
# their full text in the same instant. Each expert streams
|
||||||
|
# its own chunks (via expert_speech_chunk events) before the
|
||||||
|
# next expert starts. This trades wall-clock latency for
|
||||||
|
# perceptual clarity, which matches the user's stated
|
||||||
|
# preference for "逐个输出". Ponytail: 1-N experts, simple
|
||||||
|
# for-loop; if board size grows past ~5 members, revisit.
|
||||||
members = self._team.member_experts
|
members = self._team.member_experts
|
||||||
if members:
|
if members:
|
||||||
speech_results = await asyncio.gather(
|
for expert in members:
|
||||||
*[self._generate_expert_speech(e, round_num) for e in members],
|
try:
|
||||||
return_exceptions=True,
|
result = await self._generate_expert_speech(expert, round_num)
|
||||||
)
|
except Exception as e:
|
||||||
|
logger.warning(f"Expert '{expert.config.name}' speech failed: {e}")
|
||||||
# Broadcast speeches in order (not parallel broadcast)
|
|
||||||
for expert, result in zip(members, speech_results):
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
logger.warning(
|
|
||||||
f"Expert '{expert.config.name}' speech failed: {result}"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await self._team.add_to_history(
|
await self._team.add_to_history(
|
||||||
round_num, expert.config.name, result, "expert"
|
round_num, expert.config.name, result, "expert"
|
||||||
)
|
)
|
||||||
|
|
@ -265,12 +263,17 @@ class BoardOrchestrator:
|
||||||
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
|
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
|
||||||
|
|
||||||
async def _generate_expert_speech(self, expert: Expert, round: int) -> str:
|
async def _generate_expert_speech(self, expert: Expert, round: int) -> str:
|
||||||
"""Generate an expert's speech for the current round.
|
"""Generate an expert's speech for the current round (streaming).
|
||||||
|
|
||||||
The speech is based on:
|
The speech is based on:
|
||||||
- Expert's persona, thinking_style, speaking_style, decision_framework
|
- Expert's persona, thinking_style, speaking_style, decision_framework
|
||||||
- Full discussion history
|
- Full discussion history
|
||||||
- Current round / max rounds
|
- Current round / max rounds
|
||||||
|
|
||||||
|
Streams LLM output chunk-by-chunk via ``expert_speech_chunk`` events
|
||||||
|
so the UI shows content as it arrives, instead of waiting for the
|
||||||
|
full completion. Returns the accumulated content so the gather
|
||||||
|
step in ``execute()`` can still keep its parallel contract.
|
||||||
"""
|
"""
|
||||||
gateway = self._get_llm_gateway(expert)
|
gateway = self._get_llm_gateway(expert)
|
||||||
if not gateway:
|
if not gateway:
|
||||||
|
|
@ -300,11 +303,126 @@ class BoardOrchestrator:
|
||||||
"- 给出明确的立场或建议\n"
|
"- 给出明确的立场或建议\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ponytail: stream when the provider supports it, otherwise fall back
|
||||||
|
# to a single non-streaming call. We keep the gather contract — the
|
||||||
|
# function still returns the full text — so the surrounding execute()
|
||||||
|
# loop is unchanged.
|
||||||
|
return await self._stream_expert_speech(expert, round, prompt)
|
||||||
|
|
||||||
|
async def _stream_expert_speech(self, expert: Expert, round: int, prompt: str) -> str:
|
||||||
|
"""Stream an expert's speech via chat_stream, emitting chunks.
|
||||||
|
|
||||||
|
Falls back to non-streaming ``chat()`` when ``chat_stream`` is
|
||||||
|
unavailable (e.g. an LLM provider without streaming support) or
|
||||||
|
raises before any chunk is produced.
|
||||||
|
|
||||||
|
ponytail: when the LLM does not actually stream (returns a single
|
||||||
|
big chunk), we still want the UI to see content appearing
|
||||||
|
progressively. So we split the LLM's final content into
|
||||||
|
sentence/line chunks and emit them with a small delay. The
|
||||||
|
``expert_speech_chunk`` event already handles duplicate-sender
|
||||||
|
dedup, so emitting many small chunks is safe.
|
||||||
|
"""
|
||||||
|
gateway = self._get_llm_gateway(expert)
|
||||||
|
assert gateway is not None # checked by caller
|
||||||
|
total = ""
|
||||||
|
# Emit an opening chunk-less event so the UI can create the streaming
|
||||||
|
# placeholder before the first token arrives (keeps the first paint
|
||||||
|
# aligned with the streaming indicator).
|
||||||
|
try:
|
||||||
|
streamed_chunk_count = 0
|
||||||
|
async for chunk in gateway.chat_stream(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
model="default",
|
||||||
|
):
|
||||||
|
delta = chunk.content or ""
|
||||||
|
if not delta:
|
||||||
|
continue
|
||||||
|
total += delta
|
||||||
|
streamed_chunk_count += 1
|
||||||
|
await self._broadcast_event(
|
||||||
|
"expert_speech_chunk",
|
||||||
|
{
|
||||||
|
"expert_name": expert.config.name,
|
||||||
|
"expert_avatar": expert.config.avatar,
|
||||||
|
"expert_color": expert.config.color,
|
||||||
|
"content": delta,
|
||||||
|
"round": round,
|
||||||
|
"role": "expert",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# If the LLM "streamed" but only delivered one big chunk, still
|
||||||
|
# let the UI see content arrive progressively.
|
||||||
|
if streamed_chunk_count <= 1 and total:
|
||||||
|
await self._replay_stream(expert, round, total, delay=0.05, chunk_size=12)
|
||||||
|
return total.strip()
|
||||||
|
except (AttributeError, NotImplementedError) as e:
|
||||||
|
logger.info(
|
||||||
|
f"Provider for '{expert.config.name}' lacks chat_stream, "
|
||||||
|
f"falling back to non-streaming: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Expert '{expert.config.name}' stream failed: {e}")
|
||||||
|
|
||||||
|
# Fallback: non-streaming path. Emit the whole content as small
|
||||||
|
# chunks so the UI still renders progressively rather than going
|
||||||
|
# silent and then dumping the whole text in one frame.
|
||||||
|
try:
|
||||||
response = await gateway.chat(
|
response = await gateway.chat(
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
model="default",
|
model="default",
|
||||||
)
|
)
|
||||||
return response.content.strip()
|
content = (response.content or "").strip()
|
||||||
|
if content:
|
||||||
|
await self._replay_stream(expert, round, content, delay=0.05, chunk_size=12)
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Expert '{expert.config.name}' non-stream fallback failed: {e}")
|
||||||
|
return total.strip() or f"[{expert.config.name} 发言失败]"
|
||||||
|
|
||||||
|
async def _replay_stream(
|
||||||
|
self,
|
||||||
|
expert: Expert,
|
||||||
|
round: int,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
delay: float,
|
||||||
|
chunk_size: int,
|
||||||
|
) -> None:
|
||||||
|
"""Emit ``content`` as small ``expert_speech_chunk`` events.
|
||||||
|
|
||||||
|
Used when the LLM provider returns the whole response in a single
|
||||||
|
chunk — the UI otherwise sees no streaming animation. Splits on
|
||||||
|
Chinese sentence boundaries (``。!?\n``) and falls back to a
|
||||||
|
fixed character count for safety.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Split on sentence/line boundaries, keeping the delimiters so the
|
||||||
|
# joined output still reads naturally.
|
||||||
|
parts = re.findall(r"[^。!?\n]+[。!?\n]?|[^。!?\n]+", content)
|
||||||
|
if not parts:
|
||||||
|
parts = [content]
|
||||||
|
for part in parts:
|
||||||
|
# If a part is huge (no delimiters hit), slice it into
|
||||||
|
# sub-chunks of ``chunk_size`` characters.
|
||||||
|
for start in range(0, len(part), chunk_size):
|
||||||
|
piece = part[start : start + chunk_size]
|
||||||
|
if not piece:
|
||||||
|
continue
|
||||||
|
await self._broadcast_event(
|
||||||
|
"expert_speech_chunk",
|
||||||
|
{
|
||||||
|
"expert_name": expert.config.name,
|
||||||
|
"expert_avatar": expert.config.avatar,
|
||||||
|
"expert_color": expert.config.color,
|
||||||
|
"content": piece,
|
||||||
|
"round": round,
|
||||||
|
"role": "expert",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if delay > 0:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
async def _generate_moderator_summary(self, moderator: Expert, round: int) -> str:
|
async def _generate_moderator_summary(self, moderator: Expert, round: int) -> str:
|
||||||
"""Generate moderator's round summary.
|
"""Generate moderator's round summary.
|
||||||
|
|
@ -316,15 +434,11 @@ class BoardOrchestrator:
|
||||||
return f"[第 {round} 轮小结因 LLM 不可用无法生成]"
|
return f"[第 {round} 轮小结因 LLM 不可用无法生成]"
|
||||||
|
|
||||||
# Get only current round's speeches
|
# Get only current round's speeches
|
||||||
round_history = [
|
round_history = [h for h in self._team.history if h["round"] == round]
|
||||||
h for h in self._team.history if h["round"] == round
|
|
||||||
]
|
|
||||||
if not round_history:
|
if not round_history:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
round_text = "\n\n".join(
|
round_text = "\n\n".join(f"[{h['expert_name']}]: {h['content']}" for h in round_history)
|
||||||
f"[{h['expert_name']}]: {h['content']}" for h in round_history
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"你是私董会主持人 {moderator.config.name}。\n"
|
f"你是私董会主持人 {moderator.config.name}。\n"
|
||||||
|
|
@ -427,7 +541,9 @@ class BoardOrchestrator:
|
||||||
"dissent_points": [],
|
"dissent_points": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _generate_fallback_conclusion(self, moderator: Expert, topic: str) -> dict[str, object]:
|
async def _generate_fallback_conclusion(
|
||||||
|
self, moderator: Expert, topic: str
|
||||||
|
) -> dict[str, object]:
|
||||||
"""Generate a fallback conclusion when execution fails.
|
"""Generate a fallback conclusion when execution fails.
|
||||||
|
|
||||||
Uses existing discussion history to provide a basic summary.
|
Uses existing discussion history to provide a basic summary.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class GenericHTTPAdapter(KBAdapter):
|
||||||
典型配置::
|
典型配置::
|
||||||
|
|
||||||
adapter = GenericHTTPAdapter(
|
adapter = GenericHTTPAdapter(
|
||||||
endpoint_url="http://localhost:8000/api/knowledge",
|
endpoint_url="http://localhost:18001/api/knowledge",
|
||||||
auth_config={"type": "bearer", "token": "sk-xxx"},
|
auth_config={"type": "bearer", "token": "sk-xxx"},
|
||||||
headers={"X-Custom-Header": "value"},
|
headers={"X-Custom-Header": "value"},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class HttpRAGService:
|
||||||
memory:
|
memory:
|
||||||
semantic:
|
semantic:
|
||||||
enabled: true
|
enabled: true
|
||||||
base_url: "http://localhost:8000/api/knowledge"
|
base_url: "http://localhost:18001/api/knowledge"
|
||||||
api_key: "${GEO_API_KEY}"
|
api_key: "${GEO_API_KEY}"
|
||||||
knowledge_base_ids:
|
knowledge_base_ids:
|
||||||
- "industry-kb-id"
|
- "industry-kb-id"
|
||||||
|
|
@ -56,7 +56,7 @@ class HttpRAGService:
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
base_url: 知识库 API 基础地址,如 http://localhost:8000/api/knowledge
|
base_url: 知识库 API 基础地址,如 http://localhost:18001/api/knowledge
|
||||||
api_key: 认证 API Key(放在 Authorization: Bearer 头)
|
api_key: 认证 API Key(放在 Authorization: Bearer 头)
|
||||||
knowledge_base_ids: 默认检索的知识库 ID 列表
|
knowledge_base_ids: 默认检索的知识库 ID 列表
|
||||||
timeout: HTTP 请求超时秒数
|
timeout: HTTP 请求超时秒数
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,7 @@ async def lifespan(app: FastAPI):
|
||||||
from agentkit.tools.web_crawl import WebCrawlTool
|
from agentkit.tools.web_crawl import WebCrawlTool
|
||||||
from agentkit.tools.baidu_search import BaiduSearchTool
|
from agentkit.tools.baidu_search import BaiduSearchTool
|
||||||
from agentkit.tools.document_tool import DocumentTool
|
from agentkit.tools.document_tool import DocumentTool
|
||||||
|
from agentkit.tools.file_read import ReadFileTool # benchmark_runner skill binding
|
||||||
from agentkit.documents.service import DocumentService
|
from agentkit.documents.service import DocumentService
|
||||||
from agentkit.documents.db import init_documents_db
|
from agentkit.documents.db import init_documents_db
|
||||||
from agentkit.documents.renderers.word_renderer import WordRenderer
|
from agentkit.documents.renderers.word_renderer import WordRenderer
|
||||||
|
|
@ -262,6 +263,7 @@ async def lifespan(app: FastAPI):
|
||||||
agent._tool_registry.register(BaiduSearchTool())
|
agent._tool_registry.register(BaiduSearchTool())
|
||||||
agent._tool_registry.register(WebSearchTool(**search_api_keys))
|
agent._tool_registry.register(WebSearchTool(**search_api_keys))
|
||||||
agent._tool_registry.register(WebCrawlTool())
|
agent._tool_registry.register(WebCrawlTool())
|
||||||
|
agent._tool_registry.register(ReadFileTool()) # benchmark_runner skill binding
|
||||||
|
|
||||||
# Document processing tool (U6): DocumentService with all renderers.
|
# Document processing tool (U6): DocumentService with all renderers.
|
||||||
# On failure the tool is simply unavailable — app.state.document_service
|
# On failure the tool is simply unavailable — app.state.document_service
|
||||||
|
|
@ -816,11 +818,22 @@ def create_app(
|
||||||
config_path = str(_cwd_yaml)
|
config_path = str(_cwd_yaml)
|
||||||
|
|
||||||
if config_path and os.path.exists(config_path):
|
if config_path and os.path.exists(config_path):
|
||||||
# Load .env before parsing config (so ${ENV_VAR} substitutions work)
|
# Load .env before parsing config (so ${ENV_VAR} substitutions work).
|
||||||
|
# Try multiple candidates in priority order: ``.env`` (canonical,
|
||||||
|
# matches the rest of the Python ecosystem) → ``.env.dev`` (our
|
||||||
|
# in-tree dev preset for the 18001/15173/15174 port allocation)
|
||||||
|
# → ``.env.local`` (developer-local overrides, gitignored).
|
||||||
|
#
|
||||||
|
# ponytail: 改之前只查 .env,导致 .env.dev 不被加载 → Bitable
|
||||||
|
# 拿不到 DATABASE_URL,BitTable 初始化失败。三个候选按优先级叠加
|
||||||
|
# 加载(不互相覆盖 os.environ),与 docker-compose 的
|
||||||
|
# env_file priority 行为一致。升级路径:如果项目开始用
|
||||||
|
# python-dotenv,可以替换为它获得 quoted values / escape 支持。
|
||||||
from pathlib import Path as _P
|
from pathlib import Path as _P
|
||||||
|
|
||||||
_dotenv = _P(config_path).parent / ".env"
|
_config_dir = _P(config_path).parent
|
||||||
load_dotenv(_dotenv)
|
for _candidate in (".env", ".env.dev", ".env.local"):
|
||||||
|
load_dotenv(_config_dir / _candidate)
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
server_config = ServerConfig.from_yaml(config_path)
|
||||||
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)
|
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,11 @@ def create_token_pair(
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
"iat": int(issued_at.timestamp()),
|
"iat": int(issued_at.timestamp()),
|
||||||
"exp": int(refresh_exp.timestamp()),
|
"exp": int(refresh_exp.timestamp()),
|
||||||
|
# Persist the remember_me flag so /auth/refresh can inherit
|
||||||
|
# the original TTL (30d vs 7d) without the client re-sending
|
||||||
|
# it. Without this claim, every refresh would reset to the
|
||||||
|
# default 7-day TTL, defeating the "记住我 30 天" checkbox.
|
||||||
|
"rmb": remember_me,
|
||||||
}
|
}
|
||||||
if effective_session_id:
|
if effective_session_id:
|
||||||
access_payload["sid"] = effective_session_id
|
access_payload["sid"] = effective_session_id
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import httpx
|
||||||
class AgentKitClient:
|
class AgentKitClient:
|
||||||
"""Python SDK for AgentKit Server"""
|
"""Python SDK for AgentKit Server"""
|
||||||
|
|
||||||
def __init__(self, base_url: str = "http://localhost:8000"):
|
def __init__(self, base_url: str = "http://localhost:18001"):
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
self._client = httpx.AsyncClient(base_url=self._base_url)
|
self._client = httpx.AsyncClient(base_url=self._base_url)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ class ServerConfig:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host: str = "0.0.0.0",
|
host: str = "0.0.0.0",
|
||||||
port: int = 8001,
|
port: int = 18001,
|
||||||
workers: int = 1,
|
workers: int = 1,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
rate_limit: int = 60,
|
rate_limit: int = 60,
|
||||||
|
|
@ -264,7 +264,7 @@ class ServerConfig:
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
host=server.get("host", "0.0.0.0"),
|
host=server.get("host", "0.0.0.0"),
|
||||||
port=server.get("port", 8001),
|
port=server.get("port", 18001),
|
||||||
workers=server.get("workers", 1),
|
workers=server.get("workers", 1),
|
||||||
api_key=server.get("api_key"),
|
api_key=server.get("api_key"),
|
||||||
rate_limit=server.get("rate_limit", 60),
|
rate_limit=server.get("rate_limit", 60),
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ declare module 'vue' {
|
||||||
OptimizationPanel: typeof import('./src/components/evolution/OptimizationPanel.vue')['default']
|
OptimizationPanel: typeof import('./src/components/evolution/OptimizationPanel.vue')['default']
|
||||||
ParallelNode: typeof import('./src/components/workflow/ParallelNode.vue')['default']
|
ParallelNode: typeof import('./src/components/workflow/ParallelNode.vue')['default']
|
||||||
PathOptimizerPanel: typeof import('./src/components/evolution/PathOptimizerPanel.vue')['default']
|
PathOptimizerPanel: typeof import('./src/components/evolution/PathOptimizerPanel.vue')['default']
|
||||||
|
PhaseIndicator: typeof import('./src/components/chat/PhaseIndicator.vue')['default']
|
||||||
PitfallPanel: typeof import('./src/components/evolution/PitfallPanel.vue')['default']
|
PitfallPanel: typeof import('./src/components/evolution/PitfallPanel.vue')['default']
|
||||||
PitfallRoutePanel: typeof import('./src/components/evolution/PitfallRoutePanel.vue')['default']
|
PitfallRoutePanel: typeof import('./src/components/evolution/PitfallRoutePanel.vue')['default']
|
||||||
PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default']
|
PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default']
|
||||||
|
|
@ -160,6 +161,7 @@ declare module 'vue' {
|
||||||
SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default']
|
SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default']
|
||||||
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
|
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
|
||||||
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
|
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
|
||||||
|
StickyModeHeader: typeof import('./src/components/chat/StickyModeHeader.vue')['default']
|
||||||
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']
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { TEST_USER, clearAuth } from './helpers'
|
||||||
|
|
||||||
// ── API helpers ────────────────────────────────────────────────────────
|
// ── API helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const API_BASE = 'http://127.0.0.1:8000/api/v1'
|
const API_BASE = `http://127.0.0.1:${process.env.BACKEND_PORT ?? '18001'}/api/v1`
|
||||||
const CALENDAR_BASE = `${API_BASE}/calendar`
|
const CALENDAR_BASE = `${API_BASE}/calendar`
|
||||||
const AUTH_BASE = `${API_BASE}/auth`
|
const AUTH_BASE = `${API_BASE}/auth`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { TEST_USER, clearAuth, reloadAndWaitAuth } from './helpers'
|
||||||
|
|
||||||
// ── API helpers ────────────────────────────────────────────────────────
|
// ── API helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PORTAL_BASE = 'http://127.0.0.1:8000/api/v1/portal'
|
const PORTAL_BASE = `http://127.0.0.1:${process.env.BACKEND_PORT ?? '18001'}/api/v1/portal`
|
||||||
|
|
||||||
/** Cached access token for API calls (login once per worker). */
|
/** Cached access token for API calls (login once per worker). */
|
||||||
let _cachedToken: string | null = null
|
let _cachedToken: string | null = null
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
const BACKEND_HEALTH_URL = 'http://127.0.0.1:8000/api/v1/health'
|
const BACKEND_HEALTH_URL = `http://127.0.0.1:${process.env.BACKEND_PORT ?? '18001'}/api/v1/health`
|
||||||
const SETUP_SCRIPT = resolve(__dirname, 'setup-test-user.py')
|
const SETUP_SCRIPT = resolve(__dirname, 'setup-test-user.py')
|
||||||
|
|
||||||
/** Poll a URL until it returns 200 or the timeout expires. */
|
/** Poll a URL until it returns 200 or the timeout expires. */
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,22 @@ import type { Page, expect as ExpectType } from '@playwright/test'
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend API base — absolute URL so fetch() works in both Node.js (Playwright
|
* Backend port — read from BACKEND_PORT env var (set by the dev / E2E
|
||||||
* test context) and browser context. The Vite dev-server proxy is not available
|
* environment) so E2E tests follow the same port allocation as the Vite
|
||||||
* in Node.js, so we target the backend directly.
|
* proxy and the running backend server. Default 18001 matches
|
||||||
|
* agentkit.yaml / .env.dev. The Vite dev-server proxy is not available in
|
||||||
|
* Node.js, so we target the backend directly.
|
||||||
*/
|
*/
|
||||||
export const API_BASE = 'http://127.0.0.1:8000/api/v1'
|
const BACKEND_PORT = process.env.BACKEND_PORT ?? '18001'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend API base — absolute URL so fetch() works in both Node.js (Playwright
|
||||||
|
* test context) and browser context.
|
||||||
|
*/
|
||||||
|
export const API_BASE = `http://127.0.0.1:${BACKEND_PORT}/api/v1`
|
||||||
|
|
||||||
/** Backend health endpoint (absolute URL for direct fetch). */
|
/** Backend health endpoint (absolute URL for direct fetch). */
|
||||||
export const BACKEND_HEALTH_URL = 'http://127.0.0.1:8000/api/v1/health'
|
export const BACKEND_HEALTH_URL = `http://127.0.0.1:${BACKEND_PORT}/api/v1/health`
|
||||||
|
|
||||||
/** Test admin credentials — must match setup-test-user.py defaults. */
|
/** Test admin credentials — must match setup-test-user.py defaults. */
|
||||||
export const TEST_USER = {
|
export const TEST_USER = {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { defineConfig, devices } from '@playwright/test'
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architecture:
|
||||||
* - Backend (uvicorn direct, avoids agentkit serve interactive prompts) runs on
|
* - Backend (uvicorn direct, avoids agentkit serve interactive prompts) runs on
|
||||||
* port 8000 to match the Vite dev-server proxy target in vite.config.ts.
|
* port 18001 to match the Vite dev-server proxy target in vite.config.ts.
|
||||||
* - Frontend (Vite dev server) runs on port 5173 (strictPort in vite.config.ts).
|
* - Frontend (Vite dev server) runs on port 15173 (strictPort in vite.config.ts).
|
||||||
* - Tests target the frontend at http://localhost:5173; API/WS calls are
|
* - Tests target the frontend at http://localhost:15173; API/WS calls are
|
||||||
* transparently proxied to the backend.
|
* transparently proxied to the backend.
|
||||||
*
|
*
|
||||||
* The `globalSetup` script creates a test admin user in the auth DB before
|
* The `globalSetup` script creates a test admin user in the auth DB before
|
||||||
|
|
@ -30,7 +30,7 @@ export default defineConfig({
|
||||||
globalSetup: './e2e/global-setup.ts',
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:5173',
|
baseURL: 'http://localhost:15173',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
|
|
@ -70,8 +70,8 @@ export default defineConfig({
|
||||||
'AGENTKIT_GUI_MODE=1 NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost ' +
|
'AGENTKIT_GUI_MODE=1 NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost ' +
|
||||||
'.venv/bin/python -c "from agentkit.server.app import create_app; import uvicorn; ' +
|
'.venv/bin/python -c "from agentkit.server.app import create_app; import uvicorn; ' +
|
||||||
'app = create_app(rate_limit=10000); ' +
|
'app = create_app(rate_limit=10000); ' +
|
||||||
'uvicorn.run(app, host=\'127.0.0.1\', port=8000)"',
|
'uvicorn.run(app, host=\'127.0.0.1\', port=18001)"',
|
||||||
url: 'http://127.0.0.1:8000/api/v1/health',
|
url: 'http://127.0.0.1:18001/api/v1/health',
|
||||||
cwd: PROJECT_ROOT,
|
cwd: PROJECT_ROOT,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|
@ -80,7 +80,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
url: 'http://localhost:5173',
|
url: 'http://localhost:15173',
|
||||||
cwd: '.',
|
cwd: '.',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,24 @@ mod auth;
|
||||||
/// - clear_refresh_token() -> Result<(), String>
|
/// - clear_refresh_token() -> Result<(), String>
|
||||||
|
|
||||||
/// Remote server connection info. Defaults match the local dev server
|
/// Remote server connection info. Defaults match the local dev server
|
||||||
/// (``agentkit serve --port 8000``). Override at compile time via
|
/// (``agentkit serve --port 18001``). Override at compile time via
|
||||||
/// ``AGENTKIT_REMOTE_HOST`` / ``AGENTKIT_REMOTE_PORT``.
|
/// ``AGENTKIT_REMOTE_HOST`` / ``AGENTKIT_REMOTE_PORT``.
|
||||||
|
///
|
||||||
|
/// Note: 18001 is an intentionally uncommon port to avoid clashes with
|
||||||
|
/// common dev services (Docker's geo_backend:8000, pms-*:3306/6379/9200,
|
||||||
|
/// Vite default 5173, etc.). See vite.config.ts for the matching dev port.
|
||||||
const REMOTE_HOST: &str = match option_env!("AGENTKIT_REMOTE_HOST") {
|
const REMOTE_HOST: &str = match option_env!("AGENTKIT_REMOTE_HOST") {
|
||||||
Some(h) => h,
|
Some(h) => h,
|
||||||
None => "127.0.0.1",
|
None => "127.0.0.1",
|
||||||
};
|
};
|
||||||
const REMOTE_PORT_STR: &str = match option_env!("AGENTKIT_REMOTE_PORT") {
|
const REMOTE_PORT_STR: &str = match option_env!("AGENTKIT_REMOTE_PORT") {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => "8000",
|
None => "18001",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parse the port at first use (avoids non-const fn call in constant).
|
/// Parse the port at first use (avoids non-const fn call in constant).
|
||||||
fn remote_port() -> u16 {
|
fn remote_port() -> u16 {
|
||||||
REMOTE_PORT_STR.parse().unwrap_or(8000)
|
REMOTE_PORT_STR.parse().unwrap_or(18001)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global state: tracks whether the "backend" is considered started.
|
/// Global state: tracks whether the "backend" is considered started.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"identifier": "com.fischer.agentkit",
|
"identifier": "com.fischer.agentkit",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../static",
|
"frontendDist": "../static",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:15173",
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"beforeBuildCommand": "npm run build:frontend"
|
"beforeBuildCommand": "npm run build:frontend"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-card-hoverable:hover {
|
.ant-card-hoverable:hover {
|
||||||
border-color: var(--color-primary-light, #eef2ff) !important;
|
border-color: var(--color-primary-light, #f3f4f6) !important;
|
||||||
box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)) !important;
|
box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,477 +1,583 @@
|
||||||
import type { ICalendarEvent, IInvitation } from './calendar'
|
import type { ICalendarEvent, IInvitation } from "./calendar";
|
||||||
|
|
||||||
/** Chat request payload */
|
/** Chat request payload */
|
||||||
export interface IChatRequest {
|
export interface IChatRequest {
|
||||||
message: string
|
message: string;
|
||||||
conversation_id?: string
|
conversation_id?: string;
|
||||||
sources?: string[]
|
sources?: string[];
|
||||||
skill_name?: string
|
skill_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chat response from API */
|
/** Chat response from API */
|
||||||
export interface IChatResponse {
|
export interface IChatResponse {
|
||||||
conversation_id: string
|
conversation_id: string;
|
||||||
message: string
|
message: string;
|
||||||
matched_skill?: string
|
matched_skill?: string;
|
||||||
routing_method?: string
|
routing_method?: string;
|
||||||
confidence?: number
|
confidence?: number;
|
||||||
task_id?: string
|
task_id?: string;
|
||||||
status: 'completed' | 'pending'
|
status: "completed" | "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tool call data within a message */
|
/** Tool call data within a message */
|
||||||
export interface IToolCallData {
|
export interface IToolCallData {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
status: 'pending' | 'running' | 'completed' | 'error'
|
status: "pending" | "running" | "completed" | "error";
|
||||||
params?: string
|
params?: string;
|
||||||
result?: string
|
result?: string;
|
||||||
error?: string
|
error?: string;
|
||||||
duration?: number
|
duration?: number;
|
||||||
children?: IToolCallData[]
|
children?: IToolCallData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single chat message */
|
/** Single chat message */
|
||||||
export interface IChatMessage {
|
export interface IChatMessage {
|
||||||
id: string
|
id: string;
|
||||||
role: 'user' | 'assistant'
|
role: "user" | "assistant";
|
||||||
content: string
|
content: string;
|
||||||
timestamp: string
|
timestamp: string;
|
||||||
matched_skill?: string
|
matched_skill?: string;
|
||||||
routing_method?: string
|
routing_method?: string;
|
||||||
confidence?: number
|
confidence?: number;
|
||||||
task_id?: string
|
task_id?: string;
|
||||||
status?: 'completed' | 'pending' | 'error' | 'streaming'
|
status?: "completed" | "pending" | "error" | "streaming";
|
||||||
tool_calls?: IToolCallData[]
|
tool_calls?: IToolCallData[];
|
||||||
thinking?: string
|
thinking?: string;
|
||||||
expert_id?: string
|
expert_id?: string;
|
||||||
expert_name?: string
|
expert_name?: string;
|
||||||
expert_color?: string
|
expert_color?: string;
|
||||||
expert_avatar?: string
|
expert_avatar?: string;
|
||||||
message_type?:
|
message_type?:
|
||||||
| 'chat'
|
| "chat"
|
||||||
| 'handoff'
|
| "handoff"
|
||||||
| 'assist_request'
|
| "assist_request"
|
||||||
| 'plan_update'
|
| "plan_update"
|
||||||
| 'milestone'
|
| "milestone"
|
||||||
| 'board_started'
|
| "board_started"
|
||||||
| 'board_speech'
|
| "board_speech"
|
||||||
| 'board_summary'
|
| "board_summary"
|
||||||
| 'board_conclusion'
|
| "board_conclusion"
|
||||||
| 'debate_started'
|
| "debate_started"
|
||||||
| 'debate_argument'
|
| "debate_argument"
|
||||||
| 'debate_summary'
|
| "debate_summary"
|
||||||
| 'debate_resolved'
|
| "debate_resolved"
|
||||||
| 'collaboration_graph'
|
| "collaboration_graph"
|
||||||
| 'review_result'
|
| "review_result"
|
||||||
| 'risk_flagged'
|
| "risk_flagged"
|
||||||
| 'error'
|
| "error";
|
||||||
board_round?: number
|
board_round?: number;
|
||||||
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
|
board_role?: "moderator" | "expert" | "user" | "summary";
|
||||||
plan_phases?: ITeamPlanPhase[]
|
plan_phases?: ITeamPlanPhase[];
|
||||||
error_detail?: string
|
error_detail?: string;
|
||||||
board_started?: IBoardStartedData
|
board_started?: IBoardStartedData;
|
||||||
board_conclusion?: IBoardConcludedData
|
board_conclusion?: IBoardConcludedData;
|
||||||
debate_topic?: string
|
debate_topic?: string;
|
||||||
debate_round?: number
|
debate_round?: number;
|
||||||
debate_decision?: string
|
debate_decision?: string;
|
||||||
debate_rationale?: string
|
debate_rationale?: string;
|
||||||
debate_participants?: string[]
|
debate_participants?: string[];
|
||||||
debate_opening?: string
|
debate_opening?: string;
|
||||||
debate_moderator?: string
|
debate_moderator?: string;
|
||||||
/** U5: PM collaboration — aggregated graph data for CollaborationGraphCard */
|
/** U5: PM collaboration — aggregated graph data for CollaborationGraphCard */
|
||||||
collaboration_graph?: ICollaborationGraphData
|
collaboration_graph?: ICollaborationGraphData;
|
||||||
/** U5: PM collaboration — review result for ReviewResultCard */
|
/** U5: PM collaboration — review result for ReviewResultCard */
|
||||||
review_result?: IReviewResult
|
review_result?: IReviewResult;
|
||||||
/** U5: PM collaboration — risk flag for RiskFlagCard */
|
/** U5: PM collaboration — risk flag for RiskFlagCard */
|
||||||
risk_flag?: IRiskFlag
|
risk_flag?: IRiskFlag;
|
||||||
/** U4: synthesis identifier for streaming milestone dedup (team_synthesis_chunk/team_synthesis) */
|
/** U4: synthesis identifier for streaming milestone dedup (team_synthesis_chunk/team_synthesis) */
|
||||||
synthesis_id?: string
|
synthesis_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Conversation with messages */
|
/** Conversation with messages */
|
||||||
export interface IConversation {
|
export interface IConversation {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
messages: IChatMessage[]
|
messages: IChatMessage[];
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
/** 仅本地存在、尚未同步到服务端的临时会话 */
|
/** 仅本地存在、尚未同步到服务端的临时会话 */
|
||||||
is_local?: boolean
|
is_local?: boolean;
|
||||||
|
/** True if this conversation ever ran a @board meeting (board_started persisted).
|
||||||
|
* Backed by the server-side `is_board` field in
|
||||||
|
* /api/v1/portal/conversations so the sidebar can show a "私董会" badge
|
||||||
|
* without loading every conversation's full history. */
|
||||||
|
is_board?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capability info */
|
/** Capability info */
|
||||||
export interface ICapabilityInfo {
|
export interface ICapabilityInfo {
|
||||||
name: string
|
name: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
description: string
|
description: string;
|
||||||
icon: string
|
icon: string;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
skill_count: number
|
skill_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capabilities response */
|
/** Capabilities response */
|
||||||
export interface ICapabilitiesResponse {
|
export interface ICapabilitiesResponse {
|
||||||
capabilities: ICapabilityInfo[]
|
capabilities: ICapabilityInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WebSocket client message types */
|
/** WebSocket client message types */
|
||||||
export type WsClientMessage = {
|
export type WsClientMessage =
|
||||||
type: 'chat'
|
| {
|
||||||
message: string
|
type: "chat";
|
||||||
sources?: string[]
|
message: string;
|
||||||
conversation_id?: string
|
sources?: string[];
|
||||||
model?: string
|
conversation_id?: string;
|
||||||
} | {
|
model?: string;
|
||||||
type: 'resume'
|
|
||||||
task_id: string
|
|
||||||
conversation_id?: string
|
|
||||||
} | {
|
|
||||||
type: 'cancel'
|
|
||||||
task_id?: string
|
|
||||||
} | {
|
|
||||||
type: 'ping'
|
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "resume";
|
||||||
|
task_id: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "cancel";
|
||||||
|
task_id?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ping";
|
||||||
|
};
|
||||||
|
|
||||||
/** WebSocket server message types — matches backend portal.py protocol */
|
/** WebSocket server message types — matches backend portal.py protocol */
|
||||||
export type WsServerMessage =
|
export type WsServerMessage =
|
||||||
| { type: 'connected'; conversation_id: string }
|
| { type: "connected"; conversation_id: string }
|
||||||
| { type: 'routing'; skill: string; confidence: number; method: string }
|
| { type: "routing"; skill: string; confidence: number; method: string }
|
||||||
| { type: 'step'; data: { event_type: string; step: number; data: Record<string, unknown>; timestamp: string } }
|
| {
|
||||||
| { type: 'result'; data: { message?: string; content?: string; status?: string } }
|
type: "step";
|
||||||
| { type: 'error'; data: { message: string; code?: string } }
|
data: {
|
||||||
| { type: 'pong' }
|
event_type: string;
|
||||||
| { type: 'team_formed'; data: IExpertTeamState }
|
step: number;
|
||||||
| { type: 'expert_step'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; step: string } }
|
data: Record<string, unknown>;
|
||||||
| { type: 'expert_result_chunk'; data: { expert_id: string; content: string } }
|
timestamp: string;
|
||||||
| { type: 'expert_result_chunk_reset'; data: { expert_id: string; phase_id?: string } }
|
};
|
||||||
| { type: 'expert_result'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; status?: 'completed' | 'error'; phase_id?: string; rework_attempt?: number; error?: string } }
|
}
|
||||||
| { type: 'plan_update'; data: { plan_phases: ITeamPlanPhase[] } }
|
| {
|
||||||
| { type: 'phase_started'; data: { phase_id: string; phase_name: string; assigned_expert: string; depends_on: string[] } }
|
type: "result";
|
||||||
| { type: 'phase_completed'; data: { phase_id: string; phase_name: string; result_summary: string } }
|
data: { message?: string; content?: string; status?: string };
|
||||||
| { type: 'phase_failed'; data: { phase_id: string; phase_name: string; error: string } }
|
}
|
||||||
|
| { type: "error"; data: { message: string; code?: string } }
|
||||||
|
| { type: "pong" }
|
||||||
|
| { type: "team_formed"; data: IExpertTeamState }
|
||||||
|
| {
|
||||||
|
type: "expert_step";
|
||||||
|
data: {
|
||||||
|
expert_id: string;
|
||||||
|
expert_name: string;
|
||||||
|
expert_color: string;
|
||||||
|
content: string;
|
||||||
|
step: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "expert_result_chunk";
|
||||||
|
data: { expert_id: string; content: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "expert_result_chunk_reset";
|
||||||
|
data: { expert_id: string; phase_id?: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "expert_result";
|
||||||
|
data: {
|
||||||
|
expert_id: string;
|
||||||
|
expert_name: string;
|
||||||
|
expert_color: string;
|
||||||
|
content: string;
|
||||||
|
status?: "completed" | "error";
|
||||||
|
phase_id?: string;
|
||||||
|
rework_attempt?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "plan_update"; data: { plan_phases: ITeamPlanPhase[] } }
|
||||||
|
| {
|
||||||
|
type: "phase_started";
|
||||||
|
data: {
|
||||||
|
phase_id: string;
|
||||||
|
phase_name: string;
|
||||||
|
assigned_expert: string;
|
||||||
|
depends_on: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "phase_completed";
|
||||||
|
data: { phase_id: string; phase_name: string; result_summary: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "phase_failed";
|
||||||
|
data: { phase_id: string; phase_name: string; error: string };
|
||||||
|
}
|
||||||
// PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine.
|
// PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine.
|
||||||
| { type: 'phase_changed'; data: { phase: string; previous: string } }
|
| { type: "phase_changed"; data: { phase: string; previous: string } }
|
||||||
| { type: 'phase_violation'; data: { current_phase: string; tool: string; message: string; violation_kind: string; command_preview?: string } }
|
| {
|
||||||
| { type: 'team_synthesis_chunk'; data: { chunk: string; synthesis_id?: string } }
|
type: "phase_violation";
|
||||||
| { type: 'team_synthesis'; data: { content: string; phases_completed?: number; phases_total?: number; synthesis_id?: string; status?: 'completed' | 'error' | 'cancelled'; error?: string } }
|
data: {
|
||||||
| { type: 'team_dissolved'; data: { team_id: string } }
|
current_phase: string;
|
||||||
|
tool: string;
|
||||||
|
message: string;
|
||||||
|
violation_kind: string;
|
||||||
|
command_preview?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "team_synthesis_chunk";
|
||||||
|
data: { chunk: string; synthesis_id?: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "team_synthesis";
|
||||||
|
data: {
|
||||||
|
content: string;
|
||||||
|
phases_completed?: number;
|
||||||
|
phases_total?: number;
|
||||||
|
synthesis_id?: string;
|
||||||
|
status?: "completed" | "error" | "cancelled";
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "team_dissolved"; data: { team_id: string } }
|
||||||
// Board Meeting 模式事件
|
// Board Meeting 模式事件
|
||||||
| { type: 'board_started'; data: IBoardStartedData }
|
| { type: "board_started"; data: IBoardStartedData }
|
||||||
| { type: 'expert_speech'; data: IExpertSpeechData }
|
| { type: "expert_speech"; data: IExpertSpeechData }
|
||||||
| { type: 'round_summary'; data: IRoundSummaryData }
|
| { type: "expert_speech_chunk"; data: IExpertSpeechData }
|
||||||
| { type: 'user_intervention'; data: IUserInterventionData }
|
| { type: "round_summary"; data: IRoundSummaryData }
|
||||||
| { type: 'board_concluded'; data: IBoardConcludedData }
|
| { type: "user_intervention"; data: IUserInterventionData }
|
||||||
|
| { type: "board_concluded"; data: IBoardConcludedData }
|
||||||
// Debate (U5) 事件
|
// Debate (U5) 事件
|
||||||
| { type: 'debate_started'; data: IDebateStartedData }
|
| { type: "debate_started"; data: IDebateStartedData }
|
||||||
| { type: 'expert_argument'; data: IDebateArgumentData }
|
| { type: "expert_argument"; data: IDebateArgumentData }
|
||||||
| { type: 'debate_round_summary'; data: IDebateRoundSummaryData }
|
| { type: "debate_round_summary"; data: IDebateRoundSummaryData }
|
||||||
| { type: 'debate_resolved'; data: IDebateResolvedData }
|
| { type: "debate_resolved"; data: IDebateResolvedData }
|
||||||
| { type: 'team_intervention_ack'; data: { content: string } }
|
| { type: "team_intervention_ack"; data: { content: string } }
|
||||||
// PM Collaboration (U5) 事件
|
// PM Collaboration (U5) 事件
|
||||||
| { type: 'collaboration_contract_defined'; data: ICollaborationContractDefinedData }
|
| {
|
||||||
| { type: 'collaboration_notice'; data: ICollaborationNotice }
|
type: "collaboration_contract_defined";
|
||||||
| { type: 'review_result'; data: IReviewResult }
|
data: ICollaborationContractDefinedData;
|
||||||
| { type: 'risk_flagged'; data: IRiskFlag }
|
}
|
||||||
|
| { type: "collaboration_notice"; data: ICollaborationNotice }
|
||||||
|
| { type: "review_result"; data: IReviewResult }
|
||||||
|
| { type: "risk_flagged"; data: IRiskFlag }
|
||||||
// Calendar 事件 (KTD-10 — piggyback on chat WS)
|
// Calendar 事件 (KTD-10 — piggyback on chat WS)
|
||||||
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
|
| { type: "calendar_event_created"; data: ICalendarEventCreatedData }
|
||||||
| { type: 'calendar_reminder'; data: ICalendarReminderData }
|
| { type: "calendar_reminder"; data: ICalendarReminderData }
|
||||||
| { type: 'calendar_invitation'; data: ICalendarInvitationData }
|
| { type: "calendar_invitation"; data: ICalendarInvitationData }
|
||||||
| { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData }
|
| { type: "calendar_sync_conflict"; data: ICalendarSyncConflictData };
|
||||||
|
|
||||||
/** Expert info within a team */
|
/** Expert info within a team */
|
||||||
export interface IExpertInfo {
|
export interface IExpertInfo {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
persona: string
|
persona: string;
|
||||||
avatar: string
|
avatar: string;
|
||||||
color: string
|
color: string;
|
||||||
is_lead: boolean
|
is_lead: boolean;
|
||||||
bound_skills: string[]
|
bound_skills: string[];
|
||||||
status: 'active' | 'inactive'
|
status: "active" | "inactive";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A phase within a team plan */
|
/** A phase within a team plan */
|
||||||
export interface ITeamPlanPhase {
|
export interface ITeamPlanPhase {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
assigned_expert: string
|
assigned_expert: string;
|
||||||
task_description?: string
|
task_description?: string;
|
||||||
depends_on: string[]
|
depends_on: string[];
|
||||||
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
status: "pending" | "in_progress" | "completed" | "failed";
|
||||||
result?: string
|
result?: string;
|
||||||
parallel_type?: 'serial' | 'subtask_parallel' | 'competitive_parallel'
|
parallel_type?: "serial" | "subtask_parallel" | "competitive_parallel";
|
||||||
milestone?: string
|
milestone?: string;
|
||||||
/** U5: PM collaboration — contracts defined by Lead for this phase */
|
/** U5: PM collaboration — contracts defined by Lead for this phase */
|
||||||
collaboration_contracts?: ICollaborationContract[]
|
collaboration_contracts?: ICollaborationContract[];
|
||||||
/** U5: PM collaboration — rework count after Lead review failures */
|
/** U5: PM collaboration — rework count after Lead review failures */
|
||||||
rework_count?: number
|
rework_count?: number;
|
||||||
/** U5: PM collaboration — Lead review feedback (modification requirements) */
|
/** U5: PM collaboration — Lead review feedback (modification requirements) */
|
||||||
review_feedback?: string | null
|
review_feedback?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expert team state */
|
/** Expert team state */
|
||||||
export interface IExpertTeamState {
|
export interface IExpertTeamState {
|
||||||
team_id: string
|
team_id: string;
|
||||||
status: 'forming' | 'planning' | 'executing' | 'synthesizing' | 'completed' | 'dissolved'
|
status:
|
||||||
experts: IExpertInfo[]
|
| "forming"
|
||||||
plan_phases: ITeamPlanPhase[]
|
| "planning"
|
||||||
lead_expert: string
|
| "executing"
|
||||||
|
| "synthesizing"
|
||||||
|
| "completed"
|
||||||
|
| "dissolved";
|
||||||
|
experts: IExpertInfo[];
|
||||||
|
plan_phases: ITeamPlanPhase[];
|
||||||
|
lead_expert: string;
|
||||||
/** U2: 团队级任务目标摘要(可选,后端 team_formed 事件未发送时回退到首阶段描述) */
|
/** U2: 团队级任务目标摘要(可选,后端 team_formed 事件未发送时回退到首阶段描述) */
|
||||||
task_description?: string
|
task_description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Board Meeting 模式类型 ────────────────────────────────────────────
|
// ── Board Meeting 模式类型 ────────────────────────────────────────────
|
||||||
|
|
||||||
/** Board meeting expert info (lighter than IExpertInfo) */
|
/** Board meeting expert info (lighter than IExpertInfo) */
|
||||||
export interface IBoardExpert {
|
export interface IBoardExpert {
|
||||||
name: string
|
name: string;
|
||||||
avatar: string
|
avatar: string;
|
||||||
color: string
|
color: string;
|
||||||
is_moderator: boolean
|
is_moderator: boolean;
|
||||||
persona: string
|
persona: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** board_started event payload */
|
/** board_started event payload */
|
||||||
export interface IBoardStartedData {
|
export interface IBoardStartedData {
|
||||||
team_id: string
|
team_id: string;
|
||||||
topic: string
|
topic: string;
|
||||||
experts: IBoardExpert[]
|
experts: IBoardExpert[];
|
||||||
max_rounds: number
|
max_rounds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** expert_speech event payload */
|
/** expert_speech event payload */
|
||||||
export interface IExpertSpeechData {
|
export interface IExpertSpeechData {
|
||||||
expert_name: string
|
expert_name: string;
|
||||||
expert_avatar: string
|
expert_avatar: string;
|
||||||
expert_color: string
|
expert_color: string;
|
||||||
content: string
|
content: string;
|
||||||
round: number
|
round: number;
|
||||||
role: 'moderator' | 'expert'
|
role: "moderator" | "expert";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** round_summary event payload */
|
/** round_summary event payload */
|
||||||
export interface IRoundSummaryData {
|
export interface IRoundSummaryData {
|
||||||
moderator_name: string
|
moderator_name: string;
|
||||||
content: string
|
content: string;
|
||||||
round: number
|
round: number;
|
||||||
continue: boolean
|
continue: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** user_intervention event payload */
|
/** user_intervention event payload */
|
||||||
export interface IUserInterventionData {
|
export interface IUserInterventionData {
|
||||||
content: string
|
content: string;
|
||||||
round: number
|
round: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** board_concluded event payload */
|
/** board_concluded event payload */
|
||||||
export interface IBoardConcludedData {
|
export interface IBoardConcludedData {
|
||||||
summary: string
|
summary: string;
|
||||||
decision_advice: string
|
decision_advice: string;
|
||||||
total_rounds: number
|
total_rounds: number;
|
||||||
consensus_points: string[]
|
consensus_points: string[];
|
||||||
dissent_points: string[]
|
dissent_points: string[];
|
||||||
error?: string
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Debate (U5) 模式类型 ──────────────────────────────────────────────
|
// ── Debate (U5) 模式类型 ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** debate_started event payload */
|
/** debate_started event payload */
|
||||||
export interface IDebateStartedData {
|
export interface IDebateStartedData {
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
phase_name: string
|
phase_name: string;
|
||||||
topic: string
|
topic: string;
|
||||||
participants: string[]
|
participants: string[];
|
||||||
max_rounds: number
|
max_rounds: number;
|
||||||
opening: string
|
opening: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** expert_argument event payload */
|
/** expert_argument event payload */
|
||||||
export interface IDebateArgumentData {
|
export interface IDebateArgumentData {
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
expert_id: string
|
expert_id: string;
|
||||||
expert_name: string
|
expert_name: string;
|
||||||
expert_color: string
|
expert_color: string;
|
||||||
content: string
|
content: string;
|
||||||
round: number
|
round: number;
|
||||||
topic: string
|
topic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** debate_round_summary event payload */
|
/** debate_round_summary event payload */
|
||||||
export interface IDebateRoundSummaryData {
|
export interface IDebateRoundSummaryData {
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
moderator_name: string
|
moderator_name: string;
|
||||||
content: string
|
content: string;
|
||||||
round: number
|
round: number;
|
||||||
continue: boolean
|
continue: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** debate_resolved event payload */
|
/** debate_resolved event payload */
|
||||||
export interface IDebateResolvedData {
|
export interface IDebateResolvedData {
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
phase_name: string
|
phase_name: string;
|
||||||
decision: 'adopt' | 'compromise' | 'shelve' | 'inconclusive'
|
decision: "adopt" | "compromise" | "shelve" | "inconclusive";
|
||||||
conclusion: string
|
conclusion: string;
|
||||||
rationale: string
|
rationale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PM Collaboration (U5) 模式类型 ──────────────────────────────────
|
// ── PM Collaboration (U5) 模式类型 ──────────────────────────────────
|
||||||
|
|
||||||
/** 协作契约 — 匹配后端 CollaborationContract.to_dict() */
|
/** 协作契约 — 匹配后端 CollaborationContract.to_dict() */
|
||||||
export interface ICollaborationContract {
|
export interface ICollaborationContract {
|
||||||
from_expert: string
|
from_expert: string;
|
||||||
to_expert: string
|
to_expert: string;
|
||||||
content_description: string
|
content_description: string;
|
||||||
status: 'pending' | 'delivered' | 'received'
|
status: "pending" | "delivered" | "received";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** collaboration_contract_defined event payload
|
/** collaboration_contract_defined event payload
|
||||||
* (后端当前通过 plan_update 的 plan_phases[].collaboration_contracts 携带,
|
* (后端当前通过 plan_update 的 plan_phases[].collaboration_contracts 携带,
|
||||||
* 此类型用于可能的独立事件和类型完整性) */
|
* 此类型用于可能的独立事件和类型完整性) */
|
||||||
export interface ICollaborationContractDefinedData {
|
export interface ICollaborationContractDefinedData {
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
phase_name: string
|
phase_name: string;
|
||||||
contracts: ICollaborationContract[]
|
contracts: ICollaborationContract[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** collaboration_notice event payload — 专家完成后按契约通知相关专家 */
|
/** collaboration_notice event payload — 专家完成后按契约通知相关专家 */
|
||||||
export interface ICollaborationNotice {
|
export interface ICollaborationNotice {
|
||||||
from_expert: string
|
from_expert: string;
|
||||||
to_expert: string
|
to_expert: string;
|
||||||
content_description: string
|
content_description: string;
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
phase_name: string
|
phase_name: string;
|
||||||
output_key: string
|
output_key: string;
|
||||||
expert_color: string
|
expert_color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** review_result event payload — Lead 验收阶段输出 */
|
/** review_result event payload — Lead 验收阶段输出 */
|
||||||
export interface IReviewResult {
|
export interface IReviewResult {
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
phase_name: string
|
phase_name: string;
|
||||||
passed: boolean
|
passed: boolean;
|
||||||
feedback: string
|
feedback: string;
|
||||||
expert: string
|
expert: string;
|
||||||
rework_count?: number
|
rework_count?: number;
|
||||||
final_status?: 'rework' | 'failed'
|
final_status?: "rework" | "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** risk_flagged event payload — 专家风险标记 */
|
/** risk_flagged event payload — 专家风险标记 */
|
||||||
export interface IRiskFlag {
|
export interface IRiskFlag {
|
||||||
expert: string
|
expert: string;
|
||||||
expert_name: string
|
expert_name: string;
|
||||||
risk_description: string
|
risk_description: string;
|
||||||
phase_id: string
|
phase_id: string;
|
||||||
phase_name: string
|
phase_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 协作关系图聚合数据 — 存储在 collaboration_graph 消息中,随事件实时更新 */
|
/** 协作关系图聚合数据 — 存储在 collaboration_graph 消息中,随事件实时更新 */
|
||||||
export interface ICollaborationGraphData {
|
export interface ICollaborationGraphData {
|
||||||
contracts: Array<ICollaborationContract & { phase_id: string; phase_name: string }>
|
contracts: Array<
|
||||||
notices: ICollaborationNotice[]
|
ICollaborationContract & { phase_id: string; phase_name: string }
|
||||||
reviews: IReviewResult[]
|
>;
|
||||||
risks: IRiskFlag[]
|
notices: ICollaborationNotice[];
|
||||||
|
reviews: IReviewResult[];
|
||||||
|
risks: IRiskFlag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Board meeting status (matches backend BoardStatus enum) */
|
/** Board meeting status (matches backend BoardStatus enum) */
|
||||||
export type BoardStatus = 'forming' | 'discussing' | 'concluding' | 'completed' | 'dissolved'
|
export type BoardStatus =
|
||||||
|
| "forming"
|
||||||
|
| "discussing"
|
||||||
|
| "concluding"
|
||||||
|
| "completed"
|
||||||
|
| "dissolved";
|
||||||
|
|
||||||
/** Board message entry for group chat display */
|
/** Board message entry for group chat display */
|
||||||
export interface IBoardMessage {
|
export interface IBoardMessage {
|
||||||
id: string
|
id: string;
|
||||||
expert_name: string
|
expert_name: string;
|
||||||
expert_avatar: string
|
expert_avatar: string;
|
||||||
expert_color: string
|
expert_color: string;
|
||||||
content: string
|
content: string;
|
||||||
round: number
|
round: number;
|
||||||
role: 'moderator' | 'expert' | 'user' | 'summary'
|
role: "moderator" | "expert" | "user" | "summary";
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Calendar WS 事件 payload 类型 ───────────────────────────────────
|
// ── Calendar WS 事件 payload 类型 ───────────────────────────────────
|
||||||
|
|
||||||
/** calendar_event_created payload */
|
/** calendar_event_created payload */
|
||||||
export interface ICalendarEventCreatedData {
|
export interface ICalendarEventCreatedData {
|
||||||
event: ICalendarEvent
|
event: ICalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** calendar_reminder payload */
|
/** calendar_reminder payload */
|
||||||
export interface ICalendarReminderData {
|
export interface ICalendarReminderData {
|
||||||
event_id: string
|
event_id: string;
|
||||||
title: string
|
title: string;
|
||||||
start_time: string
|
start_time: string;
|
||||||
offset_minutes: number
|
offset_minutes: number;
|
||||||
channels: string[]
|
channels: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** calendar_invitation payload (G6) */
|
/** calendar_invitation payload (G6) */
|
||||||
export interface ICalendarInvitationData {
|
export interface ICalendarInvitationData {
|
||||||
invitation: IInvitation
|
invitation: IInvitation;
|
||||||
event_title: string
|
event_title: string;
|
||||||
inviter_name: string
|
inviter_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** calendar_sync_conflict payload (G4) */
|
/** calendar_sync_conflict payload (G4) */
|
||||||
export interface ICalendarSyncConflictData {
|
export interface ICalendarSyncConflictData {
|
||||||
event_id: string
|
event_id: string;
|
||||||
event_title: string
|
event_title: string;
|
||||||
provider: string
|
provider: string;
|
||||||
local_modified: string
|
local_modified: string;
|
||||||
remote_modified: string
|
remote_modified: string;
|
||||||
resolution: string
|
resolution: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expert template (matches backend GET /api/v1/experts response item) */
|
/** Expert template (matches backend GET /api/v1/experts response item) */
|
||||||
export interface IExpertTemplate {
|
export interface IExpertTemplate {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
is_builtin: boolean
|
is_builtin: boolean;
|
||||||
avatar: string
|
avatar: string;
|
||||||
color: string
|
color: string;
|
||||||
persona: string
|
persona: string;
|
||||||
thinking_style: string
|
thinking_style: string;
|
||||||
speaking_style: string
|
speaking_style: string;
|
||||||
decision_framework: string
|
decision_framework: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Experts list response (matches backend GET /api/v1/experts) */
|
/** Experts list response (matches backend GET /api/v1/experts) */
|
||||||
export interface IExpertsResponse {
|
export interface IExpertsResponse {
|
||||||
experts: IExpertTemplate[]
|
experts: IExpertTemplate[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** File upload response (matches backend POST /api/v1/chat/upload) */
|
/** File upload response (matches backend POST /api/v1/chat/upload) */
|
||||||
export interface IUploadResponse {
|
export interface IUploadResponse {
|
||||||
filename: string
|
filename: string;
|
||||||
stored_name: string
|
stored_name: string;
|
||||||
content_type: string
|
content_type: string;
|
||||||
size: number
|
size: number;
|
||||||
download_url: string
|
download_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API error */
|
/** API error */
|
||||||
export interface IApiError {
|
export interface IApiError {
|
||||||
status: number
|
status: number;
|
||||||
message: string
|
message: string;
|
||||||
detail?: string
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task status (matches backend TaskStatus enum) */
|
/** Task status (matches backend TaskStatus enum) */
|
||||||
export type TaskStatus = 'pending' | 'running' | 'completed' | 'partially_completed' | 'failed' | 'cancelled'
|
export type TaskStatus =
|
||||||
|
| "pending"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "partially_completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
/** Task record (matches backend TaskRecord.to_dict()) */
|
/** Task record (matches backend TaskRecord.to_dict()) */
|
||||||
export interface ITaskRecord {
|
export interface ITaskRecord {
|
||||||
task_id: string
|
task_id: string;
|
||||||
agent_name: string
|
agent_name: string;
|
||||||
skill_name: string | null
|
skill_name: string | null;
|
||||||
input_data: Record<string, unknown>
|
input_data: Record<string, unknown>;
|
||||||
status: TaskStatus
|
status: TaskStatus;
|
||||||
output_data: Record<string, unknown> | null
|
output_data: Record<string, unknown> | null;
|
||||||
error_message: string | null
|
error_message: string | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
started_at: string | null
|
started_at: string | null;
|
||||||
completed_at: string | null
|
completed_at: string | null;
|
||||||
progress: number
|
progress: number;
|
||||||
progress_message: string
|
progress_message: string;
|
||||||
metadata: Record<string, unknown>
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ function formatSize(bytes: number): string {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
color: var(--color-primary, #1677ff);
|
color: var(--color-primary, #1a1a1a);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -301,11 +301,11 @@ defineExpose({
|
||||||
}
|
}
|
||||||
|
|
||||||
.bitable-grid-scope :deep(.vxe-body--column.is--dirty) {
|
.bitable-grid-scope :deep(.vxe-body--column.is--dirty) {
|
||||||
background: var(--bg-tertiary, #fffbe6);
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bitable-grid-scope :deep(.vxe-cell--dirty) {
|
.bitable-grid-scope :deep(.vxe-cell--dirty) {
|
||||||
color: var(--color-primary, #1677ff);
|
color: var(--color-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bitable-grid-scope__add-col {
|
.bitable-grid-scope__add-col {
|
||||||
|
|
@ -321,6 +321,6 @@ defineExpose({
|
||||||
}
|
}
|
||||||
|
|
||||||
.bitable-grid-scope__add-col:hover {
|
.bitable-grid-scope__add-col:hover {
|
||||||
color: var(--color-primary, #1677ff);
|
color: var(--color-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<span class="field-manage-panel__item-name">{{ f.name }}</span>
|
<span class="field-manage-panel__item-name">{{ f.name }}</span>
|
||||||
<div class="field-manage-panel__item-meta">
|
<div class="field-manage-panel__item-meta">
|
||||||
<a-tag :color="typeColor(f.field_type)">{{ typeLabel(f.field_type) }}</a-tag>
|
<a-tag :color="typeColor(f.field_type)">{{ typeLabel(f.field_type) }}</a-tag>
|
||||||
<a-tag :color="f.owner === 'agent' ? 'blue' : 'green'">
|
<a-tag :color="f.owner === 'agent' ? 'default' : 'success'">
|
||||||
{{ f.owner === 'agent' ? 'Agent' : '用户' }}
|
{{ f.owner === 'agent' ? 'Agent' : '用户' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<a-dropdown :trigger="['contextmenu']">
|
<a-dropdown :trigger="['contextmenu']">
|
||||||
<div class="file-card" @click="emit('open', file.id)">
|
<div class="file-card" @click="emit('open', file.id)">
|
||||||
<div class="file-card__icon">{{ file.icon }}</div>
|
<div class="file-card__icon">
|
||||||
|
<component :is="resolveBitableIcon(file.icon)" />
|
||||||
|
</div>
|
||||||
<div class="file-card__body">
|
<div class="file-card__body">
|
||||||
<div class="file-card__name" :title="file.name">{{ file.name }}</div>
|
<div class="file-card__name" :title="file.name">{{ file.name }}</div>
|
||||||
<div class="file-card__desc" :title="file.description">
|
<div class="file-card__desc" :title="file.description">
|
||||||
|
|
@ -28,6 +30,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { IBitableFile } from '@/api/bitable'
|
import type { IBitableFile } from '@/api/bitable'
|
||||||
|
import { resolveBitableIcon } from './bitableIcons'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
file: IBitableFile
|
file: IBitableFile
|
||||||
|
|
@ -60,13 +63,13 @@ function formatDate(iso: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-card:hover {
|
.file-card:hover {
|
||||||
border-color: var(--color-primary, #1677ff);
|
border-color: var(--color-primary, #1a1a1a);
|
||||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.12);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-card__icon {
|
.file-card__icon {
|
||||||
font-size: 32px;
|
font-size: 28px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|
@ -74,6 +77,7 @@ function formatDate(iso: string): string {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--color-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-card__body {
|
.file-card__body {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,28 @@
|
||||||
<a-form-item label="图标" name="icon">
|
<a-form-item label="图标" name="icon">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="formState.icon"
|
v-model:value="formState.icon"
|
||||||
:options="iconOptions"
|
:options="iconSelectOptions"
|
||||||
:show-search="false"
|
:show-search="false"
|
||||||
|
>
|
||||||
|
<template #option="{ value: optValue }">
|
||||||
|
<span class="file-create-modal__option">
|
||||||
|
<component
|
||||||
|
:is="resolveBitableIcon(optValue)"
|
||||||
|
class="file-create-modal__option-icon"
|
||||||
/>
|
/>
|
||||||
|
<span>{{ labelForKey(optValue) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #label="{ value: labelValue }">
|
||||||
|
<span class="file-create-modal__selected">
|
||||||
|
<component
|
||||||
|
:is="resolveBitableIcon(labelValue)"
|
||||||
|
class="file-create-modal__selected-icon"
|
||||||
|
/>
|
||||||
|
<span>{{ labelForKey(labelValue) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="文件名" name="name">
|
<a-form-item label="文件名" name="name">
|
||||||
<a-input
|
<a-input
|
||||||
|
|
@ -39,9 +58,15 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
import type { FormInstance } from 'ant-design-vue'
|
import type { FormInstance } from 'ant-design-vue'
|
||||||
import { useBitableStore } from '@/stores/bitable'
|
import { useBitableStore } from '@/stores/bitable'
|
||||||
|
import {
|
||||||
|
BITABLE_ICON_OPTIONS,
|
||||||
|
DEFAULT_BITABLE_ICON,
|
||||||
|
resolveBitableIcon,
|
||||||
|
type BitableIconKey,
|
||||||
|
} from './bitableIcons'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -56,20 +81,18 @@ const store = useBitableStore()
|
||||||
const formRef = ref<FormInstance | null>(null)
|
const formRef = ref<FormInstance | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const iconOptions = [
|
/** a-select options: value = icon key, label rendered through #option template. */
|
||||||
{ label: '📋 表格', value: '📋' },
|
const iconSelectOptions = computed(() =>
|
||||||
{ label: '📊 仪表盘', value: '📊' },
|
BITABLE_ICON_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||||
{ label: '📝 笔记', value: '📝' },
|
)
|
||||||
{ label: '🗂️ 项目', value: '🗂️' },
|
|
||||||
{ label: '📅 日程', value: '📅' },
|
function labelForKey(key: string): string {
|
||||||
{ label: '💼 工作', value: '💼' },
|
return BITABLE_ICON_OPTIONS.find((o) => o.value === key)?.label ?? key
|
||||||
{ label: '🎯 目标', value: '🎯' },
|
}
|
||||||
{ label: '📚 知识库', value: '📚' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const formState = reactive({
|
const formState = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
icon: '📋',
|
icon: DEFAULT_BITABLE_ICON as BitableIconKey,
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -85,7 +108,7 @@ watch(
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
formState.name = ''
|
formState.name = ''
|
||||||
formState.icon = '📋'
|
formState.icon = DEFAULT_BITABLE_ICON
|
||||||
formState.description = ''
|
formState.description = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -115,3 +138,18 @@ function handleCancel(): void {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-create-modal__option,
|
||||||
|
.file-create-modal__selected {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-create-modal__option-icon,
|
||||||
|
.file-create-modal__selected-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,8 @@ const emit = defineEmits<{
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-view-list__item.is-active {
|
.table-view-list__item.is-active {
|
||||||
background: var(--color-primary-bg, #e6f4ff);
|
background: var(--color-primary-bg, #fbfbfa);
|
||||||
color: var(--color-primary, #1677ff);
|
color: var(--color-primary, #1a1a1a);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Bitable icon registry — single source of truth for file-type icons.
|
||||||
|
*
|
||||||
|
* History
|
||||||
|
* -------
|
||||||
|
* Originally, the Bitable file ``icon`` field stored emoji glyphs (📋 📊 📝
|
||||||
|
* etc.). Those look fine in isolation but clash with the project's line-icon
|
||||||
|
* aesthetic everywhere else (Ant Design Vue ``Outlined`` family — Tabs,
|
||||||
|
* Sidebar, Tool call cards all use stroke-based 1.5px icons). The mixed
|
||||||
|
* style made Bitable feel like a foreign widget inside the same window.
|
||||||
|
*
|
||||||
|
* Decision
|
||||||
|
* --------
|
||||||
|
* The ``icon`` field is now a stable key (e.g. ``"table"``). The visual is
|
||||||
|
* resolved client-side through ``resolveBitableIcon`` so:
|
||||||
|
* - the wire format stays a plain ASCII string (URL-safe, log-friendly,
|
||||||
|
* not subject to font / OS emoji rendering),
|
||||||
|
* - all Bitable icons share the same line-icon style,
|
||||||
|
* - adding a new icon = one line in ``BITABLE_ICON_MAP``.
|
||||||
|
*
|
||||||
|
* Backward compatibility
|
||||||
|
* ----------------------
|
||||||
|
* Old data may still contain emoji strings (DB row written before this
|
||||||
|
* refactor). We fall back to ``TableOutlined`` for any unknown / non-mapped
|
||||||
|
* value — better a uniform default than a mismatched glyph.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AimOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
ContainerOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
TableOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
/** Stable, URL-safe identifiers used in the BitableFile.icon field. */
|
||||||
|
export type BitableIconKey =
|
||||||
|
| 'table'
|
||||||
|
| 'dashboard'
|
||||||
|
| 'note'
|
||||||
|
| 'project'
|
||||||
|
| 'calendar'
|
||||||
|
| 'briefcase'
|
||||||
|
| 'aim'
|
||||||
|
| 'book'
|
||||||
|
|
||||||
|
export const DEFAULT_BITABLE_ICON: BitableIconKey = 'table'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from stable icon key to its Ant Design Vue outlined component.
|
||||||
|
* Keep the keys aligned with the keys listed in
|
||||||
|
* ``BITABLE_ICON_OPTIONS`` (src/components/bitable) so the picker stays in
|
||||||
|
* sync with the renderer.
|
||||||
|
*/
|
||||||
|
export const BITABLE_ICON_MAP: Record<BitableIconKey, Component> = {
|
||||||
|
table: TableOutlined,
|
||||||
|
dashboard: DashboardOutlined,
|
||||||
|
note: FileTextOutlined,
|
||||||
|
project: AppstoreOutlined,
|
||||||
|
calendar: CalendarOutlined,
|
||||||
|
briefcase: ContainerOutlined,
|
||||||
|
aim: AimOutlined,
|
||||||
|
book: BookOutlined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable labels for the file-create picker. Paired with the keys
|
||||||
|
* above; order here defines the picker display order.
|
||||||
|
*/
|
||||||
|
export const BITABLE_ICON_OPTIONS: ReadonlyArray<{ value: BitableIconKey; label: string }> = [
|
||||||
|
{ value: 'table', label: '表格' },
|
||||||
|
{ value: 'dashboard', label: '仪表盘' },
|
||||||
|
{ value: 'note', label: '笔记' },
|
||||||
|
{ value: 'project', label: '项目' },
|
||||||
|
{ value: 'calendar', label: '日程' },
|
||||||
|
{ value: 'briefcase', label: '工作' },
|
||||||
|
{ value: 'aim', label: '目标' },
|
||||||
|
{ value: 'book', label: '知识库' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a stored icon value to its component. Unknown values
|
||||||
|
* (e.g. legacy emoji strings) silently fall back to ``DEFAULT_BITABLE_ICON``
|
||||||
|
* so the UI stays visually consistent across mixed-era data.
|
||||||
|
*/
|
||||||
|
export function resolveBitableIcon(stored: string | null | undefined): Component {
|
||||||
|
if (stored && stored in BITABLE_ICON_MAP) {
|
||||||
|
return BITABLE_ICON_MAP[stored as BitableIconKey]
|
||||||
|
}
|
||||||
|
return BITABLE_ICON_MAP[DEFAULT_BITABLE_ICON]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard: returns true when ``value`` is a known Bitable icon key.
|
||||||
|
* Use in form validators to reject arbitrary user input.
|
||||||
|
*/
|
||||||
|
export function isBitableIconKey(value: unknown): value is BitableIconKey {
|
||||||
|
return typeof value === 'string' && value in BITABLE_ICON_MAP
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,11 @@
|
||||||
>
|
>
|
||||||
<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
|
||||||
|
v-if="conv.is_board"
|
||||||
|
class="chat-sidebar__board-badge"
|
||||||
|
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
|
<a-popconfirm
|
||||||
title="确定删除此对话?"
|
title="确定删除此对话?"
|
||||||
|
|
@ -195,6 +200,20 @@ function formatRelativeTime(dateStr: string): string {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 私董会会话徽章 — 使用紫色 accent-board 标识,使用 token 避免硬编码 */
|
||||||
|
.chat-sidebar__board-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent-board-bg);
|
||||||
|
color: var(--accent-board);
|
||||||
|
border: 1px solid var(--accent-board-soft);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-time {
|
.chat-sidebar__item-time {
|
||||||
font-size: var(--font-xs);
|
font-size: var(--font-xs);
|
||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
|
|
@ -202,7 +221,9 @@ function formatRelativeTime(dateStr: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-delete {
|
.chat-sidebar__item-delete {
|
||||||
display: none;
|
/* 用 opacity + pointer-events 替代 display:none/flex,按钮始终占据空间,
|
||||||
|
* 避免 hover 时 flex 布局重排导致列表项抖动。 */
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
|
|
@ -214,11 +235,14 @@ function formatRelativeTime(dateStr: string): string {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
transition: all var(--transition-fast);
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item:hover .chat-sidebar__item-delete {
|
.chat-sidebar__item:hover .chat-sidebar__item-delete {
|
||||||
display: flex;
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-delete:hover {
|
.chat-sidebar__item-delete:hover {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Stable expert identity — each expert name maps to a consistent avatar/colour
|
||||||
|
// across rounds. Used to dedup board_speech messages and to provide friendly
|
||||||
|
// fallbacks when the YAML config has no avatar/colour (ExpertConfig defaults
|
||||||
|
// to ``#1890ff`` blue, which conflicts with the platform's "no blue" rule).
|
||||||
|
//
|
||||||
|
// ponytail: a small 12-colour palette is enough for board meetings
|
||||||
|
// (MAX_EXPERTS=10). Index is name-hash-mod-palette-length, so the same name
|
||||||
|
// always picks the same colour across reloads.
|
||||||
|
|
||||||
|
export interface ExpertIdentity {
|
||||||
|
/** Single-character avatar glyph (CJK/ASCII first char of name). */
|
||||||
|
avatar: string
|
||||||
|
/** Background colour for the avatar circle and the expert badge. */
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-blue palette. Order is stable — do not reshuffle (would break identity). */
|
||||||
|
const PALETTE: ReadonlyArray<string> = [
|
||||||
|
"#d97706", // amber 600
|
||||||
|
"#059669", // emerald 600
|
||||||
|
"#7c3aed", // violet 600
|
||||||
|
"#db2777", // pink 600
|
||||||
|
"#0e7490", // cyan 700
|
||||||
|
"#65a30d", // lime 600
|
||||||
|
"#c2410c", // orange 700
|
||||||
|
"#9333ea", // purple 600
|
||||||
|
"#0891b2", // sky 600
|
||||||
|
"#a16207", // yellow 700
|
||||||
|
"#be185d", // rose 700
|
||||||
|
"#15803d", // green 700
|
||||||
|
]
|
||||||
|
|
||||||
|
/** djb2-style string hash — stable across JS engines and reloads. */
|
||||||
|
function hashName(name: string): number {
|
||||||
|
let h = 5381
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
h = ((h << 5) + h + name.charCodeAt(i)) >>> 0
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_AVATAR = "?"
|
||||||
|
|
||||||
|
/** Derive a stable {avatar, color} for an expert name. */
|
||||||
|
export function pickExpertIdentity(name: string): ExpertIdentity {
|
||||||
|
const trimmed = (name || "").trim()
|
||||||
|
if (!trimmed) return { avatar: DEFAULT_AVATAR, color: PALETTE[0] }
|
||||||
|
// First non-underscore char makes a more recognisable avatar.
|
||||||
|
const avatar = trimmed
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.map((p) => p[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 1)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || trimmed[0].toUpperCase()
|
||||||
|
return { avatar, color: PALETTE[hashName(trimmed) % PALETTE.length] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: same identity regardless of which fields are missing in source. */
|
||||||
|
export function resolveExpertIdentity(
|
||||||
|
name: string | undefined,
|
||||||
|
avatar?: string,
|
||||||
|
color?: string,
|
||||||
|
): ExpertIdentity {
|
||||||
|
const fallback = pickExpertIdentity(name || "")
|
||||||
|
return {
|
||||||
|
avatar: avatar && avatar.trim() ? avatar : fallback.avatar,
|
||||||
|
color: color && /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : fallback.color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,14 +54,19 @@ export function resolveMessageType(message: IChatMessage): MessageViewType {
|
||||||
switch (message.message_type) {
|
switch (message.message_type) {
|
||||||
case 'plan_update':
|
case 'plan_update':
|
||||||
return 'team_plan'
|
return 'team_plan'
|
||||||
|
// 2026-07-01: board_* events render as plain assistant bubbles (streaming).
|
||||||
|
// The user's first @board/@team message already shows a structured card
|
||||||
|
// (UserBubble) with topic + expert count + expert list; rendering
|
||||||
|
// dedicated cards (BoardBannerCard / BoardRoundCard / BoardConclusionCard)
|
||||||
|
// for the subsequent board_started/board_speech/board_summary/
|
||||||
|
// board_conclusion events duplicates the banner and breaks the natural
|
||||||
|
// chat flow. Fall through to 'assistant' so the content streams
|
||||||
|
// inline.
|
||||||
case 'board_started':
|
case 'board_started':
|
||||||
return 'board_banner'
|
|
||||||
case 'board_speech':
|
case 'board_speech':
|
||||||
return 'board_speech'
|
|
||||||
case 'board_summary':
|
case 'board_summary':
|
||||||
return 'board_summary'
|
|
||||||
case 'board_conclusion':
|
case 'board_conclusion':
|
||||||
return 'board_conclusion'
|
return 'assistant'
|
||||||
case 'debate_started':
|
case 'debate_started':
|
||||||
return 'debate_started'
|
return 'debate_started'
|
||||||
case 'debate_argument':
|
case 'debate_argument':
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
class="team-plan-card__lead-avatar"
|
class="team-plan-card__lead-avatar"
|
||||||
:style="leadColor ? { background: leadColor } : undefined"
|
:style="leadColor ? { background: leadColor } : undefined"
|
||||||
>
|
>
|
||||||
{{ leadAvatar }}
|
{{ leadInitials }}
|
||||||
</span>
|
</span>
|
||||||
<span class="team-plan-card__label">
|
<span class="team-plan-card__label">
|
||||||
{{ leadName }}<span class="team-plan-card__label-suffix">· 专家团计划</span>
|
{{ leadName }}<span class="team-plan-card__label-suffix">· 专家团计划</span>
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
:class="`team-plan-card__phase--${phase.status}`"
|
:class="`team-plan-card__phase--${phase.status}`"
|
||||||
>
|
>
|
||||||
<span class="team-plan-card__phase-dot" aria-hidden="true">
|
<span class="team-plan-card__phase-dot" aria-hidden="true">
|
||||||
{{ statusIcon(phase.status) }}
|
<component :is="statusIconComponent(phase.status)" />
|
||||||
</span>
|
</span>
|
||||||
<div class="team-plan-card__phase-info">
|
<div class="team-plan-card__phase-info">
|
||||||
<span class="team-plan-card__phase-name" :title="phase.task_description || phase.name">
|
<span class="team-plan-card__phase-name" :title="phase.task_description || phase.name">
|
||||||
|
|
@ -51,19 +51,50 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, type Component } from 'vue'
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
MinusOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import type { ITeamPlanPhase } from '@/api/types'
|
import type { ITeamPlanPhase } from '@/api/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
phases: ITeamPlanPhase[]
|
phases: ITeamPlanPhase[]
|
||||||
leadName?: string
|
leadName?: string
|
||||||
|
/** Optional avatar string (kept for backward compat with backend
|
||||||
|
* payloads that still pass emoji glyphs). When non-empty, takes
|
||||||
|
* precedence over the initials-based fallback so any pre-rendered
|
||||||
|
* avatar asset from the upstream pipeline is preserved. */
|
||||||
leadAvatar?: string
|
leadAvatar?: string
|
||||||
leadColor?: string
|
leadColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
leadName: 'Lead',
|
leadName: 'Lead',
|
||||||
leadAvatar: '🧑💼',
|
leadAvatar: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar glyph shown inside the 22x22 pill in the card header.
|
||||||
|
*
|
||||||
|
* 2026-07-01: switched the default from the business-person emoji
|
||||||
|
* (``🧑💼``) to a clean initials-based fallback to keep the
|
||||||
|
* visual language consistent with the rest of the app (Ant Design
|
||||||
|
* Vue outlined icons + monospace text). Backend payloads that supply
|
||||||
|
* a non-empty ``leadAvatar`` string (e.g. expert avatars streamed over
|
||||||
|
* the WebSocket) still take precedence and render unchanged.
|
||||||
|
*/
|
||||||
|
const leadInitials = computed(() => {
|
||||||
|
if (props.leadAvatar) return props.leadAvatar
|
||||||
|
const name = props.leadName.trim()
|
||||||
|
if (!name) return 'L'
|
||||||
|
// Use the first CJK char as-is; otherwise take the first letter,
|
||||||
|
// upper-cased. Multi-byte safe: codePointAt(0) returns the first
|
||||||
|
// Unicode code point regardless of BMP / surrogate-pair layout.
|
||||||
|
const first = String.fromCodePoint(name.codePointAt(0) ?? 0x4c)
|
||||||
|
return /[a-z]/i.test(first) ? first.toUpperCase() : first
|
||||||
})
|
})
|
||||||
|
|
||||||
const completedCount = computed(() => props.phases.filter((p) => p.status === 'completed').length)
|
const completedCount = computed(() => props.phases.filter((p) => p.status === 'completed').length)
|
||||||
|
|
@ -91,14 +122,23 @@ function statusLabel(status: string): string {
|
||||||
return labels[status] || status
|
return labels[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusIcon(status: string): string {
|
/**
|
||||||
const icons: Record<string, string> = {
|
* Phase status dot icon. 2026-07-01: replaced the ad-hoc Unicode
|
||||||
pending: '○',
|
* glyphs (``○ ● ✓ ✕``) with the matching Ant Design Vue components
|
||||||
in_progress: '●',
|
* so the dot matches the line-icon family used in the rest of the
|
||||||
completed: '✓',
|
* app. The dotted ``MinusOutlined`` for pending is the closest
|
||||||
failed: '✕',
|
* outlined equivalent of the old hollow ``○``; the spinning /
|
||||||
|
* pulse feel of ``in_progress`` is left to the existing colour
|
||||||
|
* treatment in the scoped CSS below.
|
||||||
|
*/
|
||||||
|
function statusIconComponent(status: string): Component {
|
||||||
|
const map: Record<string, Component> = {
|
||||||
|
pending: MinusOutlined,
|
||||||
|
in_progress: ClockCircleOutlined,
|
||||||
|
completed: CheckOutlined,
|
||||||
|
failed: CloseOutlined,
|
||||||
}
|
}
|
||||||
return icons[status] || '○'
|
return map[status] ?? MinusOutlined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -139,7 +179,10 @@ function statusIcon(status: string): string {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #fff;
|
||||||
background: var(--accent-team-soft);
|
background: var(--accent-team-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
class="user-bubble"
|
class="user-bubble"
|
||||||
:class="{ 'user-bubble--focusable': msgId }"
|
:class="{ 'user-bubble--focusable': msgId }"
|
||||||
:tabindex="msgId ? 0 : undefined"
|
:tabindex="msgId ? 0 : undefined"
|
||||||
@mouseenter="hovered = true"
|
@mouseenter="onBubbleMouseEnter"
|
||||||
@mouseleave="hovered = false"
|
@mouseleave="onBubbleMouseLeave"
|
||||||
@focus="focused = true"
|
@focus="focused = true"
|
||||||
@blur="focused = false"
|
@blur="focused = false"
|
||||||
@pointerdown="onPointerDown"
|
@pointerdown="onPointerDown"
|
||||||
|
|
@ -16,6 +16,28 @@
|
||||||
:filename="fileAttachment.filename"
|
:filename="fileAttachment.filename"
|
||||||
:url="fileAttachment.url"
|
:url="fileAttachment.url"
|
||||||
/>
|
/>
|
||||||
|
<div v-else-if="commandBubble" class="user-bubble__command" :class="`user-bubble__command--${commandBubble.kind}`">
|
||||||
|
<div class="user-bubble__command-header">
|
||||||
|
<span class="user-bubble__command-icon">{{ commandBubble.icon }}</span>
|
||||||
|
<span class="user-bubble__command-label">{{ commandBubble.label }}</span>
|
||||||
|
<span class="user-bubble__command-sep">·</span>
|
||||||
|
<span class="user-bubble__command-topic">{{ commandBubble.topic }}</span>
|
||||||
|
<span class="user-bubble__command-sep">·</span>
|
||||||
|
<span class="user-bubble__command-count" :title="`${commandBubble.expertCount} 位专家`">
|
||||||
|
{{ commandBubble.expertCount }} 位专家
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul v-if="commandBubble.experts.length > 0" class="user-bubble__command-experts">
|
||||||
|
<li
|
||||||
|
v-for="expert in commandBubble.experts"
|
||||||
|
:key="expert"
|
||||||
|
class="user-bubble__command-expert"
|
||||||
|
>
|
||||||
|
<span class="user-bubble__command-expert-dot" />
|
||||||
|
<span class="user-bubble__command-expert-name">{{ expert }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<span v-else class="user-bubble__text">{{ content }}</span>
|
<span v-else class="user-bubble__text">{{ content }}</span>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -23,6 +45,8 @@
|
||||||
class="user-bubble__actions"
|
class="user-bubble__actions"
|
||||||
@click.stop
|
@click.stop
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
|
@mouseenter="onActionsMouseEnter"
|
||||||
|
@mouseleave="onActionsMouseLeave"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="ub-action"
|
class="ub-action"
|
||||||
|
|
@ -84,6 +108,46 @@ const chatStore = useChatStore()
|
||||||
|
|
||||||
const FILE_MARKDOWN_RE = /^\[文件\]\s*\[(.+?)\]\((.+?)\)$/s
|
const FILE_MARKDOWN_RE = /^\[文件\]\s*\[(.+?)\]\((.+?)\)$/s
|
||||||
|
|
||||||
|
// Recognize @board / @team prefixes and surface a structured bubble showing
|
||||||
|
// the command label, the (bold) topic, an expert-count chip, and a
|
||||||
|
// per-expert list. Topics can contain anything (CJK, punctuation), so anchor
|
||||||
|
// on the prefix and only strip known param tokens (`rounds=N`,
|
||||||
|
// `template=...`) at the head of the remainder. An optional `:experts`
|
||||||
|
// segment immediately after the prefix is split off and rendered as the
|
||||||
|
// expert list — the count chip uses the same list.
|
||||||
|
const COMMAND_RE = /^@(board|team)(?::([^\s]+))?\s+([\s\S]+)$/
|
||||||
|
|
||||||
|
interface CommandBubble {
|
||||||
|
kind: 'board' | 'team'
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
topic: string
|
||||||
|
experts: string[]
|
||||||
|
expertCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandBubble = computed<CommandBubble | null>(() => {
|
||||||
|
const text = (props.content || '').trim()
|
||||||
|
const m = text.match(COMMAND_RE)
|
||||||
|
if (!m) return null
|
||||||
|
const kind = m[1] as 'board' | 'team'
|
||||||
|
const rawExperts = (m[2] || '').trim()
|
||||||
|
const rest = m[3].trim()
|
||||||
|
// strip optional "rounds=N" or "template=..." param tokens at the front
|
||||||
|
const topic = rest.replace(/^[a-z_]+=\S+\s*/i, '').trim()
|
||||||
|
const experts = rawExperts
|
||||||
|
? rawExperts.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
icon: kind === 'board' ? '🏛️' : '👥',
|
||||||
|
label: kind === 'board' ? '私董会' : '专家团',
|
||||||
|
topic: topic || rest,
|
||||||
|
experts,
|
||||||
|
expertCount: experts.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fileAttachment = computed(() => {
|
const fileAttachment = computed(() => {
|
||||||
const match = props.content.match(FILE_MARKDOWN_RE)
|
const match = props.content.match(FILE_MARKDOWN_RE)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
|
|
@ -153,6 +217,45 @@ function onRefill(): void {
|
||||||
// the "tap elsewhere" case without per-show add/remove churn.
|
// the "tap elsewhere" case without per-show add/remove churn.
|
||||||
const rootRef = ref<HTMLElement | null>(null)
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// --- Delayed hide for hover toolbar ---
|
||||||
|
// 操作按钮位于气泡左侧外部(right: calc(100% + var(--space-2))),
|
||||||
|
// 鼠标从气泡穿过 8px 间隙移到按钮时触发 mouseleave,导致按钮消失。
|
||||||
|
// 延迟 150ms 隐藏给鼠标时间穿过间隙;按钮的 mouseenter 会取消延迟。
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearHideTimer(): void {
|
||||||
|
if (hideTimer !== null) {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHide(): void {
|
||||||
|
clearHideTimer()
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
hovered.value = false
|
||||||
|
hideTimer = null
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBubbleMouseEnter(): void {
|
||||||
|
clearHideTimer()
|
||||||
|
hovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBubbleMouseLeave(): void {
|
||||||
|
scheduleHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActionsMouseEnter(): void {
|
||||||
|
clearHideTimer()
|
||||||
|
hovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActionsMouseLeave(): void {
|
||||||
|
scheduleHide()
|
||||||
|
}
|
||||||
|
|
||||||
function onPointerDown(event: PointerEvent): void {
|
function onPointerDown(event: PointerEvent): void {
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
touched.value = !touched.value
|
touched.value = !touched.value
|
||||||
|
|
@ -176,6 +279,7 @@ onUnmounted(() => {
|
||||||
clearTimeout(copiedTimer)
|
clearTimeout(copiedTimer)
|
||||||
copiedTimer = null
|
copiedTimer = null
|
||||||
}
|
}
|
||||||
|
clearHideTimer()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -197,7 +301,7 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-bubble--focusable:focus-visible {
|
.user-bubble--focusable:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary, #1677ff);
|
outline: 2px solid var(--accent-primary, #1a1a1a);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,6 +309,95 @@ onUnmounted(() => {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-bubble__command {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-label {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-sep {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-topic {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expert-count chip — high-contrast pill so the number reads as a
|
||||||
|
separate piece of information from the topic. */
|
||||||
|
.user-bubble__command-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-inverse, #ffffff);
|
||||||
|
background: var(--color-primary, #1a1a1a);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-experts {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-expert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-expert-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bubble__command-expert-name {
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.user-bubble__actions {
|
.user-bubble__actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ defineProps<{
|
||||||
.splash-progress-inner {
|
.splash-progress-inner {
|
||||||
width: 40%;
|
width: 40%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-primary, #6366f1);
|
background: var(--color-primary, #1a1a1a);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
animation: splash-loading 1.5s ease-in-out infinite;
|
animation: splash-loading 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
@ -95,8 +95,8 @@ defineProps<{
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-inverse, #ffffff);
|
color: var(--text-inverse, #ffffff);
|
||||||
background: var(--color-primary, #6366f1);
|
background: var(--color-primary, #1a1a1a);
|
||||||
border: 1px solid var(--color-primary, #6366f1);
|
border: 1px solid var(--color-primary, #1a1a1a);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s ease, transform 0.1s ease;
|
transition: opacity 0.15s ease, transform 0.1s ease;
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,9 @@ const tabs: Tab[] = [
|
||||||
const activeTab = ref('monitor')
|
const activeTab = ref('monitor')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
// ponytail: 首次加载标志 — 后续定时刷新静默更新数据,不切换 loading/error DOM,
|
||||||
|
// 避免每 10 秒整个面板 reload 到 spinner 再回来的视觉闪烁。升级路径:WebSocket 推送。
|
||||||
|
const initialLoading = ref(true)
|
||||||
|
|
||||||
// ponytail: WAI-ARIA tablist keyboard navigation — ArrowUp/Down move between
|
// ponytail: WAI-ARIA tablist keyboard navigation — ArrowUp/Down move between
|
||||||
// tabs, Home/End jump to first/last. Required for keyboard-only users.
|
// tabs, Home/End jump to first/last. Required for keyboard-only users.
|
||||||
|
|
@ -219,8 +222,12 @@ const serviceList = computed(() => {
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
|
||||||
async function refreshData(): Promise<void> {
|
async function refreshData(): Promise<void> {
|
||||||
|
// 首次加载显示 loading spinner;后续定时刷新静默更新数据,不切换 DOM,
|
||||||
|
// 避免面板每 10 秒 reload 到 spinner 再回来的视觉闪烁。
|
||||||
|
if (initialLoading.value) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [h, m] = await Promise.all([
|
const [h, m] = await Promise.all([
|
||||||
getHealth().catch((e: unknown) => {
|
getHealth().catch((e: unknown) => {
|
||||||
|
|
@ -234,10 +241,17 @@ async function refreshData(): Promise<void> {
|
||||||
])
|
])
|
||||||
health.value = h
|
health.value = h
|
||||||
metrics.value = m
|
metrics.value = m
|
||||||
|
// 刷新成功清除 error(静默恢复)
|
||||||
|
error.value = ''
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : '加载失败'
|
const msg = e instanceof Error ? e.message : '加载失败'
|
||||||
|
// 首次加载失败必须显示 error;后续刷新失败保持当前数据,不打断用户
|
||||||
|
if (initialLoading.value) {
|
||||||
|
error.value = msg
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
initialLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,10 +332,10 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__tab--active {
|
.system-monitor__tab--active {
|
||||||
color: var(--accent-team);
|
color: var(--color-primary);
|
||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
/* ponytail: override the transparent border-left so the active indicator is visible */
|
/* ponytail: override the transparent border-left so the active indicator is visible */
|
||||||
border-left-color: var(--accent-team);
|
border-left-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__tab-icon {
|
.system-monitor__tab-icon {
|
||||||
|
|
@ -369,7 +383,7 @@ onUnmounted(() => {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
background: var(--accent-team);
|
background: var(--color-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,7 +450,8 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-monitor__metric-delta.down {
|
.system-monitor__metric-delta.down {
|
||||||
color: var(--accent-team);
|
/* 队列中有任务 = 警告语义(不是 team 语义),用 warning 而非 --accent-team */
|
||||||
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分区容器:左右两列独立分块 */
|
/* 分区容器:左右两列独立分块 */
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-tab__source-icon {
|
.knowledge-tab__source-icon {
|
||||||
color: var(--accent-team);
|
color: var(--color-primary);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ onMounted(() => {
|
||||||
|
|
||||||
.skills-tab__item-icon {
|
.skills-tab__item-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--accent-team);
|
color: var(--color-primary);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ import { getResources, type ISystemResources } from '@/api/system'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
// ponytail: 首次加载标志 — 后续定时刷新静默更新数据,不切换 loading/error DOM,
|
||||||
|
// 避免每 10 秒整个面板 reload 到 spinner 再回来的视觉闪烁。
|
||||||
|
const initialLoading = ref(true)
|
||||||
const resources = ref<ISystemResources>({
|
const resources = ref<ISystemResources>({
|
||||||
cpu: { count: 1, load_average: null },
|
cpu: { count: 1, load_average: null },
|
||||||
memory: { total_bytes: 0, used_bytes: 0, free_bytes: 0, percent: null },
|
memory: { total_bytes: 0, used_bytes: 0, free_bytes: 0, percent: null },
|
||||||
|
|
@ -118,14 +121,24 @@ function formatBytes(bytes: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(): Promise<void> {
|
async function refresh(): Promise<void> {
|
||||||
|
// 首次加载显示 loading spinner;后续定时刷新静默更新数据,不切换 DOM,
|
||||||
|
// 避免面板每 10 秒 reload 到 spinner 再回来的视觉闪烁。
|
||||||
|
if (initialLoading.value) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
resources.value = await getResources()
|
resources.value = await getResources()
|
||||||
|
error.value = ''
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : '加载失败'
|
const msg = e instanceof Error ? e.message : '加载失败'
|
||||||
|
// 首次加载失败必须显示 error;后续刷新失败保持当前数据
|
||||||
|
if (initialLoading.value) {
|
||||||
|
error.value = msg
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
initialLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,7 +227,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.system-tab__bar-fill {
|
.system-tab__bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent-team);
|
background: var(--color-primary);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,10 @@ const scenes: Scene[] = [
|
||||||
.chat-preview {
|
.chat-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
/* 同 ChatView — flex:1 + min-height:0 替代 height:100%
|
||||||
|
* 以适配 flex-column 父容器 */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
font-family: var(--font-sans, system-ui);
|
font-family: var(--font-sans, system-ui);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -385,7 +385,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
startupState.value = 'error'
|
startupState.value = 'error'
|
||||||
error.value =
|
error.value =
|
||||||
'后端服务健康检查失败,请确认 agentkit serve 已在 8000 端口运行后点击重试。'
|
'后端服务健康检查失败,请确认 agentkit serve 已在 ' +
|
||||||
|
(import.meta.env.BACKEND_PORT || '18001') +
|
||||||
|
' 端口运行后点击重试。'
|
||||||
return startupState.value
|
return startupState.value
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,18 @@ export const useBitableStore = defineStore('bitable', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new bitable file */
|
/**
|
||||||
|
* Create a new bitable file.
|
||||||
|
*
|
||||||
|
* The ``icon`` argument is a stable Bitable icon key (e.g. ``"table"``,
|
||||||
|
* ``"dashboard"``) resolved client-side through ``bitableIcons.ts`` —
|
||||||
|
* see that module for the full key-to-component map and backward-
|
||||||
|
* compatibility fallback (legacy emoji strings map to the default
|
||||||
|
* ``table`` icon).
|
||||||
|
*/
|
||||||
async function createFile(
|
async function createFile(
|
||||||
name: string,
|
name: string,
|
||||||
icon = '📋',
|
icon: string = 'table',
|
||||||
description = '',
|
description = '',
|
||||||
): Promise<IBitableFile | null> {
|
): Promise<IBitableFile | null> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ import { useDocumentsStore } from "@/stores/documents";
|
||||||
import { useCalendarStore } from "@/stores/calendar";
|
import { useCalendarStore } from "@/stores/calendar";
|
||||||
import { useChatSocket } from "@/stores/chatSocket";
|
import { useChatSocket } from "@/stores/chatSocket";
|
||||||
import { useChatStream } from "@/stores/chatStream";
|
import { useChatStream } from "@/stores/chatStream";
|
||||||
|
import type { BoardState } from "@/stores/chatStream";
|
||||||
|
import { pickExpertIdentity } from "@/components/chat/helpers/expertIdentity";
|
||||||
import type {
|
import type {
|
||||||
IChatMessage,
|
IChatMessage,
|
||||||
IConversation,
|
IConversation,
|
||||||
|
IBoardStartedData,
|
||||||
WsClientMessage,
|
WsClientMessage,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
|
|
@ -31,6 +34,75 @@ export function nextMessageIsAssistant(
|
||||||
return messages[idx + 1].role === "assistant";
|
return messages[idx + 1].role === "assistant";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct BoardState from persisted messages after a page reload.
|
||||||
|
*
|
||||||
|
* Without this, selecting a private board meeting conversation shows
|
||||||
|
* "0 专家" and an empty topic — the streamed BoardState lives in memory
|
||||||
|
* only and is lost on reload. We rebuild it from the persisted
|
||||||
|
* `board_started` message (carries topic + expert list + max_rounds) and
|
||||||
|
* the `board_conclusion` message (signals "completed"). Otherwise the
|
||||||
|
* status stays "discussing" so the status view keeps its place.
|
||||||
|
*
|
||||||
|
* Returns null if no board_started message exists — the conversation
|
||||||
|
* was never a private board meeting.
|
||||||
|
*
|
||||||
|
* ponytail: this is the single source of truth for restore. The server
|
||||||
|
* already deduplicates via has_message_with_type; the client just
|
||||||
|
* mirrors the same metadata shape. Upgrade path: if BoardState grows new
|
||||||
|
* fields, persist them on the server side too (see chat.py persistable).
|
||||||
|
*/
|
||||||
|
export function restoreBoardStateFromMessages(
|
||||||
|
messages: IChatMessage[],
|
||||||
|
): BoardState | null {
|
||||||
|
const startMsg = messages.find(
|
||||||
|
(m) => m.message_type === "board_started" && m.board_started,
|
||||||
|
);
|
||||||
|
if (!startMsg || !startMsg.board_started) return null;
|
||||||
|
const data = startMsg.board_started as IBoardStartedData;
|
||||||
|
// board_conclusion presence -> meeting is over, otherwise still discussing.
|
||||||
|
// ponytail: assume "discussing" rather than "concluding" — the user just
|
||||||
|
// reloaded, the meeting has been over for a while if conclusion is absent.
|
||||||
|
const hasConclusion = messages.some(
|
||||||
|
(m) => m.message_type === "board_conclusion",
|
||||||
|
);
|
||||||
|
// Find the latest board_round across speech/summary/conclusion messages
|
||||||
|
// so the round counter isn't stuck at 0 after reload. Walk once instead
|
||||||
|
// of multiple .findLast() calls.
|
||||||
|
let latestRound = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
if (
|
||||||
|
(m.message_type === "board_speech" ||
|
||||||
|
m.message_type === "board_summary" ||
|
||||||
|
m.message_type === "board_conclusion") &&
|
||||||
|
typeof m.board_round === "number" &&
|
||||||
|
m.board_round > latestRound
|
||||||
|
) {
|
||||||
|
latestRound = m.board_round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
topic: data.topic,
|
||||||
|
experts: (data.experts ?? []).map((e) => {
|
||||||
|
const fallback = pickExpertIdentity(e.name);
|
||||||
|
const serverAvatar = (e.avatar || "").trim();
|
||||||
|
return {
|
||||||
|
name: e.name,
|
||||||
|
avatar: serverAvatar || fallback.avatar,
|
||||||
|
color:
|
||||||
|
/^#[0-9a-fA-F]{3,8}$/.test(e.color || "")
|
||||||
|
? e.color
|
||||||
|
: fallback.color,
|
||||||
|
is_moderator: e.is_moderator,
|
||||||
|
persona: e.persona,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
max_rounds: data.max_rounds,
|
||||||
|
current_round: latestRound,
|
||||||
|
status: hasConclusion ? "completed" : "discussing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const useChatStore = defineStore("chat", () => {
|
export const useChatStore = defineStore("chat", () => {
|
||||||
// --- State (chatStore-owned) ---
|
// --- State (chatStore-owned) ---
|
||||||
const conversations = ref<IConversation[]>([]);
|
const conversations = ref<IConversation[]>([]);
|
||||||
|
|
@ -133,6 +205,10 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
messages: Array.isArray(conv.messages) ? conv.messages : [],
|
messages: Array.isArray(conv.messages) ? conv.messages : [],
|
||||||
created_at: conv.created_at,
|
created_at: conv.created_at,
|
||||||
updated_at: conv.updated_at,
|
updated_at: conv.updated_at,
|
||||||
|
// Server returns is_board when the conversation ever ran a
|
||||||
|
// @board meeting (board_started persisted). Frontend uses this
|
||||||
|
// to render the "私董会" badge in the sidebar.
|
||||||
|
is_board: conv.is_board === true,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load conversations:", error);
|
console.error("Failed to load conversations:", error);
|
||||||
|
|
@ -224,6 +300,11 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
risks: [...graphMsg.collaboration_graph.risks],
|
risks: [...graphMsg.collaboration_graph.risks],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2 #11: 恢复 boardState — 从会话消息中查找 board_started,按 board_conclusion
|
||||||
|
// 存在与否决定 status(已结束 vs 仍在讨论中)。Reload 后 BoardStatusView / StickyModeHeader
|
||||||
|
// 才能正常显示专家列表和轮次,私信 0 人的现象也由此修复。
|
||||||
|
stream.boardState.value = restoreBoardStateFromMessages(restoredConv?.messages ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new empty conversation */
|
/** Create a new empty conversation */
|
||||||
|
|
@ -379,11 +460,16 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update conversation title from first user message
|
// Update conversation title from first user message.
|
||||||
|
// Strip @board/@team command prefix (including :experts and rounds=N)
|
||||||
|
// so the sidebar shows the actual topic, not the raw command syntax.
|
||||||
const conv = conversations.value.find((c) => c.id === conversationId);
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
if (conv && conv.title === "新对话") {
|
if (conv && conv.title === "新对话") {
|
||||||
conv.title =
|
const cleaned = message
|
||||||
message.length > 20 ? `${message.substring(0, 20)}...` : message;
|
.replace(/^@(?:board|team)(?::[^\s]+)?(?:\s+rounds=\d+)?\s*/i, "")
|
||||||
|
.trim();
|
||||||
|
const title = cleaned || message;
|
||||||
|
conv.title = title.length > 20 ? `${title.substring(0, 20)}...` : title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
IDebateResolvedData,
|
IDebateResolvedData,
|
||||||
WsServerMessage,
|
WsServerMessage,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
import { pickExpertIdentity, resolveExpertIdentity } from "@/components/chat/helpers/expertIdentity";
|
||||||
|
|
||||||
// ── Types (moved from chat.ts) ─────────────────────────────────────────
|
// ── Types (moved from chat.ts) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1104,16 +1105,27 @@ export function dispatchWsEvent(
|
||||||
|
|
||||||
case "board_started": {
|
case "board_started": {
|
||||||
const boardData = event.data;
|
const boardData = event.data;
|
||||||
// Initialize board state
|
// Initialize board state with stable identity. Server-provided
|
||||||
|
// avatar/color may be empty (or just emoji glyphs that don't render
|
||||||
|
// in some webview fonts); the pickExpertIdentity fallback guarantees
|
||||||
|
// every expert has a non-empty avatar and a non-blue colour, so
|
||||||
|
// every board_speech message uses the same identity across rounds.
|
||||||
state.boardState.value = {
|
state.boardState.value = {
|
||||||
topic: boardData.topic,
|
topic: boardData.topic,
|
||||||
experts: boardData.experts.map((e) => ({
|
experts: boardData.experts.map((e) => {
|
||||||
|
const fallback = pickExpertIdentity(e.name);
|
||||||
|
const serverAvatar = (e.avatar || "").trim();
|
||||||
|
return {
|
||||||
name: e.name,
|
name: e.name,
|
||||||
avatar: e.avatar,
|
avatar: serverAvatar || fallback.avatar,
|
||||||
color: e.color,
|
color:
|
||||||
|
/^#[0-9a-fA-F]{3,8}$/.test(e.color || "")
|
||||||
|
? e.color
|
||||||
|
: fallback.color,
|
||||||
is_moderator: e.is_moderator,
|
is_moderator: e.is_moderator,
|
||||||
persona: e.persona,
|
persona: e.persona,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
max_rounds: boardData.max_rounds,
|
max_rounds: boardData.max_rounds,
|
||||||
current_round: 0,
|
current_round: 0,
|
||||||
status: "discussing",
|
status: "discussing",
|
||||||
|
|
@ -1146,6 +1158,60 @@ export function dispatchWsEvent(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "expert_speech_chunk": {
|
||||||
|
// Streaming speech chunk (U4 #board streaming). Find the streaming
|
||||||
|
// message for this expert by name and append the chunk. If absent
|
||||||
|
// (e.g. server sent a chunk without a preceding expert_speech), fall
|
||||||
|
// back to creating a fresh streaming message.
|
||||||
|
const conversationId = state.resolveIncomingConvId();
|
||||||
|
if (!conversationId) break;
|
||||||
|
const conv = state.conversations.value.find(
|
||||||
|
(c) => c.id === conversationId,
|
||||||
|
);
|
||||||
|
if (!conv) break;
|
||||||
|
const chunkData = event.data;
|
||||||
|
const chunkName = chunkData.expert_name as string | undefined;
|
||||||
|
const existing = findLastMessage(
|
||||||
|
conv.messages,
|
||||||
|
(m) =>
|
||||||
|
m.message_type === "board_speech" &&
|
||||||
|
m.status === "streaming" &&
|
||||||
|
(chunkName ? m.expert_name === chunkName : true),
|
||||||
|
);
|
||||||
|
const identity = resolveExpertIdentity(
|
||||||
|
chunkName,
|
||||||
|
chunkData.expert_avatar,
|
||||||
|
chunkData.expert_color,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
state.updateMessage(conversationId, existing.id, {
|
||||||
|
content: (existing.content || "") + (chunkData.content || ""),
|
||||||
|
// Fill in identity fields if the original placeholder lacked them.
|
||||||
|
expert_name: existing.expert_name || chunkName,
|
||||||
|
expert_avatar: existing.expert_avatar || identity.avatar,
|
||||||
|
expert_color: existing.expert_color || identity.color,
|
||||||
|
board_round: chunkData.round ?? existing.board_round,
|
||||||
|
board_role: chunkData.role ?? existing.board_role,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const placeholder: IChatMessage = {
|
||||||
|
id: generateId(),
|
||||||
|
role: "assistant",
|
||||||
|
content: chunkData.content || "",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: "streaming",
|
||||||
|
expert_name: chunkName,
|
||||||
|
expert_avatar: identity.avatar,
|
||||||
|
expert_color: identity.color,
|
||||||
|
message_type: "board_speech",
|
||||||
|
board_round: chunkData.round,
|
||||||
|
board_role: chunkData.role,
|
||||||
|
};
|
||||||
|
state.appendMessage(conversationId, placeholder);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "expert_speech": {
|
case "expert_speech": {
|
||||||
const speechData = event.data;
|
const speechData = event.data;
|
||||||
// Update current round in board state
|
// Update current round in board state
|
||||||
|
|
@ -1157,6 +1223,43 @@ export function dispatchWsEvent(
|
||||||
}
|
}
|
||||||
const conversationId = state.resolveIncomingConvId();
|
const conversationId = state.resolveIncomingConvId();
|
||||||
if (!conversationId) break;
|
if (!conversationId) break;
|
||||||
|
const conv = state.conversations.value.find(
|
||||||
|
(c) => c.id === conversationId,
|
||||||
|
);
|
||||||
|
if (!conv) break;
|
||||||
|
// Stable identity: prefer boardState.experts (declared at board start),
|
||||||
|
// fall back to resolveExpertIdentity (palette + name hash). The server
|
||||||
|
// may emit expert_color='' or the blue default; the palette fallback
|
||||||
|
// guarantees each expert has the same colour across rounds.
|
||||||
|
const boardExpert = state.boardState.value?.experts.find(
|
||||||
|
(e) => e.name === speechData.expert_name,
|
||||||
|
);
|
||||||
|
const identity = resolveExpertIdentity(
|
||||||
|
speechData.expert_name,
|
||||||
|
boardExpert?.avatar || speechData.expert_avatar,
|
||||||
|
boardExpert?.color || speechData.expert_color,
|
||||||
|
);
|
||||||
|
// If a streaming message already exists for this expert (the
|
||||||
|
// streaming path created it from chunks), update it; otherwise
|
||||||
|
// append a fresh one. This is the unifying dedup point.
|
||||||
|
const streamingExisting = findLastMessage(
|
||||||
|
conv.messages,
|
||||||
|
(m) =>
|
||||||
|
m.message_type === "board_speech" &&
|
||||||
|
m.expert_name === speechData.expert_name &&
|
||||||
|
m.status === "streaming",
|
||||||
|
);
|
||||||
|
if (streamingExisting) {
|
||||||
|
state.updateMessage(conversationId, streamingExisting.id, {
|
||||||
|
content: speechData.content || streamingExisting.content || "",
|
||||||
|
status: "completed",
|
||||||
|
expert_name: speechData.expert_name,
|
||||||
|
expert_avatar: identity.avatar,
|
||||||
|
expert_color: identity.color,
|
||||||
|
board_round: speechData.round,
|
||||||
|
board_role: speechData.role,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const speechMsg: IChatMessage = {
|
const speechMsg: IChatMessage = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|
@ -1164,18 +1267,19 @@ export function dispatchWsEvent(
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: "completed",
|
status: "completed",
|
||||||
expert_name: speechData.expert_name,
|
expert_name: speechData.expert_name,
|
||||||
expert_color: speechData.expert_color,
|
expert_avatar: identity.avatar,
|
||||||
expert_avatar: speechData.expert_avatar,
|
expert_color: identity.color,
|
||||||
message_type: "board_speech",
|
message_type: "board_speech",
|
||||||
board_round: speechData.round,
|
board_round: speechData.round,
|
||||||
board_role: speechData.role,
|
board_role: speechData.role,
|
||||||
};
|
};
|
||||||
state.appendMessage(conversationId, speechMsg);
|
state.appendMessage(conversationId, speechMsg);
|
||||||
|
}
|
||||||
appendStep(
|
appendStep(
|
||||||
state.streamingStepsByConv,
|
state.streamingStepsByConv,
|
||||||
{
|
{
|
||||||
type: "board_event",
|
type: "board_event",
|
||||||
label: `${speechData.expert_avatar || ""} ${speechData.expert_name}`,
|
label: `${identity.avatar} ${speechData.expert_name || "专家"}`,
|
||||||
detail: `第 ${speechData.round} 轮${speechData.role === "moderator" ? " · 主持" : ""}`,
|
detail: `第 ${speechData.round} 轮${speechData.role === "moderator" ? " · 主持" : ""}`,
|
||||||
status: "success",
|
status: "success",
|
||||||
},
|
},
|
||||||
|
|
@ -1188,6 +1292,19 @@ export function dispatchWsEvent(
|
||||||
const summaryData = event.data;
|
const summaryData = event.data;
|
||||||
const conversationId = state.resolveIncomingConvId();
|
const conversationId = state.resolveIncomingConvId();
|
||||||
if (!conversationId) break;
|
if (!conversationId) break;
|
||||||
|
const conv = state.conversations.value.find(
|
||||||
|
(c) => c.id === conversationId,
|
||||||
|
);
|
||||||
|
if (!conv) break;
|
||||||
|
// Stable identity for the moderator, just like expert_speech.
|
||||||
|
const moderator = state.boardState.value?.experts.find(
|
||||||
|
(e) => e.name === summaryData.moderator_name,
|
||||||
|
);
|
||||||
|
const identity = resolveExpertIdentity(
|
||||||
|
summaryData.moderator_name,
|
||||||
|
moderator?.avatar,
|
||||||
|
moderator?.color,
|
||||||
|
);
|
||||||
const summaryMsg: IChatMessage = {
|
const summaryMsg: IChatMessage = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|
@ -1195,6 +1312,8 @@ export function dispatchWsEvent(
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: "completed",
|
status: "completed",
|
||||||
expert_name: summaryData.moderator_name,
|
expert_name: summaryData.moderator_name,
|
||||||
|
expert_avatar: identity.avatar,
|
||||||
|
expert_color: identity.color,
|
||||||
message_type: "board_summary",
|
message_type: "board_summary",
|
||||||
board_round: summaryData.round,
|
board_round: summaryData.round,
|
||||||
board_role: "summary",
|
board_role: "summary",
|
||||||
|
|
@ -1262,10 +1381,15 @@ export function dispatchWsEvent(
|
||||||
};
|
};
|
||||||
state.appendMessage(conversationId, conclusionMsg);
|
state.appendMessage(conversationId, conclusionMsg);
|
||||||
}
|
}
|
||||||
// Clear board state after a short delay to allow UI to update
|
// Clear the "执行中" loading indicator and step trail immediately
|
||||||
setTimeout(() => {
|
// when the board concludes. The previous 1s setTimeout made the UI
|
||||||
|
// look stuck for a full second after the conclusion appeared, which
|
||||||
|
// contradicted the message that the meeting was over.
|
||||||
|
if (conversationId) {
|
||||||
|
state.markConversationDone(conversationId);
|
||||||
|
clearConvSteps(state.streamingStepsByConv, conversationId);
|
||||||
|
}
|
||||||
state.boardState.value = null;
|
state.boardState.value = null;
|
||||||
}, 1000);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
cors_origins: ['*'],
|
cors_origins: ['*'],
|
||||||
logging_level: 'INFO',
|
logging_level: 'INFO',
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 8001,
|
// Default port matches agentkit.yaml (18001) and .env.dev's BACKEND_PORT.
|
||||||
|
// Overridden at runtime when the API returns the actual configured port.
|
||||||
|
port: Number(import.meta.env.BACKEND_PORT) || 18001,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
log_format: 'text',
|
log_format: 'text',
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
return {
|
return {
|
||||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
colorPrimary: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
colorInfo: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
colorSuccess: readToken('--color-success', isDark ? '#4ade80' : '#22c55e'),
|
colorSuccess: readToken('--color-success', isDark ? '#4ade80' : '#22c55e'),
|
||||||
colorWarning: readToken('--color-warning', isDark ? '#fbbf24' : '#f59e0b'),
|
colorWarning: readToken('--color-warning', isDark ? '#fbbf24' : '#f59e0b'),
|
||||||
colorError: readToken('--color-error', isDark ? '#f87171' : '#ef4444'),
|
colorError: readToken('--color-error', isDark ? '#f87171' : '#ef4444'),
|
||||||
|
|
@ -132,19 +132,36 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
Menu: {
|
Menu: {
|
||||||
itemSelectedBg: readToken('--color-primary-light', isDark ? '#1e1b4b' : '#eef2ff'),
|
itemSelectedBg: readToken('--color-primary-light', isDark ? '#2f2f2f' : '#f3f4f6'),
|
||||||
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
itemSelectedColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
itemHoverBg: isDark ? '#1e1b4b' : '#f5f3ff',
|
itemHoverBg: isDark ? '#2f2f2f' : '#ededec',
|
||||||
itemHoverColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
itemHoverColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
itemColor: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
|
itemColor: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
Tabs: {
|
Tabs: {
|
||||||
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
itemSelectedColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
itemHoverColor: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
|
itemHoverColor: readToken('--color-primary-hover', isDark ? '#ededec' : '#2f2f2f'),
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
Select: {
|
Select: {
|
||||||
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
colorPrimary: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
colorPrimaryHover: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
|
colorPrimaryHover: readToken('--color-primary-hover', isDark ? '#ededec' : '#2f2f2f'),
|
||||||
|
// 2026-07-01: dropdown option highlight colour unification.
|
||||||
|
//
|
||||||
|
// Default Ant Design Vue uses #f5f5f5 (cold neutral grey) for
|
||||||
|
// optionSelectedBg, which clashes with the indigo theme and
|
||||||
|
// looks visually out-of-place (e.g. on the Bitable file-create
|
||||||
|
// modal: a dark grey block on a white panel, with no colour
|
||||||
|
// link to the brand). Aligned with Menu.itemSelectedBg so all
|
||||||
|
// interactive surfaces share the same accent-tinted highlight.
|
||||||
|
//
|
||||||
|
// ponytail: optionActiveBg is for keyboard navigation / hover on
|
||||||
|
// the same option that's also selected — needs a slightly
|
||||||
|
// different shade to communicate "you're moving the cursor over
|
||||||
|
// the current selection", matching the Menu hover-vs-selected
|
||||||
|
// distinction above.
|
||||||
|
optionSelectedBg: readToken('--color-primary-light', isDark ? '#2f2f2f' : '#f3f4f6'),
|
||||||
|
optionSelectedColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||||
|
optionActiveBg: isDark ? '#2f2f2f' : '#ededec',
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
Button: { borderRadius: 8, controlHeight: 32 } as Record<string, unknown>,
|
Button: { borderRadius: 8, controlHeight: 32 } as Record<string, unknown>,
|
||||||
Card: { borderRadiusLG: 10 } as Record<string, unknown>,
|
Card: { borderRadiusLG: 10 } as Record<string, unknown>,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ export const themeConfig: ThemeConfig = {
|
||||||
Select: {
|
Select: {
|
||||||
colorPrimary: readToken('--accent-team', '#3b82f6'),
|
colorPrimary: readToken('--accent-team', '#3b82f6'),
|
||||||
colorPrimaryHover: readToken('--accent-team-hover', '#2563eb'),
|
colorPrimaryHover: readToken('--accent-team-hover', '#2563eb'),
|
||||||
|
// Static (light) dropdown highlight — see stores/theme.ts for the
|
||||||
|
// reactive dark-mode counterpart and the full rationale.
|
||||||
|
optionSelectedBg: readToken('--color-primary-light', '#f3f4f6'),
|
||||||
|
optionSelectedColor: readToken('--color-primary', '#1a1a1a'),
|
||||||
|
optionActiveBg: readToken('--color-primary-light', '#ededec'),
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
Button: {
|
Button: {
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,10 @@
|
||||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
/* ── Message Layout ── */
|
/* ── Message Layout ── */
|
||||||
--max-chat-width: 860px;
|
/* 2026-07-01: 全宽自适应 — 移除 860px 限制,内容区域跟随窗口宽度。
|
||||||
|
* 之前 860px 居中限制(类 ChatGPT/Notion)导致用户感觉"宽度没有自适应"。
|
||||||
|
* 设为 none 使 max-width: var(--max-chat-width) 不生效。 */
|
||||||
|
--max-chat-width: none;
|
||||||
--space-message-gap: 24px;
|
--space-message-gap: 24px;
|
||||||
--leading-message: 1.65;
|
--leading-message: 1.65;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
<div class="bitable-file-detail-view__topbar">
|
<div class="bitable-file-detail-view__topbar">
|
||||||
<div class="bitable-file-detail-view__topbar-left">
|
<div class="bitable-file-detail-view__topbar-left">
|
||||||
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
|
<a-button type="text" :icon="h(ArrowLeftOutlined)" @click="goBack" />
|
||||||
<span class="bitable-file-detail-view__icon">{{ store.currentFile?.icon ?? '📋' }}</span>
|
<component
|
||||||
|
:is="resolveBitableIcon(store.currentFile?.icon)"
|
||||||
|
class="bitable-file-detail-view__icon"
|
||||||
|
/>
|
||||||
<span class="bitable-file-detail-view__title">
|
<span class="bitable-file-detail-view__title">
|
||||||
{{ store.currentFile?.name ?? '加载中…' }}
|
{{ store.currentFile?.name ?? '加载中…' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -131,6 +134,7 @@ import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
|
||||||
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
|
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
|
||||||
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
|
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
|
||||||
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
|
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
|
||||||
|
import { resolveBitableIcon } from '@/components/bitable/bitableIcons'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -301,7 +305,10 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
.bitable-file-detail-view__icon {
|
.bitable-file-detail-view__icon {
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
|
color: var(--color-primary, #1a1a1a);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bitable-file-detail-view__title {
|
.bitable-file-detail-view__title {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,26 @@
|
||||||
>
|
>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="图标">
|
<a-form-item label="图标">
|
||||||
<a-select v-model:value="renameForm.icon" :options="iconOptions" />
|
<a-select v-model:value="renameForm.icon" :options="iconSelectOptions">
|
||||||
|
<template #option="{ value: optValue }">
|
||||||
|
<span class="bitable-file-list-view__option">
|
||||||
|
<component
|
||||||
|
:is="resolveBitableIcon(optValue)"
|
||||||
|
class="bitable-file-list-view__option-icon"
|
||||||
|
/>
|
||||||
|
<span>{{ labelForKey(optValue) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #label="{ value: labelValue }">
|
||||||
|
<span class="bitable-file-list-view__option">
|
||||||
|
<component
|
||||||
|
:is="resolveBitableIcon(labelValue)"
|
||||||
|
class="bitable-file-list-view__option-icon"
|
||||||
|
/>
|
||||||
|
<span>{{ labelForKey(labelValue) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="文件名">
|
<a-form-item label="文件名">
|
||||||
<a-input v-model:value="renameForm.name" :maxlength="100" />
|
<a-input v-model:value="renameForm.name" :maxlength="100" />
|
||||||
|
|
@ -66,7 +85,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, h, onMounted } from 'vue'
|
import { ref, reactive, computed, h, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Modal as AModal } from 'ant-design-vue'
|
import { Modal as AModal } from 'ant-design-vue'
|
||||||
import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
@ -74,6 +93,12 @@ import { useBitableStore } from '@/stores/bitable'
|
||||||
import type { IBitableFile } from '@/api/bitable'
|
import type { IBitableFile } from '@/api/bitable'
|
||||||
import FileCard from '@/components/bitable/FileCard.vue'
|
import FileCard from '@/components/bitable/FileCard.vue'
|
||||||
import FileCreateModal from '@/components/bitable/FileCreateModal.vue'
|
import FileCreateModal from '@/components/bitable/FileCreateModal.vue'
|
||||||
|
import {
|
||||||
|
BITABLE_ICON_OPTIONS,
|
||||||
|
DEFAULT_BITABLE_ICON,
|
||||||
|
resolveBitableIcon,
|
||||||
|
type BitableIconKey,
|
||||||
|
} from '@/components/bitable/bitableIcons'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useBitableStore()
|
const store = useBitableStore()
|
||||||
|
|
@ -82,23 +107,26 @@ const createOpen = ref(false)
|
||||||
const renameOpen = ref(false)
|
const renameOpen = ref(false)
|
||||||
const renaming = ref(false)
|
const renaming = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ``icon`` accepts any string here because legacy DB rows may still
|
||||||
|
* contain the old emoji values (``📋`` etc.). The select options only
|
||||||
|
* show the new Bitable icon keys; the resolver maps unknown values to
|
||||||
|
* the default ``table`` icon at render time.
|
||||||
|
*/
|
||||||
const renameForm = reactive({
|
const renameForm = reactive({
|
||||||
id: '',
|
id: '',
|
||||||
icon: '📋',
|
icon: DEFAULT_BITABLE_ICON as BitableIconKey | string,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const iconOptions = [
|
const iconSelectOptions = computed(() =>
|
||||||
{ label: '📋 表格', value: '📋' },
|
BITABLE_ICON_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||||
{ label: '📊 仪表盘', value: '📊' },
|
)
|
||||||
{ label: '📝 笔记', value: '📝' },
|
|
||||||
{ label: '🗂️ 项目', value: '🗂️' },
|
function labelForKey(key: string): string {
|
||||||
{ label: '📅 日程', value: '📅' },
|
return BITABLE_ICON_OPTIONS.find((o) => o.value === key)?.label ?? key
|
||||||
{ label: '💼 工作', value: '💼' },
|
}
|
||||||
{ label: '🎯 目标', value: '🎯' },
|
|
||||||
{ label: '📚 知识库', value: '📚' },
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadFiles()
|
store.loadFiles()
|
||||||
|
|
@ -212,4 +240,15 @@ function handleDelete(file: IBitableFile): void {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bitable-file-list-view__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitable-file-list-view__option-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-primary, #1a1a1a);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,15 @@ function handleSend(message: string, model?: string): void {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-view {
|
.chat-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
/* 2026-07-01: 用 flex:1 + min-height:0 替代 height:100%。
|
||||||
|
*
|
||||||
|
* .chat-view 是 .agent-layout__chat-main (flex:1, flex-direction:column)
|
||||||
|
* 的直接子元素。height:100% 在 flex-column 容器中引用父容器 height,
|
||||||
|
* 但父容器自身依赖 flex:1 撑开(无显式 height),在某些 WebView 引擎
|
||||||
|
* 中 height:100% 无法解析 → 窗口放大时聊天区域不跟随拉伸。
|
||||||
|
* flex:1 + min-height:0 是 flexbox 标准写法,在所有引擎中一致工作。 */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +221,8 @@ function handleSend(message: string, model?: string): void {
|
||||||
.chat-view__welcome {
|
.chat-view__welcome {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
/* 2026-07-01: 外层块水平居中,但内部文字保持左对齐(见 .chat-view__welcome-inner 的 align-items: flex-start) */
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 120px 0 48px;
|
padding: 120px 0 48px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for chatStore.restoreBoardStateFromMessages (P2 #11).
|
||||||
|
*
|
||||||
|
* Locks in the reload behavior: after the client restarts, selecting a
|
||||||
|
* private board meeting conversation must rebuild BoardState from the
|
||||||
|
* persisted `board_started` message so the status view shows the
|
||||||
|
* topic, expert list, and round counter — and sets `status` to
|
||||||
|
* "completed" when a `board_conclusion` message is also present.
|
||||||
|
*
|
||||||
|
* Without this, users see "0 专家" and an empty topic every time they
|
||||||
|
* reload the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { restoreBoardStateFromMessages } from '@/stores/chatStore'
|
||||||
|
import type { IChatMessage } from '@/api/types'
|
||||||
|
|
||||||
|
function boardStartedMsg(round: number = 0): IChatMessage {
|
||||||
|
return {
|
||||||
|
id: 'msg-start',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '🏛️ 私董会开始:AI 未来',
|
||||||
|
timestamp: '2026-07-01T10:00:00Z',
|
||||||
|
status: 'completed',
|
||||||
|
message_type: 'board_started',
|
||||||
|
board_started: {
|
||||||
|
team_id: 'team-1',
|
||||||
|
topic: 'AI 未来',
|
||||||
|
max_rounds: 5,
|
||||||
|
experts: [
|
||||||
|
{
|
||||||
|
name: 'Alice',
|
||||||
|
avatar: '🦊',
|
||||||
|
color: '#7a5af8',
|
||||||
|
is_moderator: true,
|
||||||
|
persona: '主持人',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bob',
|
||||||
|
avatar: '🐼',
|
||||||
|
color: '#22c55e',
|
||||||
|
is_moderator: false,
|
||||||
|
persona: '工程师',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
board_round: round,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function speechMsg(name: string, round: number): IChatMessage {
|
||||||
|
return {
|
||||||
|
id: `msg-speech-${name}-${round}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `${name} 的第 ${round} 轮发言`,
|
||||||
|
timestamp: '2026-07-01T10:00:01Z',
|
||||||
|
status: 'completed',
|
||||||
|
message_type: 'board_speech',
|
||||||
|
expert_name: name,
|
||||||
|
board_round: round,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function conclusionMsg(totalRounds: number): IChatMessage {
|
||||||
|
return {
|
||||||
|
id: 'msg-conclusion',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '私董会已结束',
|
||||||
|
timestamp: '2026-07-01T10:05:00Z',
|
||||||
|
status: 'completed',
|
||||||
|
message_type: 'board_conclusion',
|
||||||
|
board_conclusion: {
|
||||||
|
summary: '总结',
|
||||||
|
decision_advice: '建议',
|
||||||
|
total_rounds: totalRounds,
|
||||||
|
consensus_points: [],
|
||||||
|
dissent_points: [],
|
||||||
|
},
|
||||||
|
board_round: totalRounds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('restoreBoardStateFromMessages', () => {
|
||||||
|
it('returns null when no board_started message exists', () => {
|
||||||
|
const messages: IChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello',
|
||||||
|
timestamp: '2026-07-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(restoreBoardStateFromMessages(messages)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rebuilds experts and topic from board_started', () => {
|
||||||
|
const state = restoreBoardStateFromMessages([boardStartedMsg()])
|
||||||
|
expect(state).not.toBeNull()
|
||||||
|
expect(state?.topic).toBe('AI 未来')
|
||||||
|
expect(state?.max_rounds).toBe(5)
|
||||||
|
expect(state?.experts).toHaveLength(2)
|
||||||
|
expect(state?.experts[0].name).toBe('Alice')
|
||||||
|
expect(state?.experts[0].is_moderator).toBe(true)
|
||||||
|
expect(state?.experts[1].name).toBe('Bob')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to expert identity palette when color is missing', () => {
|
||||||
|
const msg = boardStartedMsg()
|
||||||
|
msg.board_started!.experts = [
|
||||||
|
{
|
||||||
|
name: 'Stranger',
|
||||||
|
avatar: '',
|
||||||
|
color: '',
|
||||||
|
is_moderator: false,
|
||||||
|
persona: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const state = restoreBoardStateFromMessages([msg])
|
||||||
|
expect(state?.experts[0].avatar).not.toBe('')
|
||||||
|
expect(state?.experts[0].color).toMatch(/^#[0-9a-fA-F]{3,8}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps status="discussing" when no conclusion message present', () => {
|
||||||
|
const messages = [
|
||||||
|
boardStartedMsg(),
|
||||||
|
speechMsg('Alice', 1),
|
||||||
|
speechMsg('Bob', 1),
|
||||||
|
]
|
||||||
|
const state = restoreBoardStateFromMessages(messages)
|
||||||
|
expect(state?.status).toBe('discussing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks status="completed" when board_conclusion message present', () => {
|
||||||
|
const messages = [
|
||||||
|
boardStartedMsg(),
|
||||||
|
speechMsg('Alice', 1),
|
||||||
|
speechMsg('Bob', 1),
|
||||||
|
speechMsg('Alice', 2),
|
||||||
|
conclusionMsg(2),
|
||||||
|
]
|
||||||
|
const state = restoreBoardStateFromMessages(messages)
|
||||||
|
expect(state?.status).toBe('completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes current_round from the latest speech/summary/conclusion', () => {
|
||||||
|
const messages = [
|
||||||
|
boardStartedMsg(),
|
||||||
|
speechMsg('Alice', 1),
|
||||||
|
speechMsg('Bob', 1),
|
||||||
|
speechMsg('Alice', 3),
|
||||||
|
]
|
||||||
|
const state = restoreBoardStateFromMessages(messages)
|
||||||
|
expect(state?.current_round).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores non-board messages when computing current_round', () => {
|
||||||
|
const messages: IChatMessage[] = [
|
||||||
|
boardStartedMsg(),
|
||||||
|
speechMsg('Alice', 2),
|
||||||
|
{
|
||||||
|
id: 'm-rand',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'not board',
|
||||||
|
timestamp: '2026-07-01T10:00:00Z',
|
||||||
|
message_type: 'chat',
|
||||||
|
// board_round=99 — should be ignored because message_type is 'chat'
|
||||||
|
board_round: 99,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const state = restoreBoardStateFromMessages(messages)
|
||||||
|
expect(state?.current_round).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -4,7 +4,15 @@ import { resolve } from 'path'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
|
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
|
// Port assignment is centralised via env vars to avoid conflicts with
|
||||||
|
// other local services (Docker, geo_backend, pms-*, etc.).
|
||||||
|
// BACKEND_PORT — AgentKit server port (default 18001, very-uncommon port)
|
||||||
|
// VITE_DEV_PORT — Vite dev server port (default 15173, avoids 5173 conflicts)
|
||||||
|
// VITE_HMR_PORT — Vite HMR websocket port (default 15174)
|
||||||
const host = process.env.TAURI_DEV_HOST
|
const host = process.env.TAURI_DEV_HOST
|
||||||
|
const backendPort = process.env.BACKEND_PORT || '18001'
|
||||||
|
const vitePort = parseInt(process.env.VITE_DEV_PORT || '15173', 10)
|
||||||
|
const hmrPort = parseInt(process.env.VITE_HMR_PORT || '15174', 10)
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
@ -29,20 +37,25 @@ export default defineConfig({
|
||||||
// Tauri dev server configuration
|
// Tauri dev server configuration
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: vitePort,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
// 2026-07-01: bind to all interfaces so Tauri's Rust-side reachability
|
||||||
hmr: host ? { protocol: 'ws', host, port: 5174 } : undefined,
|
// check (which may use IPv4 127.0.0.1) can connect. Previously `host || false`
|
||||||
|
// made Vite bind only IPv6 [::1] on macOS, causing Tauri to fall back to
|
||||||
|
// frontendDist (static/) and serve a stale build — Cmd+R could not pick
|
||||||
|
// up HMR changes. `true` listens on 0.0.0.0 (IPv4) + [::] (IPv6).
|
||||||
|
host: host || true,
|
||||||
|
hmr: host ? { protocol: 'ws', host, port: hmrPort } : undefined,
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ['**/src-tauri/**'],
|
ignored: ['**/src-tauri/**'],
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: `http://localhost:${backendPort}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
envPrefix: ['VITE_', 'TAURI_'],
|
envPrefix: ['VITE_', 'TAURI_', 'BACKEND_'],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,12 @@ async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
||||||
|
|
||||||
# 2-3. Validate the session (also handles reuse detection)
|
# 2-3. Validate the session (also handles reuse detection)
|
||||||
|
# Inherit ``remember_me`` from the old refresh token's ``rmb`` claim
|
||||||
|
# so a 30-day session stays 30-day across rotations. Old tokens
|
||||||
|
# issued before this fix lack the claim — default to False (7-day)
|
||||||
|
# which is the previous behaviour, so no regression for existing
|
||||||
|
# sessions.
|
||||||
|
inherited_remember_me = bool(refresh_payload.get("rmb", False))
|
||||||
try:
|
try:
|
||||||
new_pair = create_token_pair(
|
new_pair = create_token_pair(
|
||||||
user_id=refresh_payload["sub"],
|
user_id=refresh_payload["sub"],
|
||||||
|
|
@ -505,12 +511,12 @@ async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
||||||
role=refresh_payload["role"],
|
role=refresh_payload["role"],
|
||||||
secret=secret,
|
secret=secret,
|
||||||
session_id=refresh_payload.get("sid"),
|
session_id=refresh_payload.get("sid"),
|
||||||
remember_me=False,
|
remember_me=inherited_remember_me,
|
||||||
)
|
)
|
||||||
await svc.rotate(
|
await svc.rotate(
|
||||||
old_refresh_token=payload.refresh_token,
|
old_refresh_token=payload.refresh_token,
|
||||||
new_refresh_token=new_pair.refresh_token,
|
new_refresh_token=new_pair.refresh_token,
|
||||||
new_ttl_seconds=int(REFRESH_TOKEN_TTL.total_seconds()),
|
new_ttl_seconds=_refresh_ttl_seconds(inherited_remember_me),
|
||||||
)
|
)
|
||||||
except (SessionReuseDetected, SessionNotFound, ValueError, KeyError, RuntimeError) as exc: # noqa: BLE001 — SessionReuseDetected / SessionNotFound
|
except (SessionReuseDetected, SessionNotFound, ValueError, KeyError, RuntimeError) as exc: # noqa: BLE001 — SessionReuseDetected / SessionNotFound
|
||||||
logger.info("Refresh rejected: %s", exc)
|
logger.info("Refresh rejected: %s", exc)
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,11 @@ async def _check_file_ownership(
|
||||||
|
|
||||||
class CreateFileRequest(BaseModel):
|
class CreateFileRequest(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
icon: str = Field("📋", max_length=20)
|
# Stable icon key (matches BitableIconKey in
|
||||||
|
# components/bitable/bitableIcons.ts). Legacy emoji values are
|
||||||
|
# accepted at the API boundary and rendered as the default
|
||||||
|
# ``table`` icon on the client — see ``resolveBitableIcon``.
|
||||||
|
icon: str = Field("table", max_length=20)
|
||||||
description: str = Field("", max_length=2000)
|
description: str = Field("", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ _VALID_TEAM_EVENT_TYPES = frozenset(
|
||||||
# Board Meeting 模式事件
|
# Board Meeting 模式事件
|
||||||
"board_started",
|
"board_started",
|
||||||
"expert_speech",
|
"expert_speech",
|
||||||
|
"expert_speech_chunk",
|
||||||
"round_summary",
|
"round_summary",
|
||||||
"user_intervention",
|
"user_intervention",
|
||||||
"board_concluded",
|
"board_concluded",
|
||||||
|
|
@ -284,14 +285,103 @@ async def _execute_board_meeting(
|
||||||
# Strip internal fields, keep only event data
|
# Strip internal fields, keep only event data
|
||||||
event_data = {k: v for k, v in message.items() if k != "type"}
|
event_data = {k: v for k, v in message.items() if k != "type"}
|
||||||
await emit_team_event(websocket, msg_type, event_data)
|
await emit_team_event(websocket, msg_type, event_data)
|
||||||
|
# Persist board events so a page reload can reconstruct the
|
||||||
|
# discussion (otherwise the user only sees the final conclusion
|
||||||
|
# and loses every expert speech). We persist on the terminal
|
||||||
|
# "board_started" / "expert_speech" / "round_summary" /
|
||||||
|
# "board_concluded" events — chunks are intermediate and would
|
||||||
|
# explode the history size. The "board_started" payload carries
|
||||||
|
# the expert list (name/avatar/color/is_moderator/persona) so a
|
||||||
|
# reload can rebuild the boardState and the sidebar can show the
|
||||||
|
# "私董会" badge — without it, every restored conversation
|
||||||
|
# appears as a plain chat with 0 experts.
|
||||||
|
#
|
||||||
|
# We also stash the rendering hint (message_type + expert identity)
|
||||||
|
# in Message.metadata so a reload can still show the speech as a
|
||||||
|
# proper board_speech card instead of a plain assistant bubble.
|
||||||
|
experts_data = event_data.get("experts")
|
||||||
|
board_started_text = (
|
||||||
|
f"🏛️ 私董会开始:{event_data.get('topic', '')}"
|
||||||
|
if event_data.get("topic")
|
||||||
|
else "🏛️ 私董会开始"
|
||||||
|
)
|
||||||
|
persistable: dict[str, tuple[str, str, dict[str, object] | None]] = {
|
||||||
|
"board_started": (
|
||||||
|
"assistant",
|
||||||
|
board_started_text,
|
||||||
|
{
|
||||||
|
"message_type": "board_started",
|
||||||
|
"board_started": {
|
||||||
|
"team_id": event_data.get("team_id"),
|
||||||
|
"topic": event_data.get("topic"),
|
||||||
|
"experts": experts_data,
|
||||||
|
"max_rounds": event_data.get("max_rounds"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"expert_speech": (
|
||||||
|
"assistant",
|
||||||
|
event_data.get("content", ""),
|
||||||
|
{
|
||||||
|
"message_type": "board_speech",
|
||||||
|
"expert_name": event_data.get("expert_name"),
|
||||||
|
"expert_avatar": event_data.get("expert_avatar"),
|
||||||
|
"expert_color": event_data.get("expert_color"),
|
||||||
|
"board_round": event_data.get("round"),
|
||||||
|
"board_role": event_data.get("role"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"round_summary": (
|
||||||
|
"assistant",
|
||||||
|
event_data.get("content", ""),
|
||||||
|
{
|
||||||
|
"message_type": "board_summary",
|
||||||
|
"expert_name": event_data.get("moderator_name"),
|
||||||
|
"board_round": event_data.get("round"),
|
||||||
|
"board_role": "summary",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"board_concluded": (
|
||||||
|
"assistant",
|
||||||
|
event_data.get("summary") or "私董会已结束",
|
||||||
|
{
|
||||||
|
"message_type": "board_conclusion",
|
||||||
|
"board_round": event_data.get("total_rounds"),
|
||||||
|
"board_conclusion": {
|
||||||
|
"summary": event_data.get("summary", ""),
|
||||||
|
"decision_advice": event_data.get("decision_advice", ""),
|
||||||
|
"total_rounds": event_data.get("total_rounds", 0),
|
||||||
|
"consensus_points": event_data.get("consensus_points", []),
|
||||||
|
"dissent_points": event_data.get("dissent_points", []),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if msg_type in persistable:
|
||||||
|
role, content, meta = persistable[msg_type]
|
||||||
|
if content:
|
||||||
|
try:
|
||||||
|
await sm.append_message(
|
||||||
|
session_id=session_id,
|
||||||
|
role=MessageRole(role),
|
||||||
|
content=content,
|
||||||
|
metadata=meta,
|
||||||
|
)
|
||||||
|
except Exception as persist_err: # noqa: BLE001
|
||||||
|
# Persistence is best-effort; never let a save error
|
||||||
|
# break the live stream.
|
||||||
|
logger.warning(f"Failed to persist {msg_type} to session store: {persist_err}")
|
||||||
|
|
||||||
team.handoff_transport.register_handler(team.team_channel, _relay_board_event)
|
team.handoff_transport.register_handler(team.team_channel, _relay_board_event)
|
||||||
|
|
||||||
# Append user topic to session history
|
# Append user topic to session history — store the resolved topic, not
|
||||||
|
# the raw "@board:experts rounds=5 ..." syntax. Frontend renders the
|
||||||
|
# original prefix as a structured bubble; persisting it raw means the
|
||||||
|
# bubble shows the full prefix even on history reload.
|
||||||
await sm.append_message(
|
await sm.append_message(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
role=MessageRole.USER,
|
role=MessageRole.USER,
|
||||||
content=content,
|
content=f"@board {routing_result.topic}",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -34,6 +35,13 @@ from agentkit.server.routes.evolution_dashboard import (
|
||||||
from agentkit.core.fallback import EMPTY_LLM_RESPONSE
|
from agentkit.core.fallback import EMPTY_LLM_RESPONSE
|
||||||
from agentkit.chat.sqlite_conversation_store import SqliteConversationStore
|
from agentkit.chat.sqlite_conversation_store import SqliteConversationStore
|
||||||
from agentkit.server.task_store import InMemoryTaskStore
|
from agentkit.server.task_store import InMemoryTaskStore
|
||||||
|
from agentkit.session.models import MessageRole
|
||||||
|
|
||||||
|
# ponytail: importing module-private helpers from chat.py because the frontend
|
||||||
|
# WS connects to /api/v1/portal/ws (this router), not /api/v1/chat/ws/{session_id}.
|
||||||
|
# Without this, @board/@team prefixes are never intercepted and board/team cards
|
||||||
|
# never render. Upgrade path: extract these into a shared experts dispatch module.
|
||||||
|
from agentkit.server.routes.chat import _execute_board_meeting, _execute_team_collab
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -179,6 +187,34 @@ _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)
|
||||||
|
|
||||||
|
|
||||||
|
class _ConvStoreAsSessionManager:
|
||||||
|
"""Adapt SqliteConversationStore to the SessionManager.append_message shape
|
||||||
|
used by chat.py's _execute_board_meeting / _execute_team_collab.
|
||||||
|
|
||||||
|
ponytail: only append_message is implemented — that's all the board/team
|
||||||
|
intercepts call. If future logic needs more SessionManager methods, add
|
||||||
|
them here then.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, store: SqliteConversationStore) -> None:
|
||||||
|
self._store = store
|
||||||
|
|
||||||
|
async def append_message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
role: MessageRole,
|
||||||
|
content: str,
|
||||||
|
tool_call_id: str | None = None,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
metadata: dict[str, object] | None = None,
|
||||||
|
) -> None:
|
||||||
|
role_str = role.value if hasattr(role, "value") else str(role)
|
||||||
|
await self._store.add_message(session_id, role_str, content, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
_sm_adapter = _ConvStoreAsSessionManager(_conversation_store)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Active portal WebSocket connections by user_id
|
# Active portal WebSocket connections by user_id
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -236,9 +272,7 @@ class PortalConnectionManager:
|
||||||
try:
|
try:
|
||||||
await ws.send_json(message)
|
await ws.send_json(message)
|
||||||
except (ConnectionError, RuntimeError, asyncio.TimeoutError) as e:
|
except (ConnectionError, RuntimeError, asyncio.TimeoutError) as e:
|
||||||
logger.debug(
|
logger.debug("Portal WS send failed for user %s (marking stale): %s", user_id, e)
|
||||||
"Portal WS send failed for user %s (marking stale): %s", user_id, e
|
|
||||||
)
|
|
||||||
stale.append(ws)
|
stale.append(ws)
|
||||||
for ws in stale:
|
for ws in stale:
|
||||||
self.remove(user_id, ws)
|
self.remove(user_id, ws)
|
||||||
|
|
@ -740,6 +774,11 @@ async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_
|
||||||
read directly from SQLite (independent of the in-memory cache, which
|
read directly from SQLite (independent of the in-memory cache, which
|
||||||
may have an empty `messages` list after a restart). This prevents the
|
may have an empty `messages` list after a restart). This prevents the
|
||||||
regression where titles collapse to the placeholder "对话".
|
regression where titles collapse to the placeholder "对话".
|
||||||
|
|
||||||
|
Also tags each conversation with ``is_board`` so the sidebar can show
|
||||||
|
a "私董会" badge without having to fetch every conversation's full
|
||||||
|
history. The check is a cheap metadata LIKE query — the list
|
||||||
|
endpoint stays O(limit) even for hundreds of conversations.
|
||||||
"""
|
"""
|
||||||
convs = await _conversation_store.list_conversations(limit=limit)
|
convs = await _conversation_store.list_conversations(limit=limit)
|
||||||
result: list[dict] = []
|
result: list[dict] = []
|
||||||
|
|
@ -748,6 +787,7 @@ async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_
|
||||||
# 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(first_user.content if first_user else None)
|
title = _derive_conversation_title_from_content(first_user.content if first_user else None)
|
||||||
|
is_board = await _conversation_has_board_started(c.id)
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
|
|
@ -755,11 +795,31 @@ async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_
|
||||||
"created_at": c.created_at.isoformat(),
|
"created_at": c.created_at.isoformat(),
|
||||||
"updated_at": c.updated_at.isoformat(),
|
"updated_at": c.updated_at.isoformat(),
|
||||||
"message_count": len(c.messages),
|
"message_count": len(c.messages),
|
||||||
|
"is_board": is_board,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _conversation_has_board_started(conversation_id: str) -> bool:
|
||||||
|
"""Return True if the conversation contains a persisted board_started event.
|
||||||
|
|
||||||
|
Used by the sidebar list so it can render the "私董会" badge without
|
||||||
|
fetching the full message history. Reads from SQLite directly — the
|
||||||
|
in-memory cache may not be populated after a server restart.
|
||||||
|
|
||||||
|
Returns False on any storage error so the badge never blocks the
|
||||||
|
list endpoint.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await _conversation_store.has_message_with_type(
|
||||||
|
conversation_id, "board_started"
|
||||||
|
)
|
||||||
|
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
||||||
|
logger.warning("is_board lookup failed for %s", conversation_id, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _derive_conversation_title(conv: Conversation) -> str:
|
def _derive_conversation_title(conv: Conversation) -> str:
|
||||||
"""Derive a human-readable title from the first user message in the conversation object."""
|
"""Derive a human-readable title from the first user message in the conversation object."""
|
||||||
for msg in conv.messages:
|
for msg in conv.messages:
|
||||||
|
|
@ -768,10 +828,31 @@ def _derive_conversation_title(conv: Conversation) -> str:
|
||||||
return "对话"
|
return "对话"
|
||||||
|
|
||||||
|
|
||||||
|
_COMMAND_PREFIX_RE = re.compile(
|
||||||
|
r"^@(?:board|team)(?::[^\s]+)?(?:\s+rounds=\d+)?\s*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_command_prefix(content: str) -> str:
|
||||||
|
"""Strip a leading @board/@team command so conversation titles show only the topic.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"@board:warren,charlie 怎么看 AI" → "怎么看 AI"
|
||||||
|
"@team 私董会" → "私董会"
|
||||||
|
"@board rounds=3 软件行业" → "软件行业"
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
return _COMMAND_PREFIX_RE.sub("", content, count=1).strip()
|
||||||
|
|
||||||
|
|
||||||
def _derive_conversation_title_from_content(content: str | None) -> str:
|
def _derive_conversation_title_from_content(content: str | None) -> str:
|
||||||
"""Derive title from a string content (used when conv.messages is empty)."""
|
"""Derive title from a string content (used when conv.messages is empty)."""
|
||||||
if content:
|
if content:
|
||||||
return content[:20] + ("..." if len(content) > 20 else "")
|
cleaned = _strip_command_prefix(content)
|
||||||
|
if cleaned:
|
||||||
|
return cleaned[:20] + ("..." if len(cleaned) > 20 else "")
|
||||||
return "对话"
|
return "对话"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -797,21 +878,62 @@ async def get_conversation(
|
||||||
return {
|
return {
|
||||||
"id": conv.id,
|
"id": conv.id,
|
||||||
"title": _derive_conversation_title_from_content(first_user_content),
|
"title": _derive_conversation_title_from_content(first_user_content),
|
||||||
"messages": [
|
"messages": [_hydrate_persisted_message(conv.id, i, m) for i, m in enumerate(history)],
|
||||||
{
|
|
||||||
"id": f"{conv.id}-{i}",
|
|
||||||
"role": m.role,
|
|
||||||
"content": m.content,
|
|
||||||
"timestamp": m.timestamp.isoformat(),
|
|
||||||
"metadata": m.metadata,
|
|
||||||
}
|
|
||||||
for i, m in enumerate(history)
|
|
||||||
],
|
|
||||||
"created_at": conv.created_at.isoformat(),
|
"created_at": conv.created_at.isoformat(),
|
||||||
"updated_at": conv.updated_at.isoformat(),
|
"updated_at": conv.updated_at.isoformat(),
|
||||||
|
"is_board": any(
|
||||||
|
(m.metadata or {}).get("message_type") == "board_started"
|
||||||
|
for m in history
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Fields we store inside Message.metadata to reconstruct board_* messages
|
||||||
|
# after a page reload. The Message dataclass only has role/content/timestamp
|
||||||
|
# as first-class columns; everything else (message_type, expert identity,
|
||||||
|
# board round/role, conclusion payload) rides along in metadata.
|
||||||
|
_PERSISTED_MESSAGE_FIELDS = (
|
||||||
|
"message_type",
|
||||||
|
"expert_id",
|
||||||
|
"expert_name",
|
||||||
|
"expert_avatar",
|
||||||
|
"expert_color",
|
||||||
|
"board_round",
|
||||||
|
"board_role",
|
||||||
|
"board_conclusion",
|
||||||
|
"board_started",
|
||||||
|
"matched_skill",
|
||||||
|
"confidence",
|
||||||
|
"routing_method",
|
||||||
|
"thinking",
|
||||||
|
"tool_calls",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hydrate_persisted_message(conv_id: str, index: int, msg) -> dict:
|
||||||
|
"""Build the API response dict for a single persisted message.
|
||||||
|
|
||||||
|
Promotes well-known rendering fields from ``msg.metadata`` to the
|
||||||
|
top level so the frontend can render board_speech / board_summary /
|
||||||
|
board_conclusion cards after a reload — without this, every restored
|
||||||
|
assistant message would look like a plain chat bubble.
|
||||||
|
"""
|
||||||
|
payload: dict = {
|
||||||
|
"id": f"{conv_id}-{index}",
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content,
|
||||||
|
"timestamp": msg.timestamp.isoformat(),
|
||||||
|
"metadata": getattr(msg, "metadata", None) or {},
|
||||||
|
}
|
||||||
|
meta = payload["metadata"]
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return payload
|
||||||
|
for key in _PERSISTED_MESSAGE_FIELDS:
|
||||||
|
if key in meta and meta[key] is not None:
|
||||||
|
payload[key] = meta[key]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/portal/conversations/{conversation_id}")
|
@router.delete("/portal/conversations/{conversation_id}")
|
||||||
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)):
|
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)):
|
||||||
"""Delete a conversation and all its messages.
|
"""Delete a conversation and all its messages.
|
||||||
|
|
@ -866,7 +988,14 @@ async def _execute_react_background(
|
||||||
await _task_store_update_status(
|
await _task_store_update_status(
|
||||||
task_store, task_id, TaskStatus.RUNNING, started_at=datetime.now(timezone.utc)
|
task_store, task_id, TaskStatus.RUNNING, started_at=datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("Failed to update TaskStore RUNNING", exc_info=True)
|
logger.warning("Failed to update TaskStore RUNNING", exc_info=True)
|
||||||
|
|
||||||
async for event in react_engine.execute_stream(
|
async for event in react_engine.execute_stream(
|
||||||
|
|
@ -913,7 +1042,14 @@ async def _execute_react_background(
|
||||||
progress=1.0,
|
progress=1.0,
|
||||||
progress_message="Completed",
|
progress_message="Completed",
|
||||||
)
|
)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("Failed to update TaskStore COMPLETED", exc_info=True)
|
logger.warning("Failed to update TaskStore COMPLETED", exc_info=True)
|
||||||
|
|
||||||
# Emit task.completed so subscribers know the task is done
|
# Emit task.completed so subscribers know the task is done
|
||||||
|
|
@ -993,7 +1129,14 @@ async def _execute_react_background(
|
||||||
partial = _ensure_non_empty("".join(collected_output))
|
partial = _ensure_non_empty("".join(collected_output))
|
||||||
try:
|
try:
|
||||||
await conversation_store.add_message(conv_id, "assistant", partial)
|
await conversation_store.add_message(conv_id, "assistant", partial)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("Failed to persist partial output in background task")
|
logger.warning("Failed to persist partial output in background task")
|
||||||
|
|
||||||
if task_store is not None:
|
if task_store is not None:
|
||||||
|
|
@ -1005,7 +1148,14 @@ async def _execute_react_background(
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
completed_at=datetime.now(timezone.utc),
|
completed_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("Failed to update TaskStore FAILED", exc_info=True)
|
logger.warning("Failed to update TaskStore FAILED", exc_info=True)
|
||||||
|
|
||||||
# Emit task.failed so subscribers know the task failed
|
# Emit task.failed so subscribers know the task failed
|
||||||
|
|
@ -1140,7 +1290,14 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record = await _task_store_get(resume_task_store, resume_task_id)
|
record = await _task_store_get(resume_task_store, resume_task_id)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("TaskStore.get failed during resume", exc_info=True)
|
logger.warning("TaskStore.get failed during resume", exc_info=True)
|
||||||
record = None
|
record = None
|
||||||
if record is not None:
|
if record is not None:
|
||||||
|
|
@ -1312,6 +1469,18 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
conv = await _conversation_store.get_or_create(conv_id)
|
conv = await _conversation_store.get_or_create(conv_id)
|
||||||
await websocket.send_json({"type": "connected", "conversation_id": conv.id})
|
await websocket.send_json({"type": "connected", "conversation_id": conv.id})
|
||||||
|
|
||||||
|
# @board / @team intercept — mirror chat.py:1076-1081.
|
||||||
|
# Frontend WS connects to /api/v1/portal/ws (this router), not
|
||||||
|
# /api/v1/chat/ws/{session_id} (chat.py). Without this intercept
|
||||||
|
# @board/@team messages are treated as plain text and no
|
||||||
|
# board_started/team_formed events are broadcast, so the cards
|
||||||
|
# never render. Placed before task_id emit so intercepted
|
||||||
|
# messages don't leave orphan tasks in the EQ side-channel.
|
||||||
|
if await _execute_board_meeting(websocket, conv.id, message_text, _sm_adapter):
|
||||||
|
continue
|
||||||
|
if await _execute_team_collab(websocket, conv.id, message_text, _sm_adapter):
|
||||||
|
continue
|
||||||
|
|
||||||
# Generate task_id for this user message and emit task.created to EQ
|
# Generate task_id for this user message and emit task.created to EQ
|
||||||
# (EQ is a side-channel: emit failures never break the WebSocket flow)
|
# (EQ is a side-channel: emit failures never break the WebSocket flow)
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
|
|
@ -1353,7 +1522,13 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await _broadcast_dashboard_event("metrics_updated", {"period": "7d"})
|
await _broadcast_dashboard_event("metrics_updated", {"period": "7d"})
|
||||||
except (asyncio.QueueFull, RuntimeError, ConnectionError, ValueError, KeyError) as e:
|
except (
|
||||||
|
asyncio.QueueFull,
|
||||||
|
RuntimeError,
|
||||||
|
ConnectionError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
) as e:
|
||||||
logger.warning(f"Failed to record experience: {e}")
|
logger.warning(f"Failed to record experience: {e}")
|
||||||
|
|
||||||
# Unified preprocessing via RequestPreprocessor (minimal: @skill prefix + greeting regex + REACT)
|
# Unified preprocessing via RequestPreprocessor (minimal: @skill prefix + greeting regex + REACT)
|
||||||
|
|
@ -1434,7 +1609,14 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
TaskStatus.PENDING,
|
TaskStatus.PENDING,
|
||||||
metadata={"conversation_id": conv.id},
|
metadata={"conversation_id": conv.id},
|
||||||
)
|
)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("Failed to register task in TaskStore", exc_info=True)
|
logger.warning("Failed to register task in TaskStore", exc_info=True)
|
||||||
|
|
||||||
# Execute based on routing result's execution_mode
|
# Execute based on routing result's execution_mode
|
||||||
|
|
@ -1475,7 +1657,14 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
progress=1.0,
|
progress=1.0,
|
||||||
progress_message="Completed",
|
progress_message="Completed",
|
||||||
)
|
)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
logger.warning("Failed to update TaskStore for DIRECT_CHAT", exc_info=True)
|
logger.warning("Failed to update TaskStore for DIRECT_CHAT", exc_info=True)
|
||||||
|
|
||||||
# Emit turn.final_answer and task.completed to EQ
|
# Emit turn.final_answer and task.completed to EQ
|
||||||
|
|
@ -1546,7 +1735,14 @@ async def portal_websocket(websocket: WebSocket):
|
||||||
chat_messages.insert(
|
chat_messages.insert(
|
||||||
-1, {"role": hist_msg.role, "content": hist_msg.content}
|
-1, {"role": hist_msg.role, "content": hist_msg.content}
|
||||||
)
|
)
|
||||||
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError):
|
except (
|
||||||
|
ConnectionError,
|
||||||
|
OSError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
ValueError,
|
||||||
|
KeyError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
pass
|
pass
|
||||||
response = await llm_gateway.chat(
|
response = await llm_gateway.chat(
|
||||||
messages=chat_messages,
|
messages=chat_messages,
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
.side-nav[data-v-ffaa5764]{height:100vh;overflow-y:auto;display:flex;flex-direction:column}.side-nav[data-v-ffaa5764] .ant-layout-sider-children{display:flex;flex-direction:column;height:100%}.side-nav__logo[data-v-ffaa5764]{height:64px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid rgba(255,255,255,.1)}.side-nav__title[data-v-ffaa5764]{color:#fff;font-size:18px;font-weight:600;margin:0;white-space:nowrap}.side-nav__footer[data-v-ffaa5764]{margin-top:auto;padding:16px 24px;border-top:1px solid rgba(255,255,255,.1)}.side-nav__footer[data-v-ffaa5764] .ant-badge-status-text{color:#ffffffa6;font-size:12px}.app-layout[data-v-1f8febf9]{height:100vh;width:100vw}.app-layout__main[data-v-1f8febf9]{flex:1;overflow:hidden;background:var(--bg-tertiary)}
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Fischer AgentKit</title>
|
<title>Fischer AgentKit</title>
|
||||||
<script type="module" crossorigin src="/assets/index-a-0N3I41.js"></script>
|
<script type="module" crossorigin src="/assets/index-CHN0ThS0.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Mld5F0pG.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DC_0XUg6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class BitableTool(Tool):
|
||||||
"""Agent tool for bitable operations via REST API.
|
"""Agent tool for bitable operations via REST API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_url: Bitable API base URL (e.g. ``http://localhost:8001/api/v1/bitable``).
|
base_url: Bitable API base URL (e.g. ``http://localhost:18001/api/v1/bitable``).
|
||||||
internal_token: Service token for KTD11 auth. If ``None``, requests
|
internal_token: Service token for KTD11 auth. If ``None``, requests
|
||||||
go unauthenticated (will fail if the server requires auth).
|
go unauthenticated (will fail if the server requires auth).
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@ import os
|
||||||
# Must be set before importing portal module (which reads this at module level)
|
# Must be set before importing portal module (which reads this at module level)
|
||||||
os.environ.setdefault("AGENTKIT_WS_TIMEOUT", "0")
|
os.environ.setdefault("AGENTKIT_WS_TIMEOUT", "0")
|
||||||
|
|
||||||
|
# Force in-memory backends for test isolation.
|
||||||
|
# .env.dev may set AGENTKIT_*_BACKEND=redis for the dev server, but unit tests
|
||||||
|
# must use memory to avoid cross-test data leakage and Redis dependencies.
|
||||||
|
# load_dotenv(override=False) in app.py won't overwrite these.
|
||||||
|
os.environ["AGENTKIT_SESSION_BACKEND"] = "memory"
|
||||||
|
os.environ["AGENTKIT_BUS_BACKEND"] = "memory"
|
||||||
|
os.environ["AGENTKIT_TASK_STORE_BACKEND"] = "memory"
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""Regression tests for port configuration and tool registration.
|
||||||
|
|
||||||
|
These tests prevent the recurrence of two bugs found on 2026-07-01:
|
||||||
|
|
||||||
|
1. **Port conflict (login hijack)**: vite.config.ts hardcoded proxy target
|
||||||
|
to ``localhost:8000``, which was occupied by the ``geo_backend`` Docker
|
||||||
|
container. Login requests were silently proxied to the wrong service,
|
||||||
|
returning a "请求参数校验失败" error because geo_backend expected an
|
||||||
|
``email`` field while the frontend sent ``username``.
|
||||||
|
|
||||||
|
2. **Missing tool registration**: ``benchmark_runner.yaml`` referenced
|
||||||
|
``file_read`` but the tool's actual name is ``read_file``, and
|
||||||
|
``ReadFileTool`` was never registered in ``app.py``'s lifespan — causing
|
||||||
|
"Tool not found: file_read" at skill-load time.
|
||||||
|
|
||||||
|
3. **.env.dev not loaded**: ``load_dotenv`` only looked for ``.env``,
|
||||||
|
missing ``.env.dev`` (the in-tree dev preset with DATABASE_URL /
|
||||||
|
REDIS_URL / port allocation), causing Bitable init failure.
|
||||||
|
|
||||||
|
The tests are intentionally static (grep-based) — they verify that the
|
||||||
|
canonical port numbers and tool names are consistent across all config
|
||||||
|
layers, without booting a server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Project root — tests/unit/server/ → ../../../..
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Port configuration — no legacy 8000/8001/5173 in canonical files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Files where hardcoded 8000 / 8001 / 5173 are bugs (should use 18001 etc.)
|
||||||
|
_FILES_NO_LEGACY_PORTS = [
|
||||||
|
"agentkit.yaml",
|
||||||
|
"src/agentkit/server/config.py",
|
||||||
|
"src/agentkit/server/client.py",
|
||||||
|
"src/agentkit/cli/main.py",
|
||||||
|
"src/agentkit/cli/templates.py",
|
||||||
|
"src/agentkit/cli/onboarding.py",
|
||||||
|
"src/agentkit/cli/admin_client.py",
|
||||||
|
"src/agentkit/cli/admin.py",
|
||||||
|
"src/agentkit/cli/pair.py",
|
||||||
|
"src/agentkit/cli/init.py",
|
||||||
|
"src/agentkit/client/sync.py",
|
||||||
|
"src/agentkit/tools/bitable_tool.py",
|
||||||
|
"src/agentkit/memory/http_rag.py",
|
||||||
|
"src/agentkit/memory/adapters/generic_http.py",
|
||||||
|
"src/agentkit/server/frontend/vite.config.ts",
|
||||||
|
"src/agentkit/server/frontend/src-tauri/src/lib.rs",
|
||||||
|
"src/agentkit/server/frontend/src-tauri/tauri.conf.json",
|
||||||
|
"src/agentkit/server/frontend/src/stores/settings.ts",
|
||||||
|
"src/agentkit/server/frontend/src/stores/auth.ts",
|
||||||
|
"src/agentkit/server/frontend/playwright.config.ts",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Patterns that indicate a legacy hardcoded port (not inside a comment-only line).
|
||||||
|
# Matches :8000, :8001, :5173, :8002, port=8000, port=8001, port: 8001 etc.
|
||||||
|
_LEGACY_PORT_RE = re.compile(
|
||||||
|
r"""(?: # Match any of:
|
||||||
|
:8000 # URL-style :8000
|
||||||
|
| :8001 # URL-style :8001
|
||||||
|
| :8002 # URL-style :8002
|
||||||
|
| :5173 # Vite default :5173
|
||||||
|
| port['"]?\s*[:=]\s*800[012]\b # port: 8001 / port = 8001 / port' = 8001
|
||||||
|
| port['"]?\s*[:=]\s*5173\b # port: 5173
|
||||||
|
)""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rel_path", _FILES_NO_LEGACY_PORTS)
|
||||||
|
def test_no_legacy_hardcoded_ports(rel_path: str) -> None:
|
||||||
|
"""Canonical config/source files must not hardcode 8000/8001/5173/8002.
|
||||||
|
|
||||||
|
These ports conflict with Docker services (geo_backend:8000) or other
|
||||||
|
dev projects (Vite:5173). The canonical ports are 18001 (backend),
|
||||||
|
18002 (GUI), 15173 (Vite dev), 15174 (HMR).
|
||||||
|
|
||||||
|
Exceptions: comments mentioning the old ports for historical context
|
||||||
|
are allowed — we only flag actual code/config lines.
|
||||||
|
"""
|
||||||
|
fpath = PROJECT_ROOT / rel_path
|
||||||
|
if not fpath.exists():
|
||||||
|
pytest.skip(f"File not found: {rel_path}")
|
||||||
|
|
||||||
|
content = fpath.read_text(encoding="utf-8")
|
||||||
|
offending = []
|
||||||
|
for lineno, line in enumerate(content.splitlines(), 1):
|
||||||
|
stripped = line.strip()
|
||||||
|
# Skip comment-only lines (Python #, YAML #, Rust //, TS //)
|
||||||
|
if stripped.startswith(("#", "//")) or stripped.startswith("///"):
|
||||||
|
continue
|
||||||
|
if _LEGACY_PORT_RE.search(line):
|
||||||
|
offending.append(f" {rel_path}:{lineno}: {stripped}")
|
||||||
|
assert not offending, (
|
||||||
|
f"Legacy port (8000/8001/5173/8002) found in {rel_path}:\n"
|
||||||
|
+ "\n".join(offending)
|
||||||
|
+ "\nUse 18001 (backend) / 18002 (GUI) / 15173 (Vite) / 15174 (HMR)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Tool registration — ReadFileTool registered + yaml name matches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_readfiletool_registered_in_app_py() -> None:
|
||||||
|
"""app.py lifespan must register ReadFileTool so benchmark_runner works."""
|
||||||
|
app_py = (PROJECT_ROOT / "src/agentkit/server/app.py").read_text(encoding="utf-8")
|
||||||
|
assert "from agentkit.tools.file_read import ReadFileTool" in app_py, (
|
||||||
|
"ReadFileTool import missing from app.py — benchmark_runner skill "
|
||||||
|
"will fail with 'Tool not found: read_file'"
|
||||||
|
)
|
||||||
|
assert "register(ReadFileTool()" in app_py, (
|
||||||
|
"ReadFileTool registration call missing from app.py lifespan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_benchmark_runner_yaml_uses_correct_tool_name() -> None:
|
||||||
|
"""benchmark_runner.yaml must reference 'read_file' (not 'file_read')."""
|
||||||
|
yaml_path = PROJECT_ROOT / "configs/skills/benchmark_runner.yaml"
|
||||||
|
if not yaml_path.exists():
|
||||||
|
pytest.skip("benchmark_runner.yaml not found")
|
||||||
|
content = yaml_path.read_text(encoding="utf-8")
|
||||||
|
# The tools section should contain read_file, not file_read / file_write
|
||||||
|
assert "file_read" not in content or "read_file" in content, (
|
||||||
|
"benchmark_runner.yaml references 'file_read' — the tool's actual "
|
||||||
|
"name is 'read_file'. This causes 'Tool not found' at load time."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. .env.dev loading — load_dotenv supports .env.dev candidate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dotenv_supports_env_dev() -> None:
|
||||||
|
"""app.py must load .env.dev (not just .env) for dev port/DB config."""
|
||||||
|
app_py = (PROJECT_ROOT / "src/agentkit/server/app.py").read_text(encoding="utf-8")
|
||||||
|
assert ".env.dev" in app_py, (
|
||||||
|
"app.py does not load .env.dev — DATABASE_URL / REDIS_URL / port "
|
||||||
|
"overrides in .env.dev will be silently ignored, causing Bitable "
|
||||||
|
"init failure and Redis 'not_configured' status."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_dev_contains_required_keys() -> None:
|
||||||
|
""" .env.dev must define all keys needed for full functional testing."""
|
||||||
|
env_dev = PROJECT_ROOT / ".env.dev"
|
||||||
|
if not env_dev.exists():
|
||||||
|
pytest.skip(".env.dev not found")
|
||||||
|
content = env_dev.read_text(encoding="utf-8")
|
||||||
|
required_keys = [
|
||||||
|
"AGENTKIT_SERVER_PORT",
|
||||||
|
"BACKEND_PORT",
|
||||||
|
"VITE_DEV_PORT",
|
||||||
|
"VITE_HMR_PORT",
|
||||||
|
"AGENTKIT_REMOTE_PORT",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"REDIS_URL",
|
||||||
|
"AGENTKIT_SESSION_BACKEND",
|
||||||
|
"AGENTKIT_BUS_BACKEND",
|
||||||
|
"AGENTKIT_TASK_STORE_BACKEND",
|
||||||
|
]
|
||||||
|
missing = [k for k in required_keys if k not in content]
|
||||||
|
assert not missing, f".env.dev missing required keys: {missing}"
|
||||||
Loading…
Reference in New Issue