Compare commits

...

2 Commits

Author SHA1 Message Date
chiguyong 78a7faa17b refactor: remove all emoji from agentkit
Deploy to Production / deploy (push) Waiting to run Details
Test / backend-test (push) Waiting to run Details
Test / frontend-unit (push) Waiting to run Details
Test / api-e2e (push) Waiting to run Details
Test / frontend-e2e (push) Waiting to run Details
Test / backend-test (pull_request) Has been cancelled Details
Test / frontend-unit (pull_request) Has been cancelled Details
Test / api-e2e (pull_request) Has been cancelled Details
Test / frontend-e2e (pull_request) Has been cancelled Details
Replace emoji across codebase: YAML avatars -> first char, frontend banners -> Ant Design Vue components, CLI status -> OK/FAIL/WARN labels, terminal -> [WARN]/[OK]/[PENDING], Bitable DB default -> table, App.vue font cleanup, test fixtures -> first char letters. shell.avatar type upgraded to string | Component.
2026-07-02 01:33:28 +08:00
chiguyong 36b0296730 fix: 私董会数据持久化修复 + emoji 移除计划
- 修复 board_started/expert_speech/round_summary/board_concluded 事件持久化
- 添加 is_board 标记到会话列表和详情接口
- 实现 restoreBoardStateFromMessages 从持久化消息恢复 boardState
- 添加 ChatSidebar 私董会徽章
- 添加 emoji 移除计划文档 (docs/plans/2026-07-02-001)
2026-07-02 01:07:12 +08:00
111 changed files with 2829 additions and 641 deletions

View File

@ -73,7 +73,7 @@ jobs:
echo "尝试 $i/30: 服务未就绪,等待 5 秒..."
sleep 5
done
echo " 健康检查失败"
echo "[FAIL] 健康检查失败"
docker compose -f "$REPO_DIR/$COMPOSE_FILE" logs --tail=100
exit 1

View File

@ -1,6 +1,6 @@
server:
host: 0.0.0.0
port: 8001
port: 18001
workers: 1
rate_limit: 60
llm:
@ -64,9 +64,17 @@ fallback_chain:
# whitelist_override: # optional, merges with default (override wins)
# planning: [search, read_file, shell]
# building: [write_file, shell, read_file]
session: {backend: memory}
bus: {backend: memory}
task_store: {backend: memory}
# G10/U5: Use Redis for bus / session / task_store when REDIS_URL is set.
# Falls back to in-memory when REDIS_URL is unset (development fallback).
# 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"]}
experts: {paths: ["./configs/experts"]}
board: {max_rounds: 5, default_template: private_board, parallel_speech: true, history_compression_threshold: 20}

View File

@ -15,7 +15,7 @@ config:
decision_framework: "用户价值优先 — 问'这会让用户觉得简单吗'和'它在 5 年后还有意义吗'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "💬"
avatar: "A"
color: "#07C160"
is_lead: false
task_mode: llm_generate

View File

@ -14,7 +14,7 @@ config:
decision_framework: "系统设计 — 评估性能、安全性、可扩展性,遵循 SOLID 原则"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "⚙️"
avatar: "B"
color: "#fa8c16"
is_lead: false
task_mode: llm_generate

View File

@ -15,7 +15,7 @@ config:
decision_framework: "逆向思考 — 问'怎样做会必然失败',然后避免它"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🧠"
avatar: "C"
color: "#2C3E50"
is_lead: false
task_mode: llm_generate

View File

@ -14,7 +14,7 @@ config:
decision_framework: "代码质量 — 规范性、安全性、可维护性、性能四维评估"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🛡️"
avatar: "C"
color: "#722ed1"
is_lead: false
task_mode: llm_generate

View File

@ -17,7 +17,7 @@ config:
- backend_engineer
- qa_engineer
- code_reviewer
avatar: "👥"
avatar: "D"
color: "#1890ff"
is_lead: false
task_mode: llm_generate

View File

@ -14,7 +14,7 @@ config:
decision_framework: "第一性原理 — 问'这件事的物理学本质是什么',再推导可行性"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🚀"
avatar: "E"
color: "#E31937"
is_lead: false
task_mode: llm_generate

View File

@ -14,7 +14,7 @@ config:
decision_framework: "用户体验优先 — 在技术实现和用户需求之间找到最佳平衡"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🎨"
avatar: "F"
color: "#52c41a"
is_lead: false
task_mode: llm_generate

View File

@ -15,7 +15,7 @@ config:
decision_framework: "客户至上 + 长期主义 — 问'什么对客户最好'和'这个决策 10 年后是否仍正确'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "📦"
avatar: "J"
color: "#FF9900"
is_lead: false
task_mode: llm_generate

View File

@ -16,7 +16,7 @@ config:
decision_framework: "用户价值 + 不寻常路 — 问'用户真的想要这个吗'和'这看起来像坏想法吗'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "📝"
avatar: "P"
color: "#FF6600"
is_lead: false
task_mode: llm_generate

View File

@ -17,7 +17,7 @@ config:
- allenzhang
- charlie_munger
- paul_graham
avatar: "🏛️"
avatar: "P"
color: "#8E44AD"
is_lead: false
task_mode: llm_generate

View File

@ -14,7 +14,7 @@ config:
decision_framework: "质量保障 — 测试覆盖率、边界条件、回归测试三重保障"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🔍"
avatar: "Q"
color: "#eb2f96"
is_lead: false
task_mode: llm_generate

View File

@ -16,7 +16,7 @@ config:
decision_framework: "原则驱动 — 问'这符合哪条原则'和'最可信的人怎么看'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "⚖️"
avatar: "R"
color: "#1A5276"
is_lead: false
task_mode: llm_generate

View File

@ -16,7 +16,7 @@ config:
decision_framework: "用户体验 + 专注 — 问'这足够简单吗'和'这是我能做的最好的吗'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🍎"
avatar: "S"
color: "#555555"
is_lead: false
task_mode: llm_generate

View File

@ -14,7 +14,7 @@ config:
decision_framework: "架构决策 — 评估可行性、可维护性、扩展性,权衡短期与长期"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🏗️"
avatar: "T"
color: "#1890ff"
is_lead: true
task_mode: llm_generate

View File

@ -16,7 +16,7 @@ config:
decision_framework: "能力圈 + 内在价值 — 问'我理解这个业务吗'和'它的内在价值是多少'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "💰"
avatar: "W"
color: "#1E8449"
is_lead: false
task_mode: llm_generate

View File

@ -200,8 +200,7 @@ llm:
tools:
- shell
- file_read
- file_write
- read_file
quality_gate:
required_fields: ["content"]

View File

@ -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`(首字母)则原样渲染。
### KTD3CLI 状态标记用 Rich 文本标签 + 颜色,弃用 `✓` `✗` `⚠`
**决策**`OK` / `FAIL` / `WARN` 作为文本标签,配合 Rich `[green]` / `[red]` / `[yellow]` 颜色。横幅标题用 `[OK 验收结果]` / `[WARN 风险标记]` 形式保留 emoji 缺失的视觉强度。
**理由**
- 终端字体差异Windows Terminal / 容器 tty / 旧版 macOS Terminal 对 Unicode 符号渲染不一致
- 颜色已能传达同样信息量
- 与 Python `rich` 库的最佳实践一致(标签 + 颜色)
### KTD4Bitable 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 备份快照或导出/导入,可能需要清理脚本。本次不处理。
### KTD5App.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 依赖 U1chatStore.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**: Standard5 个 implementation units跨前后端+CLI+DB+测试+lint 守卫)
- **Expected commits**: 5每个 U 一个 commitU5 包含 lint 规则可能拆 2 个)
- **Risk profile**: 中(无安全/支付/外部 API 风险,主要是视觉/UX 风险)
- **Origin**: 用户直接在 ce-plan 中提出(无 upstream brainstorm doc
- **Confidence**: 中高KTD1-4 都有项目内既有模式可遵循KTD5/6 引入新机制U5 留验证空间)

View File

@ -65,7 +65,7 @@ class FileModel(BitableBase):
id = Column(String, primary_key=True, default=_uuid_str)
name = Column(String, nullable=False)
icon = Column(String, default="📋")
icon = Column(String, default="table")
description = Column(Text, default="")
owner_user_id = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
@ -213,7 +213,7 @@ async def _apply_v2_migration(conn: object) -> None:
"CREATE TABLE IF NOT EXISTS bitable.bitable_files ("
" id VARCHAR PRIMARY KEY,"
" name VARCHAR NOT NULL,"
" icon VARCHAR DEFAULT '📋',"
" icon VARCHAR DEFAULT 'table',"
" description TEXT DEFAULT '',"
" owner_user_id VARCHAR,"
" created_at TIMESTAMPTZ DEFAULT NOW(),"

View File

@ -74,7 +74,10 @@ class BitableFile(BaseModel):
id: 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 = ""
owner_user_id: str | None = None
created_at: datetime = PydanticField(default_factory=_utcnow)

View File

@ -61,7 +61,7 @@ class BitableRepository:
async def create_file(
self,
name: str,
icon: str = "📋",
icon: str = "table",
description: str = "",
owner_user_id: str | None = None,
) -> BitableFile:

View File

@ -91,7 +91,7 @@ class BitableService:
async def create_file(
self,
name: str,
icon: str = "📋",
icon: str = "table",
description: str = "",
owner_user_id: str | None = None,
) -> BitableFile:

View File

@ -368,3 +368,42 @@ class SqliteConversationStore:
except (aiosqlite.Error, ValueError, KeyError, TypeError, RuntimeError) as e:
logger.warning(f"get_first_user_message failed for {conversation_id}: {e}")
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

View File

@ -36,7 +36,7 @@ console = Console()
# ---------------------------------------------------------------------------
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")
ApiKeyOption = typer.Option(None, "--api-key", "-k", help="API key")
@ -161,7 +161,7 @@ def admin_login(
),
) -> None:
"""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)
try:
token = client.login(username, password)
@ -212,7 +212,7 @@ def dept_list(
str(d.get("id", "")),
str(d.get("name", "")),
str(d.get("description", "")),
"" if d.get("is_active") else "",
"OK" if d.get("is_active") else "--",
str(d.get("created_at", "")),
)
console.print(table)
@ -598,7 +598,7 @@ def user_list(
str(u.get("username", "")),
str(u.get("email", "")),
str(u.get("role", "")),
"" if u.get("is_active") else "",
"OK" if u.get("is_active") else "--",
)
console.print(table)

View File

@ -14,7 +14,7 @@ from pathlib import Path
import httpx
import yaml
DEFAULT_SERVER_URL = "http://localhost:8001"
DEFAULT_SERVER_URL = "http://localhost:18001"
DEFAULT_CONFIG_PATH = Path.home() / ".agentkit" / "admin_config.yaml"
DEFAULT_TIMEOUT = 30.0

View File

@ -892,7 +892,7 @@ async def _run_llm_reasoning(
)
cases.append(case)
if verbose:
status = "[green][/green]" if case.passed else "[red][/red]"
status = "[green]OK[/green]" if case.passed else "[red]FAIL[/red]"
console.print(
f" {status} {task.task_id}: {result.actual} ({result.duration_ms:.2f}ms)"
)
@ -986,7 +986,7 @@ async def _run_gui_integration(
def _log(tid: str, passed: bool, label: str) -> None:
if verbose:
status = "[green]✓[/green]" if passed else "[red]✗[/red]"
status = "[green]OK[/green]" if passed else "[red]FAIL[/red]"
console.print(f" {status} {tid}: {label}")
all_runs_cases: list[list[CaseResult]] = []
@ -2029,7 +2029,7 @@ async def _run_dimension(
cases.append(case)
if verbose:
status = "[green][/green]" if case.passed else "[red][/red]"
status = "[green]OK[/green]" if case.passed else "[red]FAIL[/red]"
console.print(
f" {status} {task.task_id}: {result.actual} ({result.duration_ms:.2f}ms)"
)
@ -2730,7 +2730,7 @@ def benchmark(
components = _build_real_components()
if components is None:
console.print(
"[yellow] LLM mode skipped — no valid agentkit.yaml or API key.[/yellow]"
"[yellow]WARN LLM mode skipped — no valid agentkit.yaml or API key.[/yellow]"
)
else:
preprocessor, _skill_registry, llm_gateway = components
@ -2764,7 +2764,7 @@ def benchmark(
progress.update(task, completed=True, total=1)
if not results:
console.print("[yellow] No dimensions were run.[/yellow]")
console.print("[yellow]WARN No dimensions were run.[/yellow]")
return
# Display summary table
@ -2781,13 +2781,13 @@ def benchmark(
if fail_all == 0:
summary = f"All {pass_all} tests passed across {len(results)} dimensions."
console.print(f"[bold green] {summary}[/bold green]")
console.print(f"[bold green]OK {summary}[/bold green]")
else:
summary = (
f"{pass_all}/{total_all} tests passed ({fail_all} failed) "
f"across {len(results)} dimensions."
)
console.print(f"[bold yellow] {summary}[/bold yellow]")
console.print(f"[bold yellow]WARN {summary}[/bold yellow]")
console.print()

View File

@ -586,7 +586,7 @@ def _render_pm_collaboration_event(message: dict) -> bool:
rprint(
Panel(
"\n".join(lines),
title=f"[bold]{'' if passed else ''} 验收结果[/bold]",
title=f"[bold]{'OK' if passed else 'FAIL'} 验收结果[/bold]",
border_style=color,
)
)
@ -600,7 +600,7 @@ def _render_pm_collaboration_event(message: dict) -> bool:
f"[bold]专家:[/bold] {expert}\n"
f"[bold]阶段:[/bold] {phase_name}\n"
f"[bold]风险:[/bold] {risk_desc}",
title="[bold] 风险标记[/bold]",
title="[bold]WARN 风险标记[/bold]",
border_style="yellow",
)
)
@ -689,9 +689,9 @@ async def _execute_team_cli(
elif etype == "plan_update":
phases = message.get("plan_phases", [])
icon_map = {
"completed": ("", "green"),
"completed": ("OK", "green"),
"in_progress": ("", "blue"),
"failed": ("", "red"),
"failed": ("FAIL", "red"),
}
lines = []
for ph in phases:
@ -724,10 +724,10 @@ async def _execute_team_cli(
)
elif etype == "phase_completed":
summary = message.get("result_summary", "")
rprint(f" [green] {message.get('phase_name', '?')}[/green]: {summary[:120]}")
rprint(f" [green]OK {message.get('phase_name', '?')}[/green]: {summary[:120]}")
elif etype == "phase_failed":
rprint(
f" [red] {message.get('phase_name', '?')}[/red]: {message.get('error', '')}"
f" [red]FAIL {message.get('phase_name', '?')}[/red]: {message.get('error', '')}"
)
elif etype == "debate_started":
rprint(

View File

@ -51,4 +51,4 @@ def init(
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(" 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]")

View File

@ -51,7 +51,7 @@ app.command(name="benchmark")(benchmark)
@app.command()
def gui(
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"),
no_open: bool = typer.Option(False, "--no-open", help="Do not open browser automatically"),
):
@ -166,7 +166,7 @@ def gui(
@app.command()
def serve(
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"),
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
@ -228,7 +228,7 @@ def serve(
# CLI args override config file
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
# Store config for app factory
@ -288,7 +288,7 @@ def version():
@app.command()
def doctor(
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"""
import httpx

View File

@ -290,7 +290,7 @@ def run_onboarding(
config = {
"server": {
"host": "0.0.0.0",
"port": 8001,
"port": 18001,
"workers": 1,
"rate_limit": 60,
},

View File

@ -39,7 +39,7 @@ def pair(
config_dir: str = typer.Option(".", "--config-dir", help="AgentKit config directory"),
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"),
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)"""
config_dir = os.path.abspath(config_dir)

View File

@ -293,7 +293,7 @@ def _render_risk_guard_suggestions(suggestions: list) -> None:
return
rprint(
"[bold yellow] 以下为自动生成的风险守卫建议,"
"[bold yellow]WARN 以下为自动生成的风险守卫建议,"
"必须人工审查后手动编辑 YAML 应用,不会自动生效。[/bold yellow]\n"
)
table = Table(title="Risk Guard Suggestions (待人工审查)")

View File

@ -6,7 +6,7 @@ AGENTKIT_YAML = """\
server:
host: "0.0.0.0"
port: 8001
port: 18001
workers: 1
api_key: null # Set to enable API key authentication
rate_limit: 60 # Requests per minute
@ -77,9 +77,9 @@ version: "3.8"
services:
agentkit:
build: .
command: serve --host 0.0.0.0 --port 8001
command: serve --host 0.0.0.0 --port 18001
ports:
- "8001:8001"
- "18001:18001"
env_file: .env
depends_on:
redis:
@ -87,7 +87,7 @@ services:
postgres:
condition: service_healthy
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
timeout: 10s
retries: 3

View File

@ -9,7 +9,7 @@ Usage::
from agentkit.client.sync import ConfigSync
sync = ConfigSync(
server_url="http://localhost:8001",
server_url="http://localhost:18001",
token_provider=lambda: jwt_token, # or None for dev mode
cache_db_path="~/.agentkit/config_cache.db",
)
@ -78,7 +78,7 @@ class ConfigSync:
changes on a configurable interval.
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
(or ``None`` if not authenticated). Called on each request.
cache_db_path: Path to the local SQLite cache file.

View File

@ -104,7 +104,7 @@ class ExecutionPlan:
def to_readable(self) -> str:
"""序列化为可读格式,用于人工确认"""
lines = [f"📋 执行计划 [{self.plan_id}]", f"目标: {self.goal}", ""]
lines = [f"[Plan] 执行计划 [{self.plan_id}]", f"目标: {self.goal}", ""]
for group_idx, group in enumerate(self.parallel_groups):
lines.append(f"── 并行组 {group_idx + 1} ──")
@ -119,7 +119,7 @@ class ExecutionPlan:
lines.append("")
if self.skill_gaps:
lines.append("⚠️ 能力缺口:")
lines.append("[WARN] 能力缺口:")
for gap in self.skill_gaps:
lines.append(f" - {gap.step_name}: 缺少 '{gap.required_skill}' ({gap.level.value})")
if gap.suggestion:

View File

@ -75,9 +75,7 @@ class BoardOrchestrator:
# Promote first active expert to moderator
self._team._moderator_name = active[0].config.name
moderator = active[0]
logger.warning(
f"Moderator not available, falling back to '{moderator.config.name}'"
)
logger.warning(f"Moderator not available, falling back to '{moderator.config.name}'")
self._team.set_status(BoardStatus.DISCUSSING)
@ -128,22 +126,22 @@ class BoardOrchestrator:
logger.info(f"Discussion stopped by user at round {round_num}")
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
if members:
speech_results = await asyncio.gather(
*[self._generate_expert_speech(e, round_num) for e in members],
return_exceptions=True,
)
# 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}"
)
for expert in members:
try:
result = await self._generate_expert_speech(expert, round_num)
except Exception as e:
logger.warning(f"Expert '{expert.config.name}' speech failed: {e}")
continue
await self._team.add_to_history(
round_num, expert.config.name, result, "expert"
)
@ -265,12 +263,17 @@ class BoardOrchestrator:
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
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:
- Expert's persona, thinking_style, speaking_style, decision_framework
- Full discussion history
- 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)
if not gateway:
@ -300,11 +303,126 @@ class BoardOrchestrator:
"- 给出明确的立场或建议\n"
)
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
return response.content.strip()
# 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(
messages=[{"role": "user", "content": prompt}],
model="default",
)
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:
"""Generate moderator's round summary.
@ -316,15 +434,11 @@ class BoardOrchestrator:
return f"[第 {round} 轮小结因 LLM 不可用无法生成]"
# Get only current round's speeches
round_history = [
h for h in self._team.history if h["round"] == round
]
round_history = [h for h in self._team.history if h["round"] == round]
if not round_history:
return ""
round_text = "\n\n".join(
f"[{h['expert_name']}]: {h['content']}" for h in round_history
)
round_text = "\n\n".join(f"[{h['expert_name']}]: {h['content']}" for h in round_history)
prompt = (
f"你是私董会主持人 {moderator.config.name}\n"
@ -427,7 +541,9 @@ class BoardOrchestrator:
"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.
Uses existing discussion history to provide a basic summary.

View File

@ -82,7 +82,7 @@ class ExpertTemplateRegistry:
bound_skills:
- data_query
- chart_gen
avatar: "📊"
avatar: "首字符"
color: "#52c41a"
is_lead: false

View File

@ -32,7 +32,7 @@ class GenericHTTPAdapter(KBAdapter):
典型配置::
adapter = GenericHTTPAdapter(
endpoint_url="http://localhost:8000/api/knowledge",
endpoint_url="http://localhost:18001/api/knowledge",
auth_config={"type": "bearer", "token": "sk-xxx"},
headers={"X-Custom-Header": "value"},
)

View File

@ -36,7 +36,7 @@ class HttpRAGService:
memory:
semantic:
enabled: true
base_url: "http://localhost:8000/api/knowledge"
base_url: "http://localhost:18001/api/knowledge"
api_key: "${GEO_API_KEY}"
knowledge_base_ids:
- "industry-kb-id"
@ -56,7 +56,7 @@ class HttpRAGService:
):
"""
Args:
base_url: 知识库 API 基础地址 http://localhost:8000/api/knowledge
base_url: 知识库 API 基础地址 http://localhost:18001/api/knowledge
api_key: 认证 API Key放在 Authorization: Bearer
knowledge_base_ids: 默认检索的知识库 ID 列表
timeout: HTTP 请求超时秒数

View File

@ -183,6 +183,7 @@ async def lifespan(app: FastAPI):
from agentkit.tools.web_crawl import WebCrawlTool
from agentkit.tools.baidu_search import BaiduSearchTool
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.db import init_documents_db
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(WebSearchTool(**search_api_keys))
agent._tool_registry.register(WebCrawlTool())
agent._tool_registry.register(ReadFileTool()) # benchmark_runner skill binding
# Document processing tool (U6): DocumentService with all renderers.
# On failure the tool is simply unavailable — app.state.document_service
@ -816,11 +818,22 @@ def create_app(
config_path = str(_cwd_yaml)
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_URLBitTable 初始化失败。三个候选按优先级叠加
# 加载(不互相覆盖 os.environ与 docker-compose 的
# env_file priority 行为一致。升级路径:如果项目开始用
# python-dotenv可以替换为它获得 quoted values / escape 支持。
from pathlib import Path as _P
_dotenv = _P(config_path).parent / ".env"
load_dotenv(_dotenv)
_config_dir = _P(config_path).parent
for _candidate in (".env", ".env.dev", ".env.local"):
load_dotenv(_config_dir / _candidate)
server_config = ServerConfig.from_yaml(config_path)
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)

View File

@ -179,6 +179,11 @@ def create_token_pair(
"type": "refresh",
"iat": int(issued_at.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:
access_payload["sid"] = effective_session_id

View File

@ -8,7 +8,7 @@ import httpx
class AgentKitClient:
"""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._client = httpx.AsyncClient(base_url=self._base_url)

View File

@ -91,7 +91,7 @@ class ServerConfig:
def __init__(
self,
host: str = "0.0.0.0",
port: int = 8001,
port: int = 18001,
workers: int = 1,
api_key: str | None = None,
rate_limit: int = 60,
@ -264,7 +264,7 @@ class ServerConfig:
return cls(
host=server.get("host", "0.0.0.0"),
port=server.get("port", 8001),
port=server.get("port", 18001),
workers=server.get("workers", 1),
api_key=server.get("api_key"),
rate_limit=server.get("rate_limit", 60),

View File

@ -131,6 +131,7 @@ declare module 'vue' {
OptimizationPanel: typeof import('./src/components/evolution/OptimizationPanel.vue')['default']
ParallelNode: typeof import('./src/components/workflow/ParallelNode.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']
PitfallRoutePanel: typeof import('./src/components/evolution/PitfallRoutePanel.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']
SplashScreen: typeof import('./src/components/layout/SplashScreen.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']
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default']
SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default']

View File

@ -28,7 +28,7 @@ import { TEST_USER, clearAuth } from './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 AUTH_BASE = `${API_BASE}/auth`

View File

@ -24,7 +24,7 @@ import { TEST_USER, clearAuth, reloadAndWaitAuth } from './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). */
let _cachedToken: string | null = null

View File

@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
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')
/** Poll a URL until it returns 200 or the timeout expires. */

View File

@ -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
* test context) and browser context. The Vite dev-server proxy is not available
* in Node.js, so we target the backend directly.
* Backend port read from BACKEND_PORT env var (set by the dev / E2E
* environment) so E2E tests follow the same port allocation as the Vite
* 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). */
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. */
export const TEST_USER = {

View File

@ -5,9 +5,9 @@ import { defineConfig, devices } from '@playwright/test'
*
* Architecture:
* - Backend (uvicorn direct, avoids agentkit serve interactive prompts) runs on
* port 8000 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).
* - Tests target the frontend at http://localhost:5173; API/WS calls are
* port 18001 to match the Vite dev-server proxy target in vite.config.ts.
* - Frontend (Vite dev server) runs on port 15173 (strictPort in vite.config.ts).
* - Tests target the frontend at http://localhost:15173; API/WS calls are
* transparently proxied to the backend.
*
* 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',
use: {
baseURL: 'http://localhost:5173',
baseURL: 'http://localhost:15173',
trace: 'on-first-retry',
screenshot: 'only-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 ' +
'.venv/bin/python -c "from agentkit.server.app import create_app; import uvicorn; ' +
'app = create_app(rate_limit=10000); ' +
'uvicorn.run(app, host=\'127.0.0.1\', port=8000)"',
url: 'http://127.0.0.1:8000/api/v1/health',
'uvicorn.run(app, host=\'127.0.0.1\', port=18001)"',
url: 'http://127.0.0.1:18001/api/v1/health',
cwd: PROJECT_ROOT,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
@ -80,7 +80,7 @@ export default defineConfig({
},
{
command: 'npm run dev',
url: 'http://localhost:5173',
url: 'http://localhost:15173',
cwd: '.',
reuseExistingServer: !process.env.CI,
timeout: 60_000,

View File

@ -22,20 +22,24 @@ mod auth;
/// - clear_refresh_token() -> Result<(), String>
/// 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``.
///
/// 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") {
Some(h) => h,
None => "127.0.0.1",
};
const REMOTE_PORT_STR: &str = match option_env!("AGENTKIT_REMOTE_PORT") {
Some(p) => p,
None => "8000",
None => "18001",
};
/// Parse the port at first use (avoids non-const fn call in constant).
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.

View File

@ -5,7 +5,7 @@
"identifier": "com.fischer.agentkit",
"build": {
"frontendDist": "../static",
"devUrl": "http://localhost:5173",
"devUrl": "http://localhost:15173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build:frontend"
},

View File

@ -106,8 +106,7 @@ html, body, #app {
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Sans Emoji';
'Noto Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--text-primary, #1a1a1a);
@ -143,7 +142,7 @@ body {
}
.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;
}

View File

@ -1,477 +1,583 @@
import type { ICalendarEvent, IInvitation } from './calendar'
import type { ICalendarEvent, IInvitation } from "./calendar";
/** Chat request payload */
export interface IChatRequest {
message: string
conversation_id?: string
sources?: string[]
skill_name?: string
message: string;
conversation_id?: string;
sources?: string[];
skill_name?: string;
}
/** Chat response from API */
export interface IChatResponse {
conversation_id: string
message: string
matched_skill?: string
routing_method?: string
confidence?: number
task_id?: string
status: 'completed' | 'pending'
conversation_id: string;
message: string;
matched_skill?: string;
routing_method?: string;
confidence?: number;
task_id?: string;
status: "completed" | "pending";
}
/** Tool call data within a message */
export interface IToolCallData {
id: string
name: string
status: 'pending' | 'running' | 'completed' | 'error'
params?: string
result?: string
error?: string
duration?: number
children?: IToolCallData[]
id: string;
name: string;
status: "pending" | "running" | "completed" | "error";
params?: string;
result?: string;
error?: string;
duration?: number;
children?: IToolCallData[];
}
/** Single chat message */
export interface IChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: string
matched_skill?: string
routing_method?: string
confidence?: number
task_id?: string
status?: 'completed' | 'pending' | 'error' | 'streaming'
tool_calls?: IToolCallData[]
thinking?: string
expert_id?: string
expert_name?: string
expert_color?: string
expert_avatar?: string
id: string;
role: "user" | "assistant";
content: string;
timestamp: string;
matched_skill?: string;
routing_method?: string;
confidence?: number;
task_id?: string;
status?: "completed" | "pending" | "error" | "streaming";
tool_calls?: IToolCallData[];
thinking?: string;
expert_id?: string;
expert_name?: string;
expert_color?: string;
expert_avatar?: string;
message_type?:
| 'chat'
| 'handoff'
| 'assist_request'
| 'plan_update'
| 'milestone'
| 'board_started'
| 'board_speech'
| 'board_summary'
| 'board_conclusion'
| 'debate_started'
| 'debate_argument'
| 'debate_summary'
| 'debate_resolved'
| 'collaboration_graph'
| 'review_result'
| 'risk_flagged'
| 'error'
board_round?: number
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
plan_phases?: ITeamPlanPhase[]
error_detail?: string
board_started?: IBoardStartedData
board_conclusion?: IBoardConcludedData
debate_topic?: string
debate_round?: number
debate_decision?: string
debate_rationale?: string
debate_participants?: string[]
debate_opening?: string
debate_moderator?: string
| "chat"
| "handoff"
| "assist_request"
| "plan_update"
| "milestone"
| "board_started"
| "board_speech"
| "board_summary"
| "board_conclusion"
| "debate_started"
| "debate_argument"
| "debate_summary"
| "debate_resolved"
| "collaboration_graph"
| "review_result"
| "risk_flagged"
| "error";
board_round?: number;
board_role?: "moderator" | "expert" | "user" | "summary";
plan_phases?: ITeamPlanPhase[];
error_detail?: string;
board_started?: IBoardStartedData;
board_conclusion?: IBoardConcludedData;
debate_topic?: string;
debate_round?: number;
debate_decision?: string;
debate_rationale?: string;
debate_participants?: string[];
debate_opening?: string;
debate_moderator?: string;
/** U5: PM collaboration — aggregated graph data for CollaborationGraphCard */
collaboration_graph?: ICollaborationGraphData
collaboration_graph?: ICollaborationGraphData;
/** U5: PM collaboration — review result for ReviewResultCard */
review_result?: IReviewResult
review_result?: IReviewResult;
/** 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) */
synthesis_id?: string
synthesis_id?: string;
}
/** Conversation with messages */
export interface IConversation {
id: string
title: string
messages: IChatMessage[]
created_at: string
updated_at: string
id: string;
title: string;
messages: IChatMessage[];
created_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 */
export interface ICapabilityInfo {
name: string
display_name: string
description: string
icon: string
enabled: boolean
skill_count: number
name: string;
display_name: string;
description: string;
icon: string;
enabled: boolean;
skill_count: number;
}
/** Capabilities response */
export interface ICapabilitiesResponse {
capabilities: ICapabilityInfo[]
capabilities: ICapabilityInfo[];
}
/** WebSocket client message types */
export type WsClientMessage = {
type: 'chat'
message: string
sources?: string[]
conversation_id?: string
model?: string
} | {
type: 'resume'
task_id: string
conversation_id?: string
} | {
type: 'cancel'
task_id?: string
} | {
type: 'ping'
}
export type WsClientMessage =
| {
type: "chat";
message: string;
sources?: string[];
conversation_id?: string;
model?: string;
}
| {
type: "resume";
task_id: string;
conversation_id?: string;
}
| {
type: "cancel";
task_id?: string;
}
| {
type: "ping";
};
/** WebSocket server message types — matches backend portal.py protocol */
export type WsServerMessage =
| { type: 'connected'; conversation_id: 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: '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 } }
| { type: "connected"; conversation_id: 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: "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.
| { 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: '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 } }
| { 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: "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 模式事件
| { type: 'board_started'; data: IBoardStartedData }
| { type: 'expert_speech'; data: IExpertSpeechData }
| { type: 'round_summary'; data: IRoundSummaryData }
| { type: 'user_intervention'; data: IUserInterventionData }
| { type: 'board_concluded'; data: IBoardConcludedData }
| { type: "board_started"; data: IBoardStartedData }
| { type: "expert_speech"; data: IExpertSpeechData }
| { type: "expert_speech_chunk"; data: IExpertSpeechData }
| { type: "round_summary"; data: IRoundSummaryData }
| { type: "user_intervention"; data: IUserInterventionData }
| { type: "board_concluded"; data: IBoardConcludedData }
// Debate (U5) 事件
| { type: 'debate_started'; data: IDebateStartedData }
| { type: 'expert_argument'; data: IDebateArgumentData }
| { type: 'debate_round_summary'; data: IDebateRoundSummaryData }
| { type: 'debate_resolved'; data: IDebateResolvedData }
| { type: 'team_intervention_ack'; data: { content: string } }
| { type: "debate_started"; data: IDebateStartedData }
| { type: "expert_argument"; data: IDebateArgumentData }
| { type: "debate_round_summary"; data: IDebateRoundSummaryData }
| { type: "debate_resolved"; data: IDebateResolvedData }
| { type: "team_intervention_ack"; data: { content: string } }
// PM Collaboration (U5) 事件
| { type: 'collaboration_contract_defined'; data: ICollaborationContractDefinedData }
| { type: 'collaboration_notice'; data: ICollaborationNotice }
| { type: 'review_result'; data: IReviewResult }
| { type: 'risk_flagged'; data: IRiskFlag }
| {
type: "collaboration_contract_defined";
data: ICollaborationContractDefinedData;
}
| { type: "collaboration_notice"; data: ICollaborationNotice }
| { type: "review_result"; data: IReviewResult }
| { type: "risk_flagged"; data: IRiskFlag }
// Calendar 事件 (KTD-10 — piggyback on chat WS)
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
| { type: 'calendar_reminder'; data: ICalendarReminderData }
| { type: 'calendar_invitation'; data: ICalendarInvitationData }
| { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData }
| { type: "calendar_event_created"; data: ICalendarEventCreatedData }
| { type: "calendar_reminder"; data: ICalendarReminderData }
| { type: "calendar_invitation"; data: ICalendarInvitationData }
| { type: "calendar_sync_conflict"; data: ICalendarSyncConflictData };
/** Expert info within a team */
export interface IExpertInfo {
id: string
name: string
persona: string
avatar: string
color: string
is_lead: boolean
bound_skills: string[]
status: 'active' | 'inactive'
id: string;
name: string;
persona: string;
avatar: string;
color: string;
is_lead: boolean;
bound_skills: string[];
status: "active" | "inactive";
}
/** A phase within a team plan */
export interface ITeamPlanPhase {
id: string
name: string
assigned_expert: string
task_description?: string
depends_on: string[]
status: 'pending' | 'in_progress' | 'completed' | 'failed'
result?: string
parallel_type?: 'serial' | 'subtask_parallel' | 'competitive_parallel'
milestone?: string
id: string;
name: string;
assigned_expert: string;
task_description?: string;
depends_on: string[];
status: "pending" | "in_progress" | "completed" | "failed";
result?: string;
parallel_type?: "serial" | "subtask_parallel" | "competitive_parallel";
milestone?: string;
/** 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 */
rework_count?: number
rework_count?: number;
/** U5: PM collaboration — Lead review feedback (modification requirements) */
review_feedback?: string | null
review_feedback?: string | null;
}
/** Expert team state */
export interface IExpertTeamState {
team_id: string
status: 'forming' | 'planning' | 'executing' | 'synthesizing' | 'completed' | 'dissolved'
experts: IExpertInfo[]
plan_phases: ITeamPlanPhase[]
lead_expert: string
team_id: string;
status:
| "forming"
| "planning"
| "executing"
| "synthesizing"
| "completed"
| "dissolved";
experts: IExpertInfo[];
plan_phases: ITeamPlanPhase[];
lead_expert: string;
/** U2: 团队级任务目标摘要(可选,后端 team_formed 事件未发送时回退到首阶段描述) */
task_description?: string
task_description?: string;
}
// ── Board Meeting 模式类型 ────────────────────────────────────────────
/** Board meeting expert info (lighter than IExpertInfo) */
export interface IBoardExpert {
name: string
avatar: string
color: string
is_moderator: boolean
persona: string
name: string;
avatar: string;
color: string;
is_moderator: boolean;
persona: string;
}
/** board_started event payload */
export interface IBoardStartedData {
team_id: string
topic: string
experts: IBoardExpert[]
max_rounds: number
team_id: string;
topic: string;
experts: IBoardExpert[];
max_rounds: number;
}
/** expert_speech event payload */
export interface IExpertSpeechData {
expert_name: string
expert_avatar: string
expert_color: string
content: string
round: number
role: 'moderator' | 'expert'
expert_name: string;
expert_avatar: string;
expert_color: string;
content: string;
round: number;
role: "moderator" | "expert";
}
/** round_summary event payload */
export interface IRoundSummaryData {
moderator_name: string
content: string
round: number
continue: boolean
moderator_name: string;
content: string;
round: number;
continue: boolean;
}
/** user_intervention event payload */
export interface IUserInterventionData {
content: string
round: number
content: string;
round: number;
}
/** board_concluded event payload */
export interface IBoardConcludedData {
summary: string
decision_advice: string
total_rounds: number
consensus_points: string[]
dissent_points: string[]
error?: string
summary: string;
decision_advice: string;
total_rounds: number;
consensus_points: string[];
dissent_points: string[];
error?: string;
}
// ── Debate (U5) 模式类型 ──────────────────────────────────────────────
/** debate_started event payload */
export interface IDebateStartedData {
phase_id: string
phase_name: string
topic: string
participants: string[]
max_rounds: number
opening: string
phase_id: string;
phase_name: string;
topic: string;
participants: string[];
max_rounds: number;
opening: string;
}
/** expert_argument event payload */
export interface IDebateArgumentData {
phase_id: string
expert_id: string
expert_name: string
expert_color: string
content: string
round: number
topic: string
phase_id: string;
expert_id: string;
expert_name: string;
expert_color: string;
content: string;
round: number;
topic: string;
}
/** debate_round_summary event payload */
export interface IDebateRoundSummaryData {
phase_id: string
moderator_name: string
content: string
round: number
continue: boolean
phase_id: string;
moderator_name: string;
content: string;
round: number;
continue: boolean;
}
/** debate_resolved event payload */
export interface IDebateResolvedData {
phase_id: string
phase_name: string
decision: 'adopt' | 'compromise' | 'shelve' | 'inconclusive'
conclusion: string
rationale: string
phase_id: string;
phase_name: string;
decision: "adopt" | "compromise" | "shelve" | "inconclusive";
conclusion: string;
rationale: string;
}
// ── PM Collaboration (U5) 模式类型 ──────────────────────────────────
/** 协作契约 — 匹配后端 CollaborationContract.to_dict() */
export interface ICollaborationContract {
from_expert: string
to_expert: string
content_description: string
status: 'pending' | 'delivered' | 'received'
from_expert: string;
to_expert: string;
content_description: string;
status: "pending" | "delivered" | "received";
}
/** collaboration_contract_defined event payload
* ( plan_update plan_phases[].collaboration_contracts
* ) */
export interface ICollaborationContractDefinedData {
phase_id: string
phase_name: string
contracts: ICollaborationContract[]
phase_id: string;
phase_name: string;
contracts: ICollaborationContract[];
}
/** collaboration_notice event payload — 专家完成后按契约通知相关专家 */
export interface ICollaborationNotice {
from_expert: string
to_expert: string
content_description: string
phase_id: string
phase_name: string
output_key: string
expert_color: string
from_expert: string;
to_expert: string;
content_description: string;
phase_id: string;
phase_name: string;
output_key: string;
expert_color: string;
}
/** review_result event payload — Lead 验收阶段输出 */
export interface IReviewResult {
phase_id: string
phase_name: string
passed: boolean
feedback: string
expert: string
rework_count?: number
final_status?: 'rework' | 'failed'
phase_id: string;
phase_name: string;
passed: boolean;
feedback: string;
expert: string;
rework_count?: number;
final_status?: "rework" | "failed";
}
/** risk_flagged event payload — 专家风险标记 */
export interface IRiskFlag {
expert: string
expert_name: string
risk_description: string
phase_id: string
phase_name: string
expert: string;
expert_name: string;
risk_description: string;
phase_id: string;
phase_name: string;
}
/** 协作关系图聚合数据 — 存储在 collaboration_graph 消息中,随事件实时更新 */
export interface ICollaborationGraphData {
contracts: Array<ICollaborationContract & { phase_id: string; phase_name: string }>
notices: ICollaborationNotice[]
reviews: IReviewResult[]
risks: IRiskFlag[]
contracts: Array<
ICollaborationContract & { phase_id: string; phase_name: string }
>;
notices: ICollaborationNotice[];
reviews: IReviewResult[];
risks: IRiskFlag[];
}
/** 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 */
export interface IBoardMessage {
id: string
expert_name: string
expert_avatar: string
expert_color: string
content: string
round: number
role: 'moderator' | 'expert' | 'user' | 'summary'
timestamp: number
id: string;
expert_name: string;
expert_avatar: string;
expert_color: string;
content: string;
round: number;
role: "moderator" | "expert" | "user" | "summary";
timestamp: number;
}
// ── Calendar WS 事件 payload 类型 ───────────────────────────────────
/** calendar_event_created payload */
export interface ICalendarEventCreatedData {
event: ICalendarEvent
event: ICalendarEvent;
}
/** calendar_reminder payload */
export interface ICalendarReminderData {
event_id: string
title: string
start_time: string
offset_minutes: number
channels: string[]
event_id: string;
title: string;
start_time: string;
offset_minutes: number;
channels: string[];
}
/** calendar_invitation payload (G6) */
export interface ICalendarInvitationData {
invitation: IInvitation
event_title: string
inviter_name: string
invitation: IInvitation;
event_title: string;
inviter_name: string;
}
/** calendar_sync_conflict payload (G4) */
export interface ICalendarSyncConflictData {
event_id: string
event_title: string
provider: string
local_modified: string
remote_modified: string
resolution: string
event_id: string;
event_title: string;
provider: string;
local_modified: string;
remote_modified: string;
resolution: string;
}
/** Expert template (matches backend GET /api/v1/experts response item) */
export interface IExpertTemplate {
name: string
description: string
is_builtin: boolean
avatar: string
color: string
persona: string
thinking_style: string
speaking_style: string
decision_framework: string
name: string;
description: string;
is_builtin: boolean;
avatar: string;
color: string;
persona: string;
thinking_style: string;
speaking_style: string;
decision_framework: string;
}
/** Experts list response (matches backend GET /api/v1/experts) */
export interface IExpertsResponse {
experts: IExpertTemplate[]
total: number
experts: IExpertTemplate[];
total: number;
}
/** File upload response (matches backend POST /api/v1/chat/upload) */
export interface IUploadResponse {
filename: string
stored_name: string
content_type: string
size: number
download_url: string
filename: string;
stored_name: string;
content_type: string;
size: number;
download_url: string;
}
/** API error */
export interface IApiError {
status: number
message: string
detail?: string
status: number;
message: string;
detail?: string;
}
/** 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()) */
export interface ITaskRecord {
task_id: string
agent_name: string
skill_name: string | null
input_data: Record<string, unknown>
status: TaskStatus
output_data: Record<string, unknown> | null
error_message: string | null
created_at: string
started_at: string | null
completed_at: string | null
progress: number
progress_message: string
metadata: Record<string, unknown>
task_id: string;
agent_name: string;
skill_name: string | null;
input_data: Record<string, unknown>;
status: TaskStatus;
output_data: Record<string, unknown> | null;
error_message: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
progress: number;
progress_message: string;
metadata: Record<string, unknown>;
}

View File

@ -46,7 +46,7 @@ function formatSize(bytes: number): string {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--color-primary, #1677ff);
color: var(--color-primary, #1a1a1a);
text-decoration: none;
font-size: 12px;
}

View File

@ -301,11 +301,11 @@ defineExpose({
}
.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) {
color: var(--color-primary, #1677ff);
color: var(--color-primary, #1a1a1a);
}
.bitable-grid-scope__add-col {
@ -321,6 +321,6 @@ defineExpose({
}
.bitable-grid-scope__add-col:hover {
color: var(--color-primary, #1677ff);
color: var(--color-primary, #1a1a1a);
}
</style>

View File

@ -17,7 +17,7 @@
<span class="field-manage-panel__item-name">{{ f.name }}</span>
<div class="field-manage-panel__item-meta">
<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' : '用户' }}
</a-tag>
</div>

View File

@ -1,7 +1,9 @@
<template>
<a-dropdown :trigger="['contextmenu']">
<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__name" :title="file.name">{{ file.name }}</div>
<div class="file-card__desc" :title="file.description">
@ -28,6 +30,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { IBitableFile } from '@/api/bitable'
import { resolveBitableIcon } from './bitableIcons'
defineProps<{
file: IBitableFile
@ -60,13 +63,13 @@ function formatDate(iso: string): string {
}
.file-card:hover {
border-color: var(--color-primary, #1677ff);
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.12);
border-color: var(--color-primary, #1a1a1a);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.file-card__icon {
font-size: 32px;
font-size: 28px;
line-height: 1;
flex-shrink: 0;
width: 40px;
@ -74,6 +77,7 @@ function formatDate(iso: string): string {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary, #1a1a1a);
}
.file-card__body {

View File

@ -15,9 +15,28 @@
<a-form-item label="图标" name="icon">
<a-select
v-model:value="formState.icon"
:options="iconOptions"
:options="iconSelectOptions"
: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 label="文件名" name="name">
<a-input
@ -39,9 +58,15 @@
</template>
<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 { useBitableStore } from '@/stores/bitable'
import {
BITABLE_ICON_OPTIONS,
DEFAULT_BITABLE_ICON,
resolveBitableIcon,
type BitableIconKey,
} from './bitableIcons'
const props = defineProps<{
open: boolean
@ -56,20 +81,18 @@ const store = useBitableStore()
const formRef = ref<FormInstance | null>(null)
const loading = ref(false)
const iconOptions = [
{ label: '📋 表格', value: '📋' },
{ label: '📊 仪表盘', value: '📊' },
{ label: '📝 笔记', value: '📝' },
{ label: '🗂️ 项目', value: '🗂️' },
{ label: '📅 日程', value: '📅' },
{ label: '💼 工作', value: '💼' },
{ label: '🎯 目标', value: '🎯' },
{ label: '📚 知识库', value: '📚' },
]
/** a-select options: value = icon key, label rendered through #option template. */
const iconSelectOptions = computed(() =>
BITABLE_ICON_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
function labelForKey(key: string): string {
return BITABLE_ICON_OPTIONS.find((o) => o.value === key)?.label ?? key
}
const formState = reactive({
name: '',
icon: '📋',
icon: DEFAULT_BITABLE_ICON as BitableIconKey,
description: '',
})
@ -85,7 +108,7 @@ watch(
(val) => {
if (val) {
formState.name = ''
formState.icon = '📋'
formState.icon = DEFAULT_BITABLE_ICON
formState.description = ''
}
},
@ -115,3 +138,18 @@ function handleCancel(): void {
emit('cancel')
}
</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>

View File

@ -99,8 +99,8 @@ const emit = defineEmits<{
}
.table-view-list__item.is-active {
background: var(--color-primary-bg, #e6f4ff);
color: var(--color-primary, #1677ff);
background: var(--color-primary-bg, #fbfbfa);
color: var(--color-primary, #1a1a1a);
font-weight: 500;
}

View File

@ -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 (table, chart, note
* 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
}

View File

@ -18,6 +18,11 @@
>
<MessageOutlined class="chat-sidebar__item-icon" />
<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>
<a-popconfirm
title="确定删除此对话?"
@ -195,6 +200,20 @@ function formatRelativeTime(dateStr: string): string {
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 {
font-size: var(--font-xs);
color: var(--text-placeholder);
@ -202,7 +221,9 @@ function formatRelativeTime(dateStr: string): string {
}
.chat-sidebar__item-delete {
display: none;
/* opacity + pointer-events display:none/flex
* 避免 hover flex 布局重排导致列表项抖动 */
display: flex;
align-items: center;
justify-content: center;
width: 22px;
@ -214,11 +235,14 @@ function formatRelativeTime(dateStr: string): string {
cursor: pointer;
flex-shrink: 0;
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 {
display: flex;
opacity: 1;
pointer-events: auto;
}
.chat-sidebar__item-delete:hover {

View File

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

View File

@ -1,4 +1,5 @@
import { computed, type Component } from 'vue'
import { AuditOutlined } from '@ant-design/icons-vue'
import type { IChatMessage } from '@/api/types'
import UserBubble from '@/components/chat/messages/UserBubble.vue'
import AssistantText from '@/components/chat/messages/AssistantText.vue'
@ -36,7 +37,7 @@ export type MessageViewType =
export interface MessageShellMeta {
name: string
meta?: string
avatar?: string
avatar?: string | Component
color?: string
}
@ -54,14 +55,19 @@ export function resolveMessageType(message: IChatMessage): MessageViewType {
switch (message.message_type) {
case 'plan_update':
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':
return 'board_banner'
case 'board_speech':
return 'board_speech'
case 'board_summary':
return 'board_summary'
case 'board_conclusion':
return 'board_conclusion'
return 'assistant'
case 'debate_started':
return 'debate_started'
case 'debate_argument':
@ -204,7 +210,7 @@ export function useMessageRenderer(message: IChatMessage) {
type,
shell: {
name: '辩论',
avatar: '⚖',
avatar: AuditOutlined,
color: '#722ed1',
meta: message.debate_topic || '',
},
@ -263,7 +269,7 @@ export function useMessageRenderer(message: IChatMessage) {
type,
shell: {
name: '辩论裁决',
avatar: '⚖',
avatar: AuditOutlined,
color: '#fa8c16',
meta: decisionLabels[decision] || decision,
},

View File

@ -3,7 +3,7 @@
<div class="board-banner-card__bar" />
<div class="board-banner-card__body">
<div class="board-banner-card__title">
<span class="board-banner-card__icon">🏛</span>
<BankOutlined class="board-banner-card__icon" />
<span>私董会 {{ topic }}</span>
</div>
<div class="board-banner-card__experts">
@ -32,6 +32,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BankOutlined } from '@ant-design/icons-vue'
import type { IBoardExpert } from '@/api/types'
interface Props {

View File

@ -1,6 +1,6 @@
<template>
<div class="debate-banner">
<div class="debate-banner__icon"></div>
<div class="debate-banner__icon"><AuditOutlined /></div>
<div class="debate-banner__body">
<div class="debate-banner__title">辩论开始</div>
<div class="debate-banner__topic">{{ topic }}</div>
@ -17,6 +17,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AuditOutlined } from '@ant-design/icons-vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage } from '@/api/types'

View File

@ -1,7 +1,7 @@
<template>
<div class="debate-conclusion" :class="`debate-conclusion--${decision}`">
<div class="debate-conclusion__header">
<span class="debate-conclusion__icon">{{ decisionIcon }}</span>
<span class="debate-conclusion__icon"><component :is="decisionIcon" /></span>
<span class="debate-conclusion__decision">{{ decisionLabel }}</span>
</div>
<div class="debate-conclusion__body">
@ -15,7 +15,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, type Component } from 'vue'
import { CheckOutlined, SwapOutlined, MinusOutlined, QuestionOutlined } from '@ant-design/icons-vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage } from '@/api/types'
@ -31,15 +32,15 @@ const decisionLabels: Record<string, string> = {
shelve: '搁置',
inconclusive: '未决',
}
const decisionIcons: Record<string, string> = {
adopt: '✓',
compromise: '⇄',
shelve: '○',
inconclusive: '?',
const decisionIcons: Record<string, Component> = {
adopt: CheckOutlined,
compromise: SwapOutlined,
shelve: MinusOutlined,
inconclusive: QuestionOutlined,
}
const decisionLabel = computed(() => decisionLabels[props.decision] || props.decision)
const decisionIcon = computed(() => decisionIcons[props.decision] || '?')
const decisionIcon = computed(() => decisionIcons[props.decision] || QuestionOutlined)
const textMessage = computed<IChatMessage>(() => ({
id: 'debate-conclusion',

View File

@ -7,7 +7,8 @@
class="message-shell__custom-avatar"
:style="{ backgroundColor: color || '#1a1a1a' }"
>
{{ avatar || (name ? name.charAt(0).toUpperCase() : '?') }}
<component :is="avatar" v-if="typeof avatar !== 'string' && avatar" />
<template v-else>{{ avatar || (name ? name.charAt(0).toUpperCase() : '?') }}</template>
</div>
<a-avatar v-else-if="role === 'assistant'" :size="28" class="message-shell__avatar--assistant">
<template #icon><RobotOutlined /></template>
@ -43,12 +44,13 @@
<script setup lang="ts">
import { Avatar as AAvatar } from 'ant-design-vue'
import { RobotOutlined, UserOutlined } from '@ant-design/icons-vue'
import type { Component } from 'vue'
interface Props {
role: 'user' | 'assistant'
name?: string
meta?: string
avatar?: string
avatar?: string | Component
color?: string
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为彩色 badge 替代普通 name 文本 */
expertName?: string

View File

@ -7,7 +7,7 @@
class="team-plan-card__lead-avatar"
:style="leadColor ? { background: leadColor } : undefined"
>
{{ leadAvatar }}
{{ leadInitials }}
</span>
<span class="team-plan-card__label">
{{ leadName }}<span class="team-plan-card__label-suffix">· 专家团计划</span>
@ -24,7 +24,7 @@
:class="`team-plan-card__phase--${phase.status}`"
>
<span class="team-plan-card__phase-dot" aria-hidden="true">
{{ statusIcon(phase.status) }}
<component :is="statusIconComponent(phase.status)" />
</span>
<div class="team-plan-card__phase-info">
<span class="team-plan-card__phase-name" :title="phase.task_description || phase.name">
@ -51,19 +51,50 @@
</template>
<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'
interface Props {
phases: ITeamPlanPhase[]
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
leadColor?: string
}
const props = withDefaults(defineProps<Props>(), {
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
* (business-person glyph) 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)
@ -91,14 +122,23 @@ function statusLabel(status: string): string {
return labels[status] || status
}
function statusIcon(status: string): string {
const icons: Record<string, string> = {
pending: '○',
in_progress: '●',
completed: '✓',
failed: '✕',
/**
* Phase status dot icon. 2026-07-01: replaced the ad-hoc Unicode
* glyphs (hollow dot, solid dot, check, cross) with the matching Ant Design Vue components
* so the dot matches the line-icon family used in the rest of the
* app. The dotted ``MinusOutlined`` for pending is the closest
* outlined equivalent of the old hollow dot; 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>
@ -139,7 +179,10 @@ function statusIcon(status: string): string {
width: 22px;
height: 22px;
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);
}

View File

@ -4,8 +4,8 @@
class="user-bubble"
:class="{ 'user-bubble--focusable': msgId }"
:tabindex="msgId ? 0 : undefined"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
@mouseenter="onBubbleMouseEnter"
@mouseleave="onBubbleMouseLeave"
@focus="focused = true"
@blur="focused = false"
@pointerdown="onPointerDown"
@ -16,6 +16,28 @@
:filename="fileAttachment.filename"
: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"><component :is="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>
<div
@ -23,6 +45,8 @@
class="user-bubble__actions"
@click.stop
@pointerdown.stop
@mouseenter="onActionsMouseEnter"
@mouseleave="onActionsMouseLeave"
>
<button
class="ub-action"
@ -68,8 +92,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue'
import { ref, computed, onMounted, onUnmounted, type Component } from 'vue'
import { CopyOutlined, DeleteOutlined, EditOutlined, BankOutlined, TeamOutlined } from '@ant-design/icons-vue'
import FileAttachment from './FileAttachment.vue'
import { useChatStore, nextMessageIsAssistant } from '@/stores/chatStore'
@ -84,6 +108,46 @@ const chatStore = useChatStore()
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: Component
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' ? BankOutlined : TeamOutlined,
label: kind === 'board' ? '私董会' : '专家团',
topic: topic || rest,
experts,
expertCount: experts.length,
}
})
const fileAttachment = computed(() => {
const match = props.content.match(FILE_MARKDOWN_RE)
if (!match) return null
@ -153,6 +217,45 @@ function onRefill(): void {
// the "tap elsewhere" case without per-show add/remove churn.
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 {
if (event.pointerType === 'touch') {
touched.value = !touched.value
@ -176,6 +279,7 @@ onUnmounted(() => {
clearTimeout(copiedTimer)
copiedTimer = null
}
clearHideTimer()
})
</script>
@ -197,7 +301,7 @@ onUnmounted(() => {
}
.user-bubble--focusable:focus-visible {
outline: 2px solid var(--accent-primary, #1677ff);
outline: 2px solid var(--accent-primary, #1a1a1a);
outline-offset: 2px;
}
@ -205,6 +309,95 @@ onUnmounted(() => {
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 {
position: absolute;
top: 50%;

View File

@ -63,7 +63,7 @@ defineProps<{
.splash-progress-inner {
width: 40%;
height: 100%;
background: var(--color-primary, #6366f1);
background: var(--color-primary, #1a1a1a);
border-radius: 2px;
animation: splash-loading 1.5s ease-in-out infinite;
}
@ -95,8 +95,8 @@ defineProps<{
font-size: 13px;
font-weight: 500;
color: var(--text-inverse, #ffffff);
background: var(--color-primary, #6366f1);
border: 1px solid var(--color-primary, #6366f1);
background: var(--color-primary, #1a1a1a);
border: 1px solid var(--color-primary, #1a1a1a);
border-radius: 6px;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.1s ease;

View File

@ -152,6 +152,9 @@ const tabs: Tab[] = [
const activeTab = ref('monitor')
const loading = ref(false)
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
// tabs, Home/End jump to first/last. Required for keyboard-only users.
@ -219,8 +222,12 @@ const serviceList = computed(() => {
let refreshTimer: number | null = null
async function refreshData(): Promise<void> {
loading.value = true
error.value = ''
// loading spinner DOM
// 10 reload spinner
if (initialLoading.value) {
loading.value = true
error.value = ''
}
try {
const [h, m] = await Promise.all([
getHealth().catch((e: unknown) => {
@ -234,10 +241,17 @@ async function refreshData(): Promise<void> {
])
health.value = h
metrics.value = m
// error
error.value = ''
} 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 {
loading.value = false
initialLoading.value = false
}
}
@ -318,10 +332,10 @@ onUnmounted(() => {
}
.system-monitor__tab--active {
color: var(--accent-team);
color: var(--color-primary);
background: var(--color-primary-light);
/* 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 {
@ -369,7 +383,7 @@ onUnmounted(() => {
display: inline-block;
width: 3px;
height: 12px;
background: var(--accent-team);
background: var(--color-primary);
border-radius: 2px;
}
@ -436,7 +450,8 @@ onUnmounted(() => {
}
.system-monitor__metric-delta.down {
color: var(--accent-team);
/* 队列中有任务 = 警告语义(不是 team 语义),用 warning 而非 --accent-team */
color: var(--color-warning);
}
/* 分区容器:左右两列独立分块 */

View File

@ -125,7 +125,7 @@ onMounted(() => {
}
.knowledge-tab__source-icon {
color: var(--accent-team);
color: var(--color-primary);
font-size: var(--font-sm);
}

View File

@ -174,7 +174,7 @@ onMounted(() => {
.skills-tab__item-icon {
flex-shrink: 0;
color: var(--accent-team);
color: var(--color-primary);
font-size: var(--font-sm);
margin-top: 2px;
}

View File

@ -78,6 +78,9 @@ import { getResources, type ISystemResources } from '@/api/system'
const loading = ref(false)
const error = ref('')
// ponytail: loading/error DOM
// 10 reload spinner
const initialLoading = ref(true)
const resources = ref<ISystemResources>({
cpu: { count: 1, load_average: 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> {
loading.value = true
error.value = ''
// loading spinner DOM
// 10 reload spinner
if (initialLoading.value) {
loading.value = true
error.value = ''
}
try {
resources.value = await getResources()
error.value = ''
} 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 {
loading.value = false
initialLoading.value = false
}
}
@ -214,7 +227,7 @@ onUnmounted(() => {
.system-tab__bar-fill {
height: 100%;
background: var(--accent-team);
background: var(--color-primary);
border-radius: 3px;
transition: width 0.3s ease;
}

View File

@ -92,7 +92,10 @@ const scenes: Scene[] = [
.chat-preview {
display: flex;
flex-direction: column;
height: 100%;
/* ChatView flex:1 + min-height:0 height:100%
* 以适配 flex-column 父容器 */
flex: 1;
min-height: 0;
background: var(--bg-primary);
font-family: var(--font-sans, system-ui);
overflow: hidden;

View File

@ -17,7 +17,7 @@
<span
:class="['command-history__exit-code', record.exit_code === 0 ? 'command-history__exit-code--success' : 'command-history__exit-code--error']"
>
{{ record.exit_code === 0 ? '✓' : '✗' }}
{{ record.exit_code === 0 ? 'OK' : 'FAIL' }}
</span>
<span class="command-history__command">{{ record.command }}</span>
</div>

View File

@ -385,7 +385,9 @@ export const useAuthStore = defineStore('auth', () => {
if (!healthy) {
startupState.value = 'error'
error.value =
'后端服务健康检查失败,请确认 agentkit serve 已在 8000 端口运行后点击重试。'
'后端服务健康检查失败,请确认 agentkit serve 已在 ' +
(import.meta.env.BACKEND_PORT || '18001') +
' 端口运行后点击重试。'
return startupState.value
}
} catch (err: unknown) {

View File

@ -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(
name: string,
icon = '📋',
icon: string = 'table',
description = '',
): Promise<IBitableFile | null> {
try {

View File

@ -6,9 +6,12 @@ import { useDocumentsStore } from "@/stores/documents";
import { useCalendarStore } from "@/stores/calendar";
import { useChatSocket } from "@/stores/chatSocket";
import { useChatStream } from "@/stores/chatStream";
import type { BoardState } from "@/stores/chatStream";
import { pickExpertIdentity } from "@/components/chat/helpers/expertIdentity";
import type {
IChatMessage,
IConversation,
IBoardStartedData,
WsClientMessage,
} from "@/api/types";
@ -31,6 +34,75 @@ export function nextMessageIsAssistant(
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", () => {
// --- State (chatStore-owned) ---
const conversations = ref<IConversation[]>([]);
@ -133,6 +205,10 @@ export const useChatStore = defineStore("chat", () => {
messages: Array.isArray(conv.messages) ? conv.messages : [],
created_at: conv.created_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) {
console.error("Failed to load conversations:", error);
@ -224,6 +300,11 @@ export const useChatStore = defineStore("chat", () => {
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 */
@ -379,11 +460,16 @@ export const useChatStore = defineStore("chat", () => {
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);
if (conv && conv.title === "新对话") {
conv.title =
message.length > 20 ? `${message.substring(0, 20)}...` : message;
const cleaned = 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;
}
}

View File

@ -18,6 +18,7 @@ import type {
IDebateResolvedData,
WsServerMessage,
} from "@/api/types";
import { pickExpertIdentity, resolveExpertIdentity } from "@/components/chat/helpers/expertIdentity";
// ── Types (moved from chat.ts) ─────────────────────────────────────────
@ -1104,16 +1105,27 @@ export function dispatchWsEvent(
case "board_started": {
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 = {
topic: boardData.topic,
experts: boardData.experts.map((e) => ({
name: e.name,
avatar: e.avatar,
color: e.color,
is_moderator: e.is_moderator,
persona: e.persona,
})),
experts: boardData.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: boardData.max_rounds,
current_round: 0,
status: "discussing",
@ -1134,7 +1146,7 @@ export function dispatchWsEvent(
const startMsg: IChatMessage = {
id: generateId(),
role: "assistant",
content: `🏛️ 私董会开始:${boardData.topic}`,
content: `私董会开始:${boardData.topic}`,
timestamp: new Date().toISOString(),
status: "completed",
message_type: "board_started",
@ -1146,6 +1158,60 @@ export function dispatchWsEvent(
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": {
const speechData = event.data;
// Update current round in board state
@ -1157,25 +1223,63 @@ export function dispatchWsEvent(
}
const conversationId = state.resolveIncomingConvId();
if (!conversationId) break;
const speechMsg: IChatMessage = {
id: generateId(),
role: "assistant",
content: speechData.content || "",
timestamp: new Date().toISOString(),
status: "completed",
expert_name: speechData.expert_name,
expert_color: speechData.expert_color,
expert_avatar: speechData.expert_avatar,
message_type: "board_speech",
board_round: speechData.round,
board_role: speechData.role,
};
state.appendMessage(conversationId, speechMsg);
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 = {
id: generateId(),
role: "assistant",
content: speechData.content || "",
timestamp: new Date().toISOString(),
status: "completed",
expert_name: speechData.expert_name,
expert_avatar: identity.avatar,
expert_color: identity.color,
message_type: "board_speech",
board_round: speechData.round,
board_role: speechData.role,
};
state.appendMessage(conversationId, speechMsg);
}
appendStep(
state.streamingStepsByConv,
{
type: "board_event",
label: `${speechData.expert_avatar || ""} ${speechData.expert_name}`,
label: `${identity.avatar} ${speechData.expert_name || "专家"}`,
detail: `${speechData.round}${speechData.role === "moderator" ? " · 主持" : ""}`,
status: "success",
},
@ -1188,6 +1292,19 @@ export function dispatchWsEvent(
const summaryData = event.data;
const conversationId = state.resolveIncomingConvId();
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 = {
id: generateId(),
role: "assistant",
@ -1195,6 +1312,8 @@ export function dispatchWsEvent(
timestamp: new Date().toISOString(),
status: "completed",
expert_name: summaryData.moderator_name,
expert_avatar: identity.avatar,
expert_color: identity.color,
message_type: "board_summary",
board_round: summaryData.round,
board_role: "summary",
@ -1262,10 +1381,15 @@ export function dispatchWsEvent(
};
state.appendMessage(conversationId, conclusionMsg);
}
// Clear board state after a short delay to allow UI to update
setTimeout(() => {
state.boardState.value = null;
}, 1000);
// Clear the "执行中" loading indicator and step trail immediately
// 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;
break;
}

View File

@ -64,7 +64,9 @@ export const useSettingsStore = defineStore('settings', () => {
cors_origins: ['*'],
logging_level: 'INFO',
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,
log_format: 'text',
})

View File

@ -248,7 +248,7 @@ export const useTerminalStore = defineStore('terminal', () => {
command: data.command as string,
reason: (data.reason as string) || '需要用户确认',
}
appendOutput(`\x1b[33m 需要确认: ${data.command}\x1b[0m`)
appendOutput(`\x1b[33m[WARN] 需要确认: ${data.command}\x1b[0m`)
appendOutput(`\x1b[33m 原因: ${data.reason}\x1b[0m`)
break
// ── Server terminal: admin approval ────────────────────────
@ -259,20 +259,20 @@ export const useTerminalStore = defineStore('terminal', () => {
reason: (data.reason as string) || '需要管理员审批',
expires_in: (data.expires_in as number) || 300,
}
appendOutput(`\x1b[33m 等待管理员审批: ${data.command}\x1b[0m`)
appendOutput(`\x1b[33m[PENDING] 等待管理员审批: ${data.command}\x1b[0m`)
appendOutput(`\x1b[33m 原因: ${data.reason}\x1b[0m`)
break
case 'approval_approved':
appendOutput(`\x1b[32m 审批已通过,正在执行...\x1b[0m`)
appendOutput(`\x1b[32m[OK] 审批已通过,正在执行...\x1b[0m`)
pendingApproval.value = null
break
case 'approval_rejected':
appendOutput(`\x1b[31m 审批被拒绝: ${data.reason || ''}\x1b[0m`)
appendOutput(`\x1b[31m[REJECTED] 审批被拒绝: ${data.reason || ''}\x1b[0m`)
pendingApproval.value = null
isExecuting.value = false
break
case 'approval_expired':
appendOutput(`\x1b[31m 审批超时未响应\x1b[0m`)
appendOutput(`\x1b[31m[TIMEOUT] 审批超时未响应\x1b[0m`)
pendingApproval.value = null
isExecuting.value = false
break

View File

@ -91,8 +91,8 @@ export const useThemeStore = defineStore('theme', () => {
return {
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorPrimary: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
colorInfo: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
colorSuccess: readToken('--color-success', isDark ? '#4ade80' : '#22c55e'),
colorWarning: readToken('--color-warning', isDark ? '#fbbf24' : '#f59e0b'),
colorError: readToken('--color-error', isDark ? '#f87171' : '#ef4444'),
@ -132,19 +132,36 @@ export const useThemeStore = defineStore('theme', () => {
},
components: {
Menu: {
itemSelectedBg: readToken('--color-primary-light', isDark ? '#1e1b4b' : '#eef2ff'),
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemHoverBg: isDark ? '#1e1b4b' : '#f5f3ff',
itemHoverColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemSelectedBg: readToken('--color-primary-light', isDark ? '#2f2f2f' : '#f3f4f6'),
itemSelectedColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
itemHoverBg: isDark ? '#2f2f2f' : '#ededec',
itemHoverColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
itemColor: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
} as Record<string, unknown>,
Tabs: {
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemHoverColor: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
itemSelectedColor: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
itemHoverColor: readToken('--color-primary-hover', isDark ? '#ededec' : '#2f2f2f'),
} as Record<string, unknown>,
Select: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorPrimaryHover: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
colorPrimary: readToken('--color-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
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>,
Button: { borderRadius: 8, controlHeight: 32 } as Record<string, unknown>,
Card: { borderRadiusLG: 10 } as Record<string, unknown>,

View File

@ -84,6 +84,11 @@ export const themeConfig: ThemeConfig = {
Select: {
colorPrimary: readToken('--accent-team', '#3b82f6'),
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>,
Button: {
borderRadius: 8,

View File

@ -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);
/* ── 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;
--leading-message: 1.65;

View File

@ -4,7 +4,10 @@
<div class="bitable-file-detail-view__topbar">
<div class="bitable-file-detail-view__topbar-left">
<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">
{{ store.currentFile?.name ?? '加载中…' }}
</span>
@ -131,6 +134,7 @@ import TableCreateModal from '@/components/bitable/TableCreateModal.vue'
import FieldManagePanel from '@/components/bitable/FieldManagePanel.vue'
import ViewSwitcher from '@/components/bitable/ViewSwitcher.vue'
import ViewConfigPanel from '@/components/bitable/ViewConfigPanel.vue'
import { resolveBitableIcon } from '@/components/bitable/bitableIcons'
const router = useRouter()
const route = useRoute()
@ -301,7 +305,10 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
}
.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 {

View File

@ -52,7 +52,26 @@
>
<a-form layout="vertical">
<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 label="文件名">
<a-input v-model:value="renameForm.name" :maxlength="100" />
@ -66,7 +85,7 @@
</template>
<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 { Modal as AModal } from 'ant-design-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 FileCard from '@/components/bitable/FileCard.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 store = useBitableStore()
@ -82,23 +107,26 @@ const createOpen = ref(false)
const renameOpen = ref(false)
const renaming = ref(false)
/**
* ``icon`` accepts any string here because legacy DB rows may still
* contain the old emoji values (clipboard-emoji 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({
id: '',
icon: '📋',
icon: DEFAULT_BITABLE_ICON as BitableIconKey | string,
name: '',
description: '',
})
const iconOptions = [
{ label: '📋 表格', value: '📋' },
{ label: '📊 仪表盘', value: '📊' },
{ label: '📝 笔记', value: '📝' },
{ label: '🗂️ 项目', value: '🗂️' },
{ label: '📅 日程', value: '📅' },
{ label: '💼 工作', value: '💼' },
{ label: '🎯 目标', value: '🎯' },
{ label: '📚 知识库', value: '📚' },
]
const iconSelectOptions = computed(() =>
BITABLE_ICON_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
function labelForKey(key: string): string {
return BITABLE_ICON_OPTIONS.find((o) => o.value === key)?.label ?? key
}
onMounted(() => {
store.loadFiles()
@ -212,4 +240,15 @@ function handleDelete(file: IBitableFile): void {
justify-content: center;
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>

View File

@ -176,7 +176,15 @@ function handleSend(message: string, model?: string): void {
<style scoped>
.chat-view {
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;
}
@ -213,7 +221,8 @@ function handleSend(message: string, model?: string): void {
.chat-view__welcome {
display: flex;
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;
padding: 120px 0 48px;
flex: 1;

View File

@ -91,7 +91,7 @@ function makeExpert(overrides: Partial<IExpertInfo> = {}): IExpertInfo {
id: 'e1',
name: '专家A',
persona: '资深架构师',
avatar: '🤖',
avatar: 'A',
color: '#3b82f6',
is_lead: false,
bound_skills: ['react'],
@ -120,7 +120,7 @@ function makeBoardState(overrides: Partial<BoardState> = {}): BoardState {
experts: [
{
name: '主持人',
avatar: '🎯',
avatar: 'T',
color: '#a855f7',
is_moderator: true,
persona: '引导讨论',
@ -177,8 +177,8 @@ describe('StickyModeHeader (U2)', () => {
teamState.value = makeTeamState({
task_description: '实现用户登录功能',
experts: [
makeExpert({ id: 'e1', name: '专家A', avatar: '🤖', is_lead: true }),
makeExpert({ id: 'e2', name: '专家B', avatar: '🐱' }),
makeExpert({ id: 'e1', name: '专家A', avatar: 'A', is_lead: true }),
makeExpert({ id: 'e2', name: '专家B', avatar: 'C' }),
],
})
const { container, unmount } = mountStickyHeader()
@ -196,7 +196,7 @@ describe('StickyModeHeader (U2)', () => {
const avatars = container.querySelectorAll('.sticky-mode-header__avatar')
expect(avatars.length).toBe(2)
expect(avatars[0].textContent).toBe('🤖')
expect(avatars[0].textContent).toBe('A')
unmount()
})
@ -229,14 +229,14 @@ describe('StickyModeHeader (U2)', () => {
experts: [
{
name: '主持人',
avatar: '🎯',
avatar: 'T',
color: '#a855f7',
is_moderator: true,
persona: '引导讨论',
},
{
name: '专家1',
avatar: '💡',
avatar: 'I',
color: '#3b82f6',
is_moderator: false,
persona: '市场分析',
@ -285,7 +285,7 @@ describe('StickyModeHeader (U2)', () => {
makeExpert({
id: 'e1',
name: '架构师',
avatar: '🤖',
avatar: 'A',
persona: '专注系统设计',
bound_skills: ['react', 'vue'],
is_lead: true,
@ -435,7 +435,7 @@ describe('StickyModeHeader (U2)', () => {
experts: [
{
name: '主持人',
avatar: '🎯',
avatar: 'T',
color: '#a855f7',
is_moderator: true,
persona: '引导者',

View File

@ -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: 'F',
color: '#7a5af8',
is_moderator: true,
persona: '主持人',
},
{
name: 'Bob',
avatar: 'P',
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)
})
})

View File

@ -728,7 +728,7 @@ describe('dispatchWsEvent', () => {
experts: [
{
name: 'Mod',
avatar: '🦊',
avatar: 'F',
color: '#f00',
is_moderator: true,
persona: 'moderator',

View File

@ -4,7 +4,15 @@ import { resolve } from 'path'
import Components from 'unplugin-vue-components/vite'
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 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({
plugins: [
@ -29,20 +37,25 @@ export default defineConfig({
// Tauri dev server configuration
clearScreen: false,
server: {
port: 5173,
port: vitePort,
strictPort: true,
host: host || false,
hmr: host ? { protocol: 'ws', host, port: 5174 } : undefined,
// 2026-07-01: bind to all interfaces so Tauri's Rust-side reachability
// 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: {
ignored: ['**/src-tauri/**'],
},
proxy: {
'/api': {
target: 'http://localhost:8000',
target: `http://localhost:${backendPort}`,
changeOrigin: true,
ws: true,
},
},
},
envPrefix: ['VITE_', 'TAURI_'],
envPrefix: ['VITE_', 'TAURI_', 'BACKEND_'],
})

View File

@ -498,6 +498,12 @@ async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
# 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:
new_pair = create_token_pair(
user_id=refresh_payload["sub"],
@ -505,12 +511,12 @@ async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
role=refresh_payload["role"],
secret=secret,
session_id=refresh_payload.get("sid"),
remember_me=False,
remember_me=inherited_remember_me,
)
await svc.rotate(
old_refresh_token=payload.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
logger.info("Refresh rejected: %s", exc)

View File

@ -121,7 +121,11 @@ async def _check_file_ownership(
class CreateFileRequest(BaseModel):
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)

View File

@ -165,6 +165,7 @@ _VALID_TEAM_EVENT_TYPES = frozenset(
# Board Meeting 模式事件
"board_started",
"expert_speech",
"expert_speech_chunk",
"round_summary",
"user_intervention",
"board_concluded",
@ -284,14 +285,103 @@ async def _execute_board_meeting(
# Strip internal fields, keep only event data
event_data = {k: v for k, v in message.items() if k != "type"}
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)
# 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(
session_id=session_id,
role=MessageRole.USER,
content=content,
content=f"@board {routing_result.topic}",
)
try:

View File

@ -1,6 +1,7 @@
import asyncio
import hmac
import json
import re
import logging
import os
import uuid
@ -34,6 +35,13 @@ from agentkit.server.routes.evolution_dashboard import (
from agentkit.core.fallback import EMPTY_LLM_RESPONSE
from agentkit.chat.sqlite_conversation_store import SqliteConversationStore
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__)
@ -179,6 +187,34 @@ _WS_HEARTBEAT_TIMEOUT = float(os.environ.get("AGENTKIT_WS_TIMEOUT", "120"))
_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
# ---------------------------------------------------------------------------
@ -236,9 +272,7 @@ class PortalConnectionManager:
try:
await ws.send_json(message)
except (ConnectionError, RuntimeError, asyncio.TimeoutError) as e:
logger.debug(
"Portal WS send failed for user %s (marking stale): %s", user_id, e
)
logger.debug("Portal WS send failed for user %s (marking stale): %s", user_id, e)
stale.append(ws)
for ws in stale:
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
may have an empty `messages` list after a restart). This prevents the
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)
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.
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)
is_board = await _conversation_has_board_started(c.id)
result.append(
{
"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(),
"updated_at": c.updated_at.isoformat(),
"message_count": len(c.messages),
"is_board": is_board,
}
)
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:
"""Derive a human-readable title from the first user message in the conversation object."""
for msg in conv.messages:
@ -768,10 +828,31 @@ def _derive_conversation_title(conv: Conversation) -> str:
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:
"""Derive title from a string content (used when conv.messages is empty)."""
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 "对话"
@ -797,21 +878,62 @@ async def get_conversation(
return {
"id": conv.id,
"title": _derive_conversation_title_from_content(first_user_content),
"messages": [
{
"id": f"{conv.id}-{i}",
"role": m.role,
"content": m.content,
"timestamp": m.timestamp.isoformat(),
"metadata": m.metadata,
}
for i, m in enumerate(history)
],
"messages": [_hydrate_persisted_message(conv.id, i, m) for i, m in enumerate(history)],
"created_at": conv.created_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}")
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)):
"""Delete a conversation and all its messages.
@ -866,7 +988,14 @@ async def _execute_react_background(
await _task_store_update_status(
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)
async for event in react_engine.execute_stream(
@ -913,7 +1042,14 @@ async def _execute_react_background(
progress=1.0,
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)
# 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))
try:
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")
if task_store is not None:
@ -1005,7 +1148,14 @@ async def _execute_react_background(
error_message=str(e),
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)
# Emit task.failed so subscribers know the task failed
@ -1140,7 +1290,14 @@ async def portal_websocket(websocket: WebSocket):
try:
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)
record = 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)
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
# (EQ is a side-channel: emit failures never break the WebSocket flow)
task_id = str(uuid.uuid4())
@ -1353,7 +1522,13 @@ async def portal_websocket(websocket: WebSocket):
},
)
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}")
# Unified preprocessing via RequestPreprocessor (minimal: @skill prefix + greeting regex + REACT)
@ -1434,7 +1609,14 @@ async def portal_websocket(websocket: WebSocket):
TaskStatus.PENDING,
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)
# Execute based on routing result's execution_mode
@ -1475,7 +1657,14 @@ async def portal_websocket(websocket: WebSocket):
progress=1.0,
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)
# Emit turn.final_answer and task.completed to EQ
@ -1546,7 +1735,14 @@ async def portal_websocket(websocket: WebSocket):
chat_messages.insert(
-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
response = await llm_gateway.chat(
messages=chat_messages,

View File

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

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-a-0N3I41.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Mld5F0pG.css">
<script type="module" crossorigin src="/assets/index-CHN0ThS0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DC_0XUg6.css">
</head>
<body>
<div id="app"></div>

View File

@ -33,7 +33,7 @@ class BitableTool(Tool):
"""Agent tool for bitable operations via REST API.
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
go unauthenticated (will fail if the server requires auth).
"""

Some files were not shown because too many files have changed in this diff Show More