Przeglądaj źródła

feat: Bot 工作组一期(机器人、Bot API、Bot Playground),Key 分为 Agent Key / Bot Key

- 服务端 teams 模块:机器人与 Bot Key、工作组组成规则与生命周期、唤醒规则、同一机器人顺序执行与消息合并、
  审批卡片、共享目录与文件、工作组事件流;/bot/v1 与 /v1 的 Key 和浏览器令牌互不通用(ADR-0023、0024)
- 每个"机器人 × 工作组"一个会话,工作目录为工作组共享目录;每轮开始前同步文件索引,只把本轮产出挂到回复上
- SDK:BotClient(与 AgentClient 共用 HttpClient)、<TeamChat> 与 useTeamSession
- 后台:机器人页面、Bot Playground(时间线、成员、过程、文件);API Keys 页面分 Agent Keys / Bot Keys
- API 协议页面与 PDF 同时包含 Agent API 与 Bot API,测试核对两套文档与实际路由一致
- 原 API Key 在界面与文档中改称 Agent Key(错误码、接口与环境变量不变)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 tydzień temu
rodzic
commit
7d5c2b806f
55 zmienionych plików z 5601 dodań i 339 usunięć
  1. 4 3
      apps/admin/src/components/ApiTryPanel.vue
  2. 144 0
      apps/admin/src/components/BotKeysPanel.vue
  3. 6 4
      apps/admin/src/components/FilePreview.vue
  4. 2 0
      apps/admin/src/components/Workbench.vue
  5. 14 2
      apps/admin/src/lib/api.ts
  6. 2 0
      apps/admin/src/router.ts
  7. 56 22
      apps/admin/src/views/ApiDocsView.vue
  8. 32 8
      apps/admin/src/views/ApiKeysView.vue
  9. 345 0
      apps/admin/src/views/BotPlaygroundView.vue
  10. 150 0
      apps/admin/src/views/BotsView.vue
  11. 1 1
      apps/admin/src/views/PluginsView.vue
  12. 1 1
      apps/admin/src/views/SessionsView.vue
  13. 1 1
      apps/admin/src/views/SettingsView.vue
  14. 65 0
      apps/server/drizzle/0006_bot_teams.sql
  15. 1936 0
      apps/server/drizzle/meta/0006_snapshot.json
  16. 7 0
      apps/server/drizzle/meta/_journal.json
  17. 323 0
      apps/server/src/api-reference/bot-reference.ts
  18. 25 12
      apps/server/src/api-reference/reference.ts
  19. 54 33
      apps/server/src/api-reference/render.ts
  20. 14 1
      apps/server/src/app.ts
  21. 63 0
      apps/server/src/db/schema.ts
  22. 31 7
      apps/server/src/http/auth.ts
  23. 70 13
      apps/server/src/http/routes/admin.ts
  24. 3 3
      apps/server/src/http/routes/v1.ts
  25. 4 2
      apps/server/src/http/server.ts
  26. 12 5
      apps/server/src/http/sse.ts
  27. 1 1
      apps/server/src/lib/ids.ts
  28. 13 8
      apps/server/src/services/browser-tokens.ts
  29. 7 7
      apps/server/src/services/catalog.ts
  30. 20 11
      apps/server/src/services/runs.ts
  31. 32 3
      apps/server/src/services/sessions.ts
  32. 4 1
      apps/server/src/services/usage.ts
  33. 178 0
      apps/server/src/teams/bots.ts
  34. 68 0
      apps/server/src/teams/events.ts
  35. 52 0
      apps/server/src/teams/prompt.ts
  36. 135 0
      apps/server/src/teams/routes.ts
  37. 552 0
      apps/server/src/teams/service.ts
  38. 25 3
      apps/server/test/api-reference.test.ts
  39. 290 0
      apps/server/test/teams.test.ts
  40. 5 4
      docs/README.md
  41. 1 1
      docs/adr/0023-bot-teams-collaboration-model.md
  42. 1 1
      docs/adr/0024-bot-api-separate-keys.md
  43. 2 2
      docs/design/11-roadmap.md
  44. 34 14
      docs/design/13-bot-teams.md
  45. 7 7
      docs/integration.md
  46. 293 0
      packages/chat-ui/src/components/TeamChat.vue
  47. 8 5
      packages/chat-ui/src/context.ts
  48. 3 1
      packages/chat-ui/src/index.ts
  49. 115 0
      packages/chat-ui/src/team-store.ts
  50. 65 0
      packages/client/src/bot.ts
  51. 155 0
      packages/client/src/http.ts
  52. 7 150
      packages/client/src/index.ts
  53. 13 2
      packages/shared/src/api.ts
  54. 1 0
      packages/shared/src/index.ts
  55. 149 0
      packages/shared/src/teams.ts

+ 4 - 3
apps/admin/src/components/ApiTryPanel.vue

@@ -16,13 +16,14 @@ const emit = defineEmits<{ ids: [ids: Record<string, string>] }>()
 const ep = props.endpoint
 const isStream = ep.path.endsWith('/stream')
 const isUpload = ep.method === 'POST' && ep.path.endsWith('/files')
-const isDownload = ep.method === 'GET' && ep.path === '/v1/files/:id'
+const isDownload = ep.method === 'GET' && (ep.path === '/v1/files/:id' || ep.path === '/bot/v1/files/:id')
 const startsRun = ep.path.endsWith('/messages')
 
 /** 路径参数:按前一段推断 ID 类型(sessions/:id → ses_) */
 const PREFIX: Record<string, { prefix: string; label: string }> = {
   sessions: { prefix: 'ses_', label: '会话 ID' }, runs: { prefix: 'run_', label: 'Run ID' },
   interactions: { prefix: 'itx_', label: '交互 ID' }, files: { prefix: 'file_', label: '文件 ID' },
+  teams: { prefix: 'team_', label: '工作组 ID' }, bots: { prefix: 'bot_', label: '机器人 ID' },
 }
 const pathParams = [...ep.path.matchAll(/([a-z-]+)\/:(\w+)/g)].map(m => ({ name: m[2]!, ...(PREFIX[m[1]!] ?? { prefix: '', label: m[2]! }) }))
 const params = reactive<Record<string, string>>(Object.fromEntries(pathParams.map(p => [p.name, props.recentIds[p.prefix] ?? ''])))
@@ -48,8 +49,8 @@ const missing = computed(() => pathParams.some(p => !params[p.name]!.trim()) ||
 function collectIds(v: unknown, out: Record<string, string> = {}, depth = 0): Record<string, string> {
   if (depth > 4 || v === null || typeof v !== 'object') return out
   for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
-    if (typeof x === 'string' && /^(id|sessionId|runId|interactionId|fileId|activeRunId)$/.test(k)) {
-      const m = /^(ses|run|itx|file)_[0-9a-z]+$/.exec(x)
+    if (typeof x === 'string' && /^(id|sessionId|runId|interactionId|fileId|activeRunId|botId|teamId)$/.test(k)) {
+      const m = /^(ses|run|itx|file|team|bot)_[0-9a-z]+$/.exec(x)
       if (m && !out[`${m[1]}_`]) out[`${m[1]}_`] = x
     } else if (typeof x === 'object') collectIds(x, out, depth + 1)
   }

+ 144 - 0
apps/admin/src/components/BotKeysPanel.vue

@@ -0,0 +1,144 @@
+<script setup lang="ts">
+// Bot Key(ADR-0024):只能调用 /bot/v1,按机器人授权;与 Agent Key 互不通用
+import { computed, onMounted, reactive, ref } from 'vue'
+import type { BotDto, BotKeyDto } from '@agent-server/shared'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const keys = ref<BotKeyDto[]>([])
+const bots = ref<BotDto[]>([])
+const botName = (id: string) => bots.value.find(b => b.id === id)?.name ?? '(已删除)'
+const KIND = { lead: '组长', worker: '组员' } as const
+
+async function load() {
+  try {
+    ;[keys.value, bots.value] = await Promise.all([admin.botKeys(), admin.bots()])
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+const created = ref<{ key: string; name: string } | null>(null)
+const editor = reactive<{ open: boolean; id: string | null; name: string; allowed: string[]; saving: boolean }>({ open: false, id: null, name: '', allowed: [], saving: false })
+const valid = computed(() => editor.allowed.length > 0 && (editor.id !== null || editor.name.trim().length > 0))
+
+function openKey(k?: BotKeyDto) {
+  if (!k && !bots.value.length) return toast('请先在"机器人"页面新建机器人', 'error')
+  Object.assign(editor, { open: true, id: k?.id ?? null, name: k?.name ?? '', allowed: k ? [...k.allowedBotIds] : bots.value.map(b => b.id) })
+}
+
+function toggleAllowed(id: string) {
+  editor.allowed = editor.allowed.includes(id) ? editor.allowed.filter(x => x !== id) : [...editor.allowed, id]
+}
+
+async function save() {
+  if (!valid.value) return
+  editor.saving = true
+  try {
+    if (editor.id) {
+      await admin.setBotKeyBots(editor.id, editor.allowed)
+      toast('已保存', 'success')
+    } else {
+      const r = await admin.createBotKey(editor.name.trim(), editor.allowed)
+      created.value = { key: r.key, name: r.botKey.name }
+    }
+    editor.open = false
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    editor.saving = false
+  }
+}
+
+async function toggle(k: BotKeyDto) {
+  await admin.setBotKeyEnabled(k.id, !k.enabled).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
+async function remove(k: BotKeyDto) {
+  if (!confirm(`删除 Bot Key「${k.name}」?使用它的平台将无法再调用,它创建的工作组也无法再访问。`)) return
+  await admin.deleteBotKey(k.id).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
+async function copy(text: string) {
+  try {
+    await navigator.clipboard.writeText(text)
+    toast('已复制', 'success')
+  } catch {
+    toast('复制失败,请手动选择复制', 'error')
+  }
+}
+
+onMounted(load)
+defineExpose({ openKey: () => openKey() })
+</script>
+
+<template>
+  <div class="flex flex-col gap-5">
+    <div v-if="created" class="card p-4 flex flex-col gap-2 border-accent">
+      <div class="font-600">「{{ created.name }}」的 Bot Key(只显示这一次,请立即保存)</div>
+      <div class="flex gap-2">
+        <code class="flex-1 font-mono text-[12.5px] p-2 rounded-md bg-code break-all select-all">{{ created.key }}</code>
+        <button type="button" class="btn-secondary" @click="copy(created.key)"><span class="codicon codicon-copy" />复制</button>
+      </div>
+      <button type="button" class="btn-ghost self-start" @click="created = null">我已保存</button>
+    </div>
+
+    <table class="w-full border-collapse text-[12.5px]">
+      <thead>
+        <tr class="text-left text-fg-muted">
+          <th class="py-2 font-500">名称</th><th class="font-500">可用的机器人</th><th class="font-500">状态</th><th class="font-500">最近使用</th><th />
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="k in keys" :key="k.id" class="border-t border-t-solid border-border align-top">
+          <td class="py-2"><div>{{ k.name }}</div><div class="font-mono hint">{{ k.prefix }}…</div></td>
+          <td class="py-2"><div class="flex flex-wrap gap-1"><span v-for="id in k.allowedBotIds" :key="id" class="badge bg-code">{{ botName(id) }}</span></div></td>
+          <td class="py-2"><span class="badge" :class="k.enabled ? 'bg-success text-white' : 'bg-code text-fg-muted'">{{ k.enabled ? '启用' : '停用' }}</span></td>
+          <td class="py-2">{{ formatTime(k.lastUsedAt) }}</td>
+          <td class="py-1 text-right whitespace-nowrap">
+            <button type="button" class="btn-ghost" @click="openKey(k)">配置机器人</button>
+            <button type="button" class="btn-ghost" @click="toggle(k)">{{ k.enabled ? '停用' : '启用' }}</button>
+            <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
+          </td>
+        </tr>
+        <tr v-if="!keys.length"><td colspan="5" class="py-4 hint">还没有 Bot Key</td></tr>
+      </tbody>
+    </table>
+
+    <section class="card p-4 flex flex-col gap-2 text-[12.5px]">
+      <div class="font-600 text-[13px] flex items-center gap-1.5"><span class="codicon codicon-plug" />业务平台接入 Bot API</div>
+      <ol class="m-0 pl-5 flex flex-col gap-1">
+        <li>Bot Key(<code class="font-mono">bk_</code>)只能调用 <code class="font-mono">/bot/v1</code>(机器人与工作组),按机器人授权;它与 Agent Key 互不通用。</li>
+        <li>在平台<b>后端</b>保存 Bot Key,调用 <code class="font-mono">POST /bot/v1/browser-tokens</code> 为已登录用户换取浏览器令牌。<b>不要把 Bot Key 放进前端代码。</b></li>
+        <li>前端使用 SDK 中的 <code class="font-mono">BotClient</code> 与 <code class="font-mono">&lt;TeamChat&gt;</code>;接口详见"API 协议 › Bot API"。</li>
+      </ol>
+    </section>
+
+    <div v-if="editor.open" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor.open = false">
+      <form class="w-[520px] card bg-input-alt p-5 flex flex-col gap-4 shadow-xl" @submit.prevent="save">
+        <div class="font-600 text-[14px]">{{ editor.id ? `配置「${editor.name}」可用的机器人` : '签发 Bot Key' }}</div>
+        <div v-if="!editor.id">
+          <label class="label" for="bkey-name">平台名称</label>
+          <input id="bkey-name" v-model="editor.name" class="field" placeholder="例如:运维平台" autofocus>
+        </div>
+        <div>
+          <div class="label">可用的机器人(至少选一个)</div>
+          <div class="flex flex-col gap-1 max-h-72 overflow-y-auto">
+            <label v-for="b in bots" :key="b.id" class="flex items-center gap-2 px-2 h-8 rounded-md hover:bg-hover cursor-pointer">
+              <input type="checkbox" :checked="editor.allowed.includes(b.id)" @change="toggleAllowed(b.id)">
+              <span>{{ b.avatar }} {{ b.name }}</span><span class="hint">{{ KIND[b.kind] }}</span>
+            </label>
+          </div>
+          <div class="hint mt-1">平台调用 <code class="font-mono">GET /bot/v1/bots</code> 只会看到勾选的机器人,建工作组时也只能使用它们。</div>
+        </div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="editor.open = false">取消</button>
+          <button type="submit" class="btn-primary" :disabled="!valid || editor.saving">{{ editor.id ? '保存' : '签发' }}</button>
+        </div>
+      </form>
+    </div>
+  </div>
+</template>

+ 6 - 4
apps/admin/src/components/FilePreview.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, ref, watch } from 'vue'
-import { basename, downloadFile, formatSize } from '@agent-server/chat-ui'
-import { client } from '../lib/api'
+import { basename, downloadFile, formatSize, type FileClient } from '@agent-server/chat-ui'
+import { client as agentClient } from '../lib/api'
 
-const props = defineProps<{ file: { fileId: string; path: string; mime: string; size: number } }>()
+// 默认读取会话文件;Bot Playground 传入 botClient 读取工作组文件
+const props = defineProps<{ file: { fileId: string; path: string; mime: string; size: number }; client?: FileClient }>()
+const client = computed(() => props.client ?? agentClient)
 
 const TEXT_EXT = ['txt', 'md', 'csv', 'tsv', 'json', 'py', 'js', 'ts', 'sql', 'sh', 'log', 'yaml', 'yml', 'xml', 'html']
 const ext = computed(() => props.file.path.split('.').pop()?.toLowerCase() ?? '')
@@ -27,7 +29,7 @@ async function load() {
   if (kind.value === 'other') return
   loading.value = true
   try {
-    const blob = await client.fileBlob(props.file.fileId)
+    const blob = await client.value.fileBlob(props.file.fileId)
     if (kind.value === 'text') text.value = (await blob.text()).slice(0, 200_000)
     else url.value = URL.createObjectURL(kind.value === 'pdf' ? new Blob([blob], { type: 'application/pdf' }) : blob)
   } catch (err) {

+ 2 - 0
apps/admin/src/components/Workbench.vue

@@ -11,9 +11,11 @@ const router = useRouter()
 
 const nav = [
   { to: '/playground', icon: 'codicon-comment-discussion', label: 'Playground' },
+  { to: '/bot-playground', icon: 'codicon-organization', label: 'Bot Playground' },
   { to: '/sessions', icon: 'codicon-history', label: '会话记录' },
   { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
+  { to: '/bots', icon: 'codicon-robot', label: '机器人' },
   { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
   { to: '/files', icon: 'codicon-files', label: '文件管理' },
   { to: '/plugins', icon: 'codicon-extensions', label: '技能与插件' },

+ 14 - 2
apps/admin/src/lib/api.ts

@@ -1,11 +1,13 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
-import { AgentClient } from '@agent-server/client'
+import { AgentClient, BotClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
+/** Bot Playground:以后台身份调用 /bot/v1(ADR-0024) */
+export const botClient = new BotClient({ baseUrl: '' })
 const req = <T>(method: string, path: string, body?: unknown) => client.request<T>(method, `/admin/v1${path}`, body)
 const fsq = (root: string, path: string) => `root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`
 
@@ -141,6 +143,16 @@ export const admin = {
   createKnowledge: (k: KnowledgeInput) => req<KnowledgeRootDto>('POST', '/knowledge', k),
   updateKnowledge: (id: string, k: KnowledgeInput) => req<KnowledgeRootDto>('PUT', `/knowledge/${id}`, k),
   deleteKnowledge: (id: string) => req<void>('DELETE', `/knowledge/${id}`),
+  bots: () => req<BotDto[]>('GET', '/bots'),
+  createBot: (input: BotInput) => req<BotDto>('POST', '/bots', input),
+  updateBot: (id: string, input: BotInput) => req<BotDto>('PUT', `/bots/${id}`, input),
+  deleteBot: (id: string) => req<void>('DELETE', `/bots/${id}`),
+  botKeys: () => req<BotKeyDto[]>('GET', '/bot-keys'),
+  createBotKey: (name: string, allowedBotIds: string[]) => req<{ key: string; botKey: BotKeyDto }>('POST', '/bot-keys', { name, allowedBotIds }),
+  setBotKeyEnabled: (id: string, enabled: boolean) => req<{ ok: true }>('PATCH', `/bot-keys/${id}`, { enabled }),
+  setBotKeyBots: (id: string, allowedBotIds: string[]) => req<{ ok: true }>('PUT', `/bot-keys/${id}/bots`, { allowedBotIds }),
+  deleteBotKey: (id: string) => req<void>('DELETE', `/bot-keys/${id}`),
+  botKeyDebugToken: (id: string) => req<BrowserTokenDto>('POST', `/bot-keys/${id}/debug-token`),
   apiKeys: () => req<ApiKeyDto[]>('GET', '/api-keys'),
   createApiKey: (name: string, access: ApiKeyProfiles) => req<{ key: string; apiKey: ApiKeyDto }>('POST', '/api-keys', { name, ...access }),
   setApiKeyProfiles: (id: string, access: ApiKeyProfiles) => req<{ ok: true }>('PUT', `/api-keys/${id}/profiles`, access),

+ 2 - 0
apps/admin/src/router.ts

@@ -8,6 +8,8 @@ export const router = createRouter({
     { path: '/login', component: () => import('./views/LoginView.vue'), meta: { public: true } },
     { path: '/playground/:sessionId?', name: 'playground', component: () => import('./views/PlaygroundView.vue') },
     { path: '/sessions/:sessionId?', name: 'sessions', component: () => import('./views/SessionsView.vue') },
+    { path: '/bot-playground/:teamId?', name: 'bot-playground', component: () => import('./views/BotPlaygroundView.vue') },
+    { path: '/bots', component: () => import('./views/BotsView.vue') },
     { path: '/overview', component: () => import('./views/OverviewView.vue') },
     { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },
     { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },

+ 56 - 22
apps/admin/src/views/ApiDocsView.vue

@@ -1,8 +1,9 @@
 <script setup lang="ts">
-// 对外 API 协议:按服务端的协议定义渲染(与实际接口一致,由测试保证),右侧目录导航,每个接口可以直接调试。
-// 下载 PDF 与"新窗口打开"使用服务端渲染的同一份内容
-import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
-import type { ApiEndpoint, ApiKeyDto, ApiReferenceDto } from '@agent-server/shared'
+// 对外 API 协议:Agent API(/v1)与 Bot API(/bot/v1)两套,按服务端的协议定义渲染(与实际接口一致,由测试保证),
+// 右侧目录导航,每个接口可以直接调试。下载 PDF 与"新窗口打开"使用服务端渲染的同一份内容(包含两套协议)
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import type { ApiEndpoint, ApiKeyDto, ApiReferenceDto, ApiSpec, BotKeyDto } from '@agent-server/shared'
 import ApiTryPanel from '../components/ApiTryPanel.vue'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
@@ -10,6 +11,16 @@ import { toast } from '../lib/ui'
 
 const ref_ = ref<ApiReferenceDto | null>(null)
 const keys = ref<ApiKeyDto[]>([])
+const botKeys = ref<BotKeyDto[]>([])
+
+// 当前查看的协议(地址栏 ?api=bot 可以直接打开 Bot API)
+const route = useRoute()
+const router = useRouter()
+const specId = computed<ApiSpec['id']>({
+  get: () => (route.query.api === 'bot' ? 'bot' : 'agent'),
+  set: v => void router.replace({ query: v === 'bot' ? { api: 'bot' } : {} }),
+})
+const spec = computed(() => ref_.value?.specs.find(x => x.id === specId.value) ?? null)
 const pdfAvailable = ref(false)
 const downloading = ref(false)
 const base = location.origin
@@ -24,7 +35,7 @@ const METHOD_CLASS: Record<string, string> = { GET: 'bg-[#1a7f37]', POST: 'bg-[#
 
 // ---- 目录 ----
 const toc = computed(() => {
-  const r = ref_.value
+  const r = spec.value
   if (!r) return []
   return [
     { id: 'overview', title: '概述', children: [] as { id: string; title: string; method: string }[] },
@@ -64,19 +75,29 @@ const activeGroup = computed(() => toc.value.find(t => t.id === active.value ||
 
 // ---- 调试 ----
 const trying = reactive<Record<string, boolean>>({})
-/** 调试身份:'' 为后台管理员;否则为 API Key id(服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端) */
+/** 调试身份:'' 为后台管理员;否则为当前协议对应的 Key id(Agent Key 或 Bot Key,服务端签发 10 分钟的浏览器令牌,Key 不离开服务端) */
 const identity = ref('')
-const identityLabel = computed(() => (identity.value ? `平台「${keys.value.find(k => k.id === identity.value)?.name ?? ''}」` : '后台管理员'))
+const identityKeys = computed(() => (specId.value === 'bot' ? botKeys.value : keys.value).filter(k => k.enabled))
+const identityLabel = computed(() => (identity.value ? `平台「${identityKeys.value.find(k => k.id === identity.value)?.name ?? ''}」` : '后台管理员'))
 const tokens = new Map<string, { token: string; exp: number }>()
 async function auth(): Promise<string | null> {
   const id = identity.value
   if (!id) return null
-  const t = tokens.get(id)
+  const cacheKey = `${specId.value}:${id}`
+  const t = tokens.get(cacheKey)
   if (t && t.exp - Date.now() > 60_000) return t.token
-  const r = await admin.apiKeyDebugToken(id)
-  tokens.set(id, { token: r.token, exp: Date.parse(r.expiresAt) })
+  const r = await (specId.value === 'bot' ? admin.botKeyDebugToken(id) : admin.apiKeyDebugToken(id))
+  tokens.set(cacheKey, { token: r.token, exp: Date.parse(r.expiresAt) })
   return r.token
 }
+// 切换协议:调试身份回到后台管理员,回到顶部并重新跟踪标题
+watch(specId, async () => {
+  identity.value = ''
+  active.value = 'overview'
+  scroller.value?.scrollTo({ top: 0 })
+  await nextTick()
+  observeHeadings()
+})
 const recentIds = reactive<Record<string, string>>({})
 
 async function downloadPdf() {
@@ -105,8 +126,8 @@ const htmlUrl = computed(() => `/admin/v1/api-reference.html?base=${encodeURICom
 
 onMounted(async () => {
   try {
-    ;[ref_.value, keys.value, pdfAvailable.value] = await Promise.all([
-      admin.apiReference(), admin.apiKeys(), admin.apiReferenceMeta().then(m => m.pdfAvailable).catch(() => false),
+    ;[ref_.value, keys.value, botKeys.value, pdfAvailable.value] = await Promise.all([
+      admin.apiReference(), admin.apiKeys(), admin.botKeys().catch(() => []), admin.apiReferenceMeta().then(m => m.pdfAvailable).catch(() => false),
     ])
   } catch (err) {
     toast(errorMessage(err), 'error')
@@ -126,15 +147,24 @@ onBeforeUnmount(() => observer?.disconnect())
       </button>
     </PageHeader>
 
-    <div v-if="ref_" class="flex-1 min-h-0 flex">
+    <div v-if="ref_" class="flex-none flex items-stretch h-[35px] px-6 border-b border-b-solid border-border text-[12px]">
+      <button
+        v-for="x in ref_.specs" :key="x.id" type="button"
+        class="px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-1 flex items-center gap-2"
+        :class="specId === x.id ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="specId = x.id"
+      >
+        <span class="font-600">{{ x.title }}</span><span class="hint" v-html="marked(x.summary)" />
+      </button>
+    </div>
+    <div v-if="ref_ && spec" class="flex-1 min-h-0 flex">
       <div ref="scroller" class="flex-1 min-w-0 overflow-y-auto" @scroll.passive="onScroll">
         <article class="doc max-w-220 mx-auto px-8 py-6">
           <h2 id="overview" data-toc>概述</h2>
-          <ul><li v-for="(p, i) in ref_.overview.points" :key="i" v-html="marked(p)" /></ul>
+          <ul><li v-for="(p, i) in spec.overview.points" :key="i" v-html="marked(p)" /></ul>
           <h4>典型流程</h4>
-          <ol><li v-for="(p, i) in ref_.overview.flow" :key="i" v-html="marked(p)" /></ol>
+          <ol><li v-for="(p, i) in spec.overview.flow" :key="i" v-html="marked(p)" /></ol>
 
-          <template v-for="g in ref_.groups" :key="g.id">
+          <template v-for="g in spec.groups" :key="g.id">
             <h2 :id="g.id" data-toc>{{ g.title }}</h2>
             <p v-if="g.desc" class="muted" v-html="marked(g.desc)" />
             <section v-for="e in g.endpoints" :id="anchor(e)" :key="anchor(e)" class="endpoint" data-toc>
@@ -179,12 +209,12 @@ onBeforeUnmount(() => observer?.disconnect())
           </template>
 
           <h2 id="event-types" data-toc>事件类型</h2>
-          <p class="muted">事件信封见"数据结构 › AgentEvent"。"落库"为否的事件只在实时事件流中推送,历史事件中只保留对应的 completed 事件。</p>
+          <p class="muted">事件信封见"数据结构 › {{ spec.eventEnvelope }}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
           <table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead>
-            <tbody><tr v-for="ev in ref_.events" :key="ev.type"><td><code>{{ ev.type }}</code></td><td><code class="muted">{{ ev.payload }}</code></td><td v-html="marked(ev.desc)" /><td>{{ ev.persisted ? '是' : '否' }}</td></tr></tbody></table>
+            <tbody><tr v-for="ev in spec.events" :key="ev.type"><td><code>{{ ev.type }}</code></td><td><code class="muted">{{ ev.payload }}</code></td><td v-html="marked(ev.desc)" /><td>{{ ev.persisted ? '是' : '否' }}</td></tr></tbody></table>
 
           <h2 id="types" data-toc>数据结构</h2>
-          <template v-for="t in ref_.types" :key="t.name">
+          <template v-for="t in spec.types" :key="t.name">
             <h3><code class="path">{{ t.name }}</code></h3>
             <p v-if="t.desc" class="muted" v-html="marked(t.desc)" />
             <table><thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead>
@@ -193,7 +223,7 @@ onBeforeUnmount(() => observer?.disconnect())
 
           <h2 id="errors" data-toc>错误码</h2>
           <table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
-            <tbody><tr v-for="er in ref_.errors" :key="er.code"><td>{{ er.status }}</td><td><code>{{ er.code }}</code></td><td v-html="marked(er.desc)" /></tr></tbody></table>
+            <tbody><tr v-for="er in spec.errors" :key="er.code"><td>{{ er.status }}</td><td><code>{{ er.code }}</code></td><td v-html="marked(er.desc)" /></tr></tbody></table>
         </article>
       </div>
 
@@ -203,9 +233,13 @@ onBeforeUnmount(() => observer?.disconnect())
           <label class="label m-0" for="try-identity">调试身份</label>
           <select id="try-identity" v-model="identity" class="field">
             <option value="">后台管理员</option>
-            <option v-for="k in keys.filter(x => x.enabled)" :key="k.id" :value="k.id">平台:{{ k.name }}</option>
+            <option v-for="k in identityKeys" :key="k.id" :value="k.id">平台:{{ k.name }}({{ specId === 'bot' ? 'Bot Key' : 'Agent Key' }})</option>
           </select>
-          <div class="hint">{{ identity ? '与该平台完全相同的权限与可见范围(使用 10 分钟的临时令牌)' : '可见全部 Profile;创建的会话会出现在 Playground 中' }}</div>
+          <div class="hint">
+            <template v-if="identity">与该平台完全相同的权限与可见范围(使用 10 分钟的临时令牌)</template>
+            <template v-else-if="specId === 'bot'">可见全部机器人;创建的工作组会出现在 Bot Playground 中</template>
+            <template v-else>可见全部 Profile;创建的会话会出现在 Playground 中</template>
+          </div>
         </div>
         <nav class="flex flex-col" aria-label="目录">
           <div class="section-title mb-1">目录</div>

+ 32 - 8
apps/admin/src/views/ApiKeysView.vue

@@ -1,10 +1,21 @@
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
 import type { ApiKeyDto, ProfileDto } from '@agent-server/shared'
+import { useRoute, useRouter } from 'vue-router'
+import BotKeysPanel from '../components/BotKeysPanel.vue'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
+// 两类 Key:Agent Key 调用 /v1(会话),Bot Key 调用 /bot/v1(工作组),互不通用(ADR-0024)
+const route = useRoute()
+const router = useRouter()
+const tab = computed<'agent' | 'bot'>({
+  get: () => (route.query.kind === 'bot' ? 'bot' : 'agent'),
+  set: v => void router.replace({ query: v === 'bot' ? { kind: 'bot' } : {} }),
+})
+const botPanel = ref<InstanceType<typeof BotKeysPanel>>()
+
 const keys = ref<ApiKeyDto[]>([])
 const profiles = ref<ProfileDto[]>([])
 const created = ref<{ key: string; name: string } | null>(null)
@@ -70,7 +81,7 @@ async function toggle(k: ApiKeyDto) {
 }
 
 async function remove(k: ApiKeyDto) {
-  if (!confirm(`删除 API Key「${k.name}」?使用它的平台将无法再调用。`)) return
+  if (!confirm(`删除 Agent Key「${k.name}」?使用它的平台将无法再调用。`)) return
   await admin.deleteApiKey(k.id).catch(err => toast(errorMessage(err), 'error'))
   await load()
 }
@@ -89,12 +100,25 @@ onMounted(load)
 
 <template>
   <div class="h-full flex flex-col">
-    <PageHeader title="API Keys" subtitle="每个接入的业务平台一个 Key,只能使用为它勾选的 Profile">
-      <button type="button" class="btn-primary" @click="openCreate"><span class="codicon codicon-add" />签发 API Key</button>
+    <PageHeader title="API Keys" subtitle="Agent Key 调用 /v1(会话,按 Profile 授权);Bot Key 调用 /bot/v1(机器人与工作组,按机器人授权);两者互不通用">
+      <button v-if="tab === 'agent'" type="button" class="btn-primary" @click="openCreate"><span class="codicon codicon-add" />签发 Agent Key</button>
+      <button v-else type="button" class="btn-primary" @click="botPanel?.openKey()"><span class="codicon codicon-add" />签发 Bot Key</button>
     </PageHeader>
-    <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-5 max-w-240">
+    <div class="flex-none flex items-stretch h-[35px] px-6 border-b border-b-solid border-border text-[12px]">
+      <button
+        v-for="t in ([['agent', 'Agent Keys'], ['bot', 'Bot Keys']] as const)" :key="t[0]" type="button"
+        class="px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-1"
+        :class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
+      >
+        {{ t[1] }}
+      </button>
+    </div>
+    <div v-if="tab === 'bot'" class="flex-1 overflow-y-auto p-6 max-w-240">
+      <BotKeysPanel ref="botPanel" />
+    </div>
+    <div v-else class="flex-1 overflow-y-auto p-6 flex flex-col gap-5 max-w-240">
       <div v-if="created" class="card p-4 flex flex-col gap-2 border-accent">
-        <div class="font-600">「{{ created.name }}」的 API Key(只显示这一次,请立即保存)</div>
+        <div class="font-600">「{{ created.name }}」的 Agent Key(只显示这一次,请立即保存)</div>
         <div class="flex gap-2">
           <code class="flex-1 font-mono text-[12.5px] p-2 rounded-md bg-code break-all select-all">{{ created.key }}</code>
           <button type="button" class="btn-secondary" @click="copy(created.key)"><span class="codicon codicon-copy" />复制</button>
@@ -130,7 +154,7 @@ onMounted(load)
               <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
             </td>
           </tr>
-          <tr v-if="!keys.length"><td colspan="5" class="py-4 hint">还没有 API Key</td></tr>
+          <tr v-if="!keys.length"><td colspan="5" class="py-4 hint">还没有 Agent Key</td></tr>
         </tbody>
       </table>
 
@@ -138,7 +162,7 @@ onMounted(load)
       <section class="card p-4 flex flex-col gap-2 text-[12.5px]">
         <div class="font-600 text-[13px] flex items-center gap-1.5"><span class="codicon codicon-plug" />业务平台接入</div>
         <ol class="m-0 pl-5 flex flex-col gap-1">
-          <li>在平台<b>后端</b>保存 API Key,提供一个接口调用 <code class="font-mono">POST /v1/browser-tokens</code> 为已登录用户换取浏览器令牌(1 小时有效)。<b>不要把 API Key 放进前端代码。</b></li>
+          <li>在平台<b>后端</b>保存 Agent Key,提供一个接口调用 <code class="font-mono">POST /v1/browser-tokens</code> 为已登录用户换取浏览器令牌(1 小时有效)。<b>不要把 Agent Key 放进前端代码。</b></li>
           <li>在"设置 › 安全"的 CORS 来源中加入平台的地址(如 <code class="font-mono">https://tools.example.com</code>)。</li>
           <li>前端安装 SDK,使用 <code class="font-mono">&lt;AgentChat&gt;</code> 组件;详见仓库中的 docs/integration.md 与 examples/platform-demo。</li>
         </ol>
@@ -155,7 +179,7 @@ onMounted(load)
 
     <div v-if="editor.open" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor.open = false">
       <form class="w-[520px] card bg-input-alt p-5 flex flex-col gap-4 shadow-xl" @submit.prevent="save">
-        <div class="font-600 text-[14px]">{{ editor.id ? `配置「${editor.name}」可用的 Profile` : '签发 API Key' }}</div>
+        <div class="font-600 text-[14px]">{{ editor.id ? `配置「${editor.name}」可用的 Profile` : '签发 Agent Key' }}</div>
         <div v-if="!editor.id">
           <label class="label" for="key-name">平台名称</label>
           <input id="key-name" v-model="editor.name" class="field" placeholder="例如:家庭记账" autofocus>

+ 345 - 0
apps/admin/src/views/BotPlaygroundView.vue

@@ -0,0 +1,345 @@
+<script setup lang="ts">
+// Bot Playground(docs/design/13-bot-teams.md §12.2):以后台身份调用 /bot/v1,建工作组、下命令、查看每个机器人的执行过程
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { AgentChat, TeamChat, basename, fileIcon, formatSize, useChatSession, useTeamSession, type ChatSession, type TeamSession } from '@agent-server/chat-ui'
+import type { BotDto, FileDto, TeamDto, TeamStatus } from '@agent-server/shared'
+import FilePreview from '../components/FilePreview.vue'
+import SidePane from '../components/SidePane.vue'
+import { admin, botClient, client, errorMessage } from '../lib/api'
+import { relativeTime, toast } from '../lib/ui'
+
+type OpenFile = { fileId: string; path: string; mime: string; size: number }
+
+const route = useRoute()
+const router = useRouter()
+const teamId = computed(() => (route.params.teamId as string | undefined) || null)
+
+const teams = ref<TeamDto[]>([])
+const bots = ref<BotDto[]>([])
+const team = shallowRef<TeamSession | null>(null)
+const t = computed(() => team.value?.state.team ?? null)
+const STATUS: Record<TeamStatus, { text: string; cls: string }> = {
+  active: { text: '进行中', cls: 'bg-success text-white' },
+  paused: { text: '已暂停', cls: 'bg-warning text-white' },
+  closed: { text: '已关闭', cls: 'bg-code text-fg-muted' },
+}
+const BOT_STATUS = { idle: '空闲', queued: '排队中', working: '工作中', waiting_input: '等待确认' } as const
+
+async function loadTeams() {
+  try {
+    teams.value = await botClient.teams.list({ limit: 200 })
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+// 工作组的状态或成员变化时刷新列表
+watch(() => [t.value?.status, t.value?.bots.length, t.value?.updatedAt], () => void loadTeams())
+
+// ---- 新建工作组 ----
+const form = reactive<{ title: string; lead: string; workers: string[]; human: string; saving: boolean }>({ title: '', lead: '', workers: [], human: '管理员', saving: false })
+const leads = computed(() => bots.value.filter(b => b.kind === 'lead' && b.enabled))
+const workers = computed(() => bots.value.filter(b => b.kind === 'worker' && b.enabled))
+const formValid = computed(() => (form.lead || form.workers.length) && form.human.trim())
+
+async function createTeam() {
+  if (!formValid.value) return
+  form.saving = true
+  try {
+    const created = await botClient.teams.create({
+      title: form.title.trim() || undefined, human: { id: 'admin', name: form.human.trim() },
+      leadBotId: form.lead || undefined, workerBotIds: form.workers,
+    })
+    Object.assign(form, { title: '', lead: '', workers: [] })
+    await loadTeams()
+    await router.push(`/bot-playground/${created.id}`)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    form.saving = false
+  }
+}
+
+// ---- 工作组操作 ----
+async function act(fn: () => Promise<unknown>) {
+  try {
+    await fn()
+    await team.value?.refresh()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+type TeamAction = 'pause' | 'resume' | 'close' | 'reopen'
+const setStatus = (action: TeamAction) => act(() => botClient.teams[action](teamId.value!))
+const interruptBot = (botId: string) => act(() => botClient.teams.interruptBot(teamId.value!, botId))
+const removeBot = (botId: string) => act(() => botClient.teams.removeBot(teamId.value!, botId))
+function addBot(e: Event) {
+  const el = e.target as HTMLSelectElement
+  const botId = el.value
+  el.value = ''
+  if (botId) void act(() => botClient.teams.addBot(teamId.value!, botId))
+}
+
+async function removeTeam(x: TeamDto) {
+  if (!confirm(`删除工作组「${x.title}」?消息、共享目录中的文件和各机器人的会话都会一起删除。`)) return
+  try {
+    await botClient.teams.remove(x.id)
+    teams.value = teams.value.filter(y => y.id !== x.id)
+    if (teamId.value === x.id) await router.push('/bot-playground')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+const addable = computed(() => {
+  const cur = t.value
+  if (!cur) return []
+  const hasLead = cur.bots.some(b => b.role === 'lead')
+  return bots.value.filter(b => b.enabled && !cur.bots.some(x => x.id === b.id) && (b.kind === 'worker' || !hasLead))
+})
+
+// ---- 调试面板 ----
+const showPanel = ref(true)
+const tab = ref<'members' | 'process' | 'files'>('members')
+
+// 过程:某个机器人的会话(只读),定位到指定的一轮
+const proc = reactive<{ botId: string; sessionId: string; runId: string | null }>({ botId: '', sessionId: '', runId: null })
+const procChat = shallowRef<ChatSession | null>(null)
+const procEl = ref<HTMLElement>()
+
+function closeProcess() {
+  procChat.value?.dispose()
+  procChat.value = null
+  Object.assign(proc, { botId: '', sessionId: '', runId: null })
+}
+
+function openProcess(ref: { botId: string; sessionId: string; runId: string | null }) {
+  showPanel.value = true
+  tab.value = 'process'
+  if (proc.sessionId !== ref.sessionId) {
+    procChat.value?.dispose()
+    procChat.value = useChatSession(client, ref.sessionId)
+  }
+  const botId = ref.botId || t.value?.bots.find(b => b.sessionId === ref.sessionId)?.id || ''
+  Object.assign(proc, { ...ref, botId })
+  void scrollToRun()
+}
+
+function pickBot(botId: string) {
+  const b = t.value?.bots.find(x => x.id === botId)
+  if (b?.sessionId) openProcess({ botId, sessionId: b.sessionId, runId: null })
+}
+
+async function scrollToRun() {
+  const chat = procChat.value
+  if (!chat || !proc.runId) return
+  const end = Date.now() + 3000
+  while (chat.state.loading && Date.now() < end) await new Promise(r => setTimeout(r, 50))
+  const first = chat.state.events.find(e => e.runId === proc.runId && e.type === 'user.message')
+  await nextTick()
+  if (first) procEl.value?.querySelector(`[data-seq="${first.seq}"]`)?.scrollIntoView({ block: 'start' })
+}
+
+// 文件:工作组的共享目录
+const files = ref<FileDto[]>([])
+const preview = ref<OpenFile | null>(null)
+async function loadFiles() {
+  if (teamId.value) files.value = await botClient.teams.files(teamId.value).catch(() => [])
+}
+watch([tab, () => team.value?.state.filesVersion], () => {
+  if (tab.value === 'files') void loadFiles()
+})
+function openFile(f: OpenFile) {
+  showPanel.value = true
+  tab.value = 'files'
+  preview.value = f
+}
+
+// 切换工作组:放在调试面板的状态声明之后(immediate 会立即执行)
+watch(teamId, id => {
+  team.value?.dispose()
+  team.value = null
+  closeProcess()
+  preview.value = null
+  files.value = []
+  if (id) team.value = useTeamSession(botClient, id)
+}, { immediate: true })
+
+let timer: ReturnType<typeof setInterval> | undefined
+onMounted(async () => {
+  void loadTeams()
+  bots.value = await admin.bots().catch(() => [])
+  timer = setInterval(loadTeams, 15000)
+})
+onBeforeUnmount(() => {
+  clearInterval(timer)
+  team.value?.dispose()
+  procChat.value?.dispose()
+})
+</script>
+
+<template>
+  <SidePane title="Bot Playground">
+    <template #actions>
+      <button type="button" class="icon-btn" title="新建工作组" @click="router.push('/bot-playground')"><span class="codicon codicon-add" /></button>
+      <button type="button" class="icon-btn" title="刷新" @click="loadTeams"><span class="codicon codicon-refresh" /></button>
+    </template>
+    <template #side>
+      <div v-if="!teams.length" class="px-5 py-3 hint">还没有工作组,点击 + 新建</div>
+      <div
+        v-for="x in teams" :key="x.id"
+        class="group flex items-center gap-2 h-[48px] pl-5 pr-2 cursor-pointer hover:bg-hover"
+        :class="{ '!bg-active': x.id === teamId }" @click="router.push(`/bot-playground/${x.id}`)"
+      >
+        <div class="flex-1 min-w-0">
+          <div class="truncate" :class="{ 'text-fg-muted': x.status === 'closed' }">{{ x.title }}</div>
+          <div class="text-[11px] text-fg-muted truncate">{{ x.bots.map(b => b.avatar).join('') }} · {{ relativeTime(x.updatedAt) }}</div>
+        </div>
+        <span v-if="x.bots.some(b => b.status !== 'idle')" class="w-2 h-2 rounded-full flex-none" :class="x.bots.some(b => b.status === 'waiting_input') ? 'bg-warning' : 'bg-accent'" />
+        <span v-else-if="x.status !== 'active'" class="hint text-[11px]">{{ STATUS[x.status].text }}</span>
+        <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click.stop="removeTeam(x)">
+          <span class="codicon codicon-trash" />
+        </button>
+      </div>
+    </template>
+
+    <!-- 新建工作组 -->
+    <div v-if="!teamId" class="flex-1 overflow-y-auto flex flex-col items-center gap-4 p-8 bg-bg-side">
+      <span class="codicon codicon-organization text-[40px] text-fg-muted" />
+      <div class="text-[15px] font-600">在 Bot Playground 中验证机器人</div>
+      <div class="hint max-w-120 text-center">使用与业务平台完全相同的 /bot/v1 接口。工作组由 1 个成员、0–1 个组长和若干组员组成;@ 某个组员让它单独干活,不 @ 时交给组长。</div>
+      <form class="w-[480px] card bg-input-alt p-4 flex flex-col gap-3" @submit.prevent="createTeam">
+        <div class="flex gap-3">
+          <div class="flex-1">
+            <label class="label" for="team-title">标题(可选)</label>
+            <input id="team-title" v-model="form.title" class="field" placeholder="默认用机器人名称">
+          </div>
+          <div class="w-32">
+            <label class="label" for="team-human">成员名称</label>
+            <input id="team-human" v-model="form.human" class="field">
+          </div>
+        </div>
+        <div>
+          <label class="label" for="team-lead">组长(可选)</label>
+          <select id="team-lead" v-model="form.lead" class="field">
+            <option value="">不要组长</option>
+            <option v-for="b in leads" :key="b.id" :value="b.id">{{ b.avatar }} {{ b.name }}</option>
+          </select>
+        </div>
+        <div>
+          <div class="label">组员</div>
+          <div v-if="!workers.length" class="hint">还没有组员类型的机器人</div>
+          <div class="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
+            <label v-for="b in workers" :key="b.id" class="flex items-start gap-2 px-2 py-1 rounded-md hover:bg-hover cursor-pointer">
+              <input v-model="form.workers" type="checkbox" :value="b.id" class="mt-0.5">
+              <span class="flex-1 min-w-0"><span>{{ b.avatar }} {{ b.name }}</span><span class="block hint truncate">{{ b.description }}</span></span>
+            </label>
+          </div>
+        </div>
+        <div v-if="!bots.length" class="hint">请先在<RouterLink to="/bots" class="text-link">机器人</RouterLink>页面新建机器人</div>
+        <div class="flex justify-end">
+          <button type="submit" class="btn-primary" :disabled="!formValid || form.saving"><span class="codicon codicon-add" />新建工作组</button>
+        </div>
+      </form>
+    </div>
+
+    <template v-else>
+      <header class="h-[35px] flex-none flex items-center gap-2 px-4 border-b border-b-solid border-border bg-bg-side">
+        <span class="font-500 truncate">{{ t?.title ?? '' }}</span>
+        <span v-if="t" class="badge" :class="STATUS[t.status].cls">{{ STATUS[t.status].text }}</span>
+        <span class="flex-1" />
+        <template v-if="t">
+          <button v-if="t.status === 'active'" type="button" class="icon-btn" title="暂停:中断正在执行的工作,新消息只记录" @click="setStatus('pause')"><span class="codicon codicon-debug-pause" /></button>
+          <button v-if="t.status === 'paused'" type="button" class="icon-btn" title="恢复" @click="setStatus('resume')"><span class="codicon codicon-debug-continue" /></button>
+          <button v-if="t.status !== 'closed'" type="button" class="icon-btn" title="关闭工作组" @click="setStatus('close')"><span class="codicon codicon-archive" /></button>
+          <button v-else type="button" class="icon-btn" title="重新打开" @click="setStatus('reopen')"><span class="codicon codicon-folder-opened" /></button>
+        </template>
+        <button type="button" class="icon-btn" :class="{ '!text-fg': showPanel }" title="调试面板" @click="showPanel = !showPanel">
+          <span class="codicon codicon-layout-sidebar-right" />
+        </button>
+      </header>
+      <div class="flex-1 min-h-0 flex">
+        <div class="flex-1 min-w-0">
+          <TeamChat v-if="team" :key="teamId!" :client="botClient" :session="team" @open-file="openFile" @open-run="openProcess" />
+        </div>
+        <div v-if="showPanel && t" class="flex-none min-h-0 flex flex-col bg-bg-side border-l border-l-solid border-border" :class="tab === 'process' ? 'w-[520px]' : 'w-[360px]'">
+          <div class="h-[35px] flex-none flex items-stretch border-b border-b-solid border-border text-[11px] font-600 uppercase tracking-wide">
+            <button
+              v-for="x in ([['members', '成员'], ['process', '过程'], ['files', '文件']] as const)" :key="x[0]" type="button"
+              class="px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-1"
+              :class="tab === x[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = x[0]; preview = null"
+            >
+              {{ x[1] }}
+            </button>
+          </div>
+
+          <!-- 成员 -->
+          <div v-if="tab === 'members'" class="flex-1 min-h-0 overflow-y-auto p-3 flex flex-col gap-2 text-[12.5px]">
+            <div class="flex items-center gap-2 px-1"><span class="codicon codicon-person" />{{ t.human.name }}<span class="hint">成员</span></div>
+            <div v-for="b in t.bots" :key="b.id" class="card p-2.5 flex flex-col gap-1">
+              <div class="flex items-center gap-2">
+                <span class="text-[16px]">{{ b.avatar }}</span>
+                <span class="font-600">{{ b.name }}</span>
+                <span class="badge bg-code" :class="b.role === 'lead' ? 'text-accent' : ''">{{ b.role === 'lead' ? '组长' : '组员' }}</span>
+                <span class="flex-1" />
+                <span class="text-[11px]" :class="b.status === 'idle' ? 'text-fg-muted' : b.status === 'waiting_input' ? 'text-warning' : 'text-accent'">{{ BOT_STATUS[b.status] }}</span>
+              </div>
+              <div class="hint">{{ b.provider ?? '?' }} / {{ b.model ?? '?' }}</div>
+              <div v-if="b.description" class="hint line-clamp-2">{{ b.description }}</div>
+              <div class="flex gap-1">
+                <button type="button" class="btn-ghost" :disabled="!b.sessionId" @click="pickBot(b.id)"><span class="codicon codicon-list-tree" />过程</button>
+                <button v-if="b.status !== 'idle'" type="button" class="btn-ghost" @click="interruptBot(b.id)"><span class="codicon codicon-debug-stop" />停止</button>
+                <span class="flex-1" />
+                <button type="button" class="btn-ghost" :disabled="t.bots.length <= 1" @click="removeBot(b.id)">移出</button>
+              </div>
+            </div>
+            <select
+              v-if="addable.length && t.status !== 'closed'" class="field" aria-label="添加机器人" value=""
+              @change="addBot"
+            >
+              <option value="" disabled>+ 添加机器人…</option>
+              <option v-for="b in addable" :key="b.id" :value="b.id">{{ b.avatar }} {{ b.name }}({{ b.kind === 'lead' ? '组长' : '组员' }})</option>
+            </select>
+          </div>
+
+          <!-- 过程 -->
+          <template v-else-if="tab === 'process'">
+            <div class="flex-none flex items-center gap-2 px-3 h-9 border-b border-b-solid border-border">
+              <select class="field h-7" :value="proc.botId" aria-label="选择机器人" @change="pickBot(($event.target as HTMLSelectElement).value)">
+                <option value="" disabled>选择机器人…</option>
+                <option v-for="b in t.bots" :key="b.id" :value="b.id" :disabled="!b.sessionId">{{ b.avatar }} {{ b.name }}{{ b.sessionId ? '' : '(还没有工作过)' }}</option>
+              </select>
+            </div>
+            <div ref="procEl" class="flex-1 min-h-0">
+              <AgentChat v-if="procChat" :key="proc.sessionId" :client="client" :session="procChat" readonly />
+              <div v-else class="p-4 hint">点击时间线中机器人消息上的"过程",或在上面选择一个机器人,查看它在这个工作组中的完整执行过程(工具调用、思考、交给它的输入)。</div>
+            </div>
+          </template>
+
+          <!-- 文件 -->
+          <template v-else>
+            <FilePreview v-if="preview" :file="preview" :client="botClient" class="flex-1" />
+            <div v-else class="flex-1 min-h-0 overflow-y-auto py-1">
+              <div v-if="!files.length" class="p-4 hint">共享目录还没有文件</div>
+              <button
+                v-for="f in files" :key="f.id" type="button"
+                class="w-full flex items-center gap-2 px-3 h-7 border-none bg-transparent cursor-pointer text-left hover:bg-hover"
+                @click="preview = { fileId: f.id, path: f.path, mime: f.mime, size: f.size }"
+              >
+                <span class="codicon flex-none" :class="[fileIcon(f.mime, f.path), f.origin === 'upload' ? 'text-link' : 'text-accent']" />
+                <span class="truncate" :title="f.path">{{ f.path }}</span>
+                <span class="flex-1" />
+                <span class="hint flex-none">{{ formatSize(f.size) }}</span>
+              </button>
+            </div>
+            <button v-if="preview" type="button" class="flex-none h-7 border-none border-t border-t-solid border-border bg-transparent cursor-pointer hint hover:text-fg" @click="preview = null">
+              <span class="codicon codicon-arrow-left text-[12px]" /> 返回文件列表({{ basename(preview.path) }})
+            </button>
+          </template>
+        </div>
+      </div>
+    </template>
+  </SidePane>
+</template>

+ 150 - 0
apps/admin/src/views/BotsView.vue

@@ -0,0 +1,150 @@
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import type { BotDto, BotInput, ProfileDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, botClient, errorMessage } from '../lib/api'
+import { PERMISSION_LABELS, toast } from '../lib/ui'
+
+const router = useRouter()
+const bots = ref<BotDto[]>([])
+const profiles = ref<ProfileDto[]>([])
+const profileOf = (id: string) => profiles.value.find(p => p.id === id)
+const KIND = { lead: '组长', worker: '组员' } as const
+
+async function load() {
+  try {
+    ;[bots.value, profiles.value] = await Promise.all([admin.bots(), admin.profiles()])
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+// ---- 机器人 ----
+const editor = reactive<{ open: boolean; id: string | null; form: BotInput; saving: boolean }>({
+  open: false, id: null, saving: false, form: { name: '', avatar: '🤖', kind: 'worker', description: '', profileId: '', enabled: true },
+})
+const formValid = computed(() => editor.form.name.trim() && !/[\s@]/.test(editor.form.name) && editor.form.avatar.trim() && editor.form.profileId)
+
+function openBot(b?: BotDto) {
+  editor.id = b?.id ?? null
+  editor.form = b
+    ? { name: b.name, avatar: b.avatar, kind: b.kind, description: b.description, profileId: b.profileId, enabled: b.enabled }
+    : { name: '', avatar: '🤖', kind: 'worker', description: '', profileId: profiles.value.find(p => p.isDefault)?.id ?? profiles.value[0]?.id ?? '', enabled: true }
+  editor.open = true
+}
+
+async function saveBot() {
+  if (!formValid.value) return
+  editor.saving = true
+  try {
+    const input = { ...editor.form, name: editor.form.name.trim() }
+    if (editor.id) await admin.updateBot(editor.id, input)
+    else await admin.createBot(input)
+    editor.open = false
+    toast('已保存', 'success')
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    editor.saving = false
+  }
+}
+
+async function removeBot(b: BotDto) {
+  if (!confirm(`删除机器人「${b.name}」?`)) return
+  await admin.deleteBot(b.id).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
+/** 在 Bot Playground 中测试:新建一个只有它的工作组(组长则单独成组,由它直接回复) */
+async function tryBot(b: BotDto) {
+  try {
+    const t = await botClient.teams.create({
+      title: `测试 · ${b.name}`, human: { id: 'admin', name: '管理员' },
+      ...(b.kind === 'lead' ? { leadBotId: b.id } : { workerBotIds: [b.id] }),
+    })
+    await router.push(`/bot-playground/${t.id}`)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+onMounted(load)
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="机器人" subtitle="不同岗位的机器人:身份 + 关联的 Profile(技能、工具、权限);业务平台通过 Bot API 使用(Bot Key 在 API Keys 页面签发)">
+      <RouterLink to="/api-keys?kind=bot" class="btn-ghost no-underline"><span class="codicon codicon-key" />Bot Keys</RouterLink>
+      <button type="button" class="btn-primary" :disabled="!profiles.length" @click="openBot()"><span class="codicon codicon-add" />新建机器人</button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-3 max-w-240">
+      <div v-if="!bots.length" class="card p-4 hint">还没有机器人。每个机器人关联一个 Profile:技能、MCP 服务、资料库和权限都由 Profile 决定;机器人本身只是身份和岗位描述。</div>
+      <div v-for="b in bots" :key="b.id" class="card p-4 flex gap-3" :class="{ 'opacity-60': !b.enabled }">
+        <div class="w-10 h-10 flex-none flex items-center justify-center rounded-full bg-code text-[20px]">{{ b.avatar }}</div>
+        <div class="flex-1 min-w-0 flex flex-col gap-1">
+          <div class="flex items-center gap-2">
+            <span class="font-600">{{ b.name }}</span>
+            <span class="badge bg-code" :class="b.kind === 'lead' ? 'text-accent' : ''">{{ KIND[b.kind] }}</span>
+            <span v-if="!b.enabled" class="badge bg-code text-fg-muted">已停用</span>
+          </div>
+          <div class="hint whitespace-pre-wrap">{{ b.description || '(没有岗位描述)' }}</div>
+          <div class="text-[12px] text-fg-muted">
+            <template v-if="profileOf(b.profileId)">
+              Profile:<RouterLink :to="`/profiles/${b.profileId}`" class="text-link">{{ profileOf(b.profileId)!.name }}</RouterLink>
+              · {{ profileOf(b.profileId)!.provider }} / {{ profileOf(b.profileId)!.model }} · {{ PERMISSION_LABELS[profileOf(b.profileId)!.permissionMode] }}
+            </template>
+            <span v-else class="text-error">Profile 已删除,请重新选择</span>
+          </div>
+        </div>
+        <div class="flex-none flex items-start gap-1">
+          <button type="button" class="btn-ghost" :disabled="!b.enabled" @click="tryBot(b)"><span class="codicon codicon-play" />测试</button>
+          <button type="button" class="btn-ghost" @click="openBot(b)">编辑</button>
+          <button type="button" class="icon-btn" title="删除" @click="removeBot(b)"><span class="codicon codicon-trash" /></button>
+        </div>
+      </div>
+    </div>
+
+
+    <!-- 机器人编辑 -->
+    <div v-if="editor.open" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor.open = false">
+      <form class="w-[560px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="saveBot">
+        <div class="font-600 text-[14px]">{{ editor.id ? `编辑「${editor.form.name}」` : '新建机器人' }}</div>
+        <div class="flex gap-3">
+          <div class="w-20">
+            <label class="label" for="bot-avatar">头像</label>
+            <input id="bot-avatar" v-model="editor.form.avatar" class="field text-center" maxlength="8">
+          </div>
+          <div class="flex-1">
+            <label class="label" for="bot-name">名称(不能有空格和 @)</label>
+            <input id="bot-name" v-model="editor.form.name" class="field" placeholder="例如:数据分析员" maxlength="30" autofocus>
+          </div>
+          <div class="w-28">
+            <label class="label" for="bot-kind">类型</label>
+            <select id="bot-kind" v-model="editor.form.kind" class="field">
+              <option value="worker">组员</option>
+              <option value="lead">组长</option>
+            </select>
+          </div>
+        </div>
+        <div>
+          <label class="label" for="bot-desc">岗位描述</label>
+          <textarea id="bot-desc" v-model="editor.form.description" class="field-area h-28" placeholder="擅长什么、负责什么、不负责什么。会写进其他机器人的提示词,组长据此分派任务。" />
+        </div>
+        <div>
+          <label class="label" for="bot-profile">Profile</label>
+          <select id="bot-profile" v-model="editor.form.profileId" class="field">
+            <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}({{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }})</option>
+          </select>
+          <div class="hint mt-1">技能、MCP 服务、资料库、权限模式都由 Profile 决定。工作组中的机器人尽量用"自动执行"或"只读",避免频繁审批。</div>
+        </div>
+        <label class="flex items-center gap-2 cursor-pointer"><input v-model="editor.form.enabled" type="checkbox">启用</label>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="editor.open = false">取消</button>
+          <button type="submit" class="btn-primary" :disabled="!formValid || editor.saving">保存</button>
+        </div>
+      </form>
+    </div>
+  </div>
+</template>

+ 1 - 1
apps/admin/src/views/PluginsView.vue

@@ -265,7 +265,7 @@ onMounted(() => {
           <div class="flex flex-col gap-1.5 text-[12.5px]">
             <div class="font-600 text-[13px]">来源:OpenAI 官方插件目录(openai-curated),<b>只在 Playground 中使用</b></div>
             <div>插件由 Codex 自己安装和管理,可能包含技能和应用连接器(如 Gmail、Google Drive)。连接器<b>使用 Codex 登录账号的授权</b>访问对应数据,首次使用需要在 ChatGPT 中完成连接。</div>
-            <div>只有在后台 Playground 中创建的 Codex 会话会开放已安装的插件,调用连接器前会弹出审批;<b>经 API Key 创建的会话始终关闭插件与连接器</b>。对话中不能安装新插件。</div>
+            <div>只有在后台 Playground 中创建的 Codex 会话会开放已安装的插件,调用连接器前会弹出审批;<b>经 Agent API 创建的会话始终关闭插件与连接器</b>。对话中不能安装新插件。</div>
           </div>
         </section>
         <div class="flex items-center gap-3 flex-wrap">

+ 1 - 1
apps/admin/src/views/SessionsView.vue

@@ -8,7 +8,7 @@ import SidePane from '../components/SidePane.vue'
 import { admin, client, errorMessage } from '../lib/api'
 import { relativeTime, toast } from '../lib/ui'
 
-// 所有来源(各 API Key 与后台)的会话,只读回放
+// 所有来源(各 Agent Key 与后台)的会话,只读回放
 const route = useRoute()
 const router = useRouter()
 const sessionId = computed(() => (route.params.sessionId as string | undefined) || null)

+ 1 - 1
apps/admin/src/views/SettingsView.vue

@@ -117,7 +117,7 @@ const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
           <div>
             <label class="label" for="s-cors">允许浏览器直接调用 API 的业务平台来源(CORS,每行一个)</label>
             <textarea id="s-cors" class="field-area h-20 font-mono" :value="lines(draft.security.corsOrigins)" placeholder="https://tools.example.com" @change="draft.security.corsOrigins = parseLines(($event.target as HTMLTextAreaElement).value)" />
-            <div class="hint mt-1">格式为 https://host[:port],不带路径。浏览器中请使用平台后端签发的浏览器令牌,不要把 API Key 放进前端代码(见接入指南)。</div>
+            <div class="hint mt-1">格式为 https://host[:port],不带路径。浏览器中请使用平台后端签发的浏览器令牌,不要把 Agent Key 放进前端代码(见接入指南)。</div>
           </div>
         </section>
 

+ 65 - 0
apps/server/drizzle/0006_bot_teams.sql

@@ -0,0 +1,65 @@
+CREATE TABLE `bot_api_keys` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`key_hash` text NOT NULL,
+	`key_prefix` text NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`allowed_bot_ids` text DEFAULT '[]' NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`last_used_at` text
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `bot_api_keys_key_hash_unique` ON `bot_api_keys` (`key_hash`);--> statement-breakpoint
+CREATE TABLE `bots` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`avatar` text DEFAULT '🤖' NOT NULL,
+	`kind` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`profile_id` text NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `bots_name_unique` ON `bots` (`name`);--> statement-breakpoint
+CREATE TABLE `team_bot_sessions` (
+	`team_id` text NOT NULL,
+	`bot_id` text NOT NULL,
+	`session_id` text NOT NULL,
+	`last_read_seq` integer DEFAULT 0 NOT NULL,
+	PRIMARY KEY(`team_id`, `bot_id`)
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `team_bot_sessions_session_id_unique` ON `team_bot_sessions` (`session_id`);--> statement-breakpoint
+CREATE TABLE `team_bots` (
+	`team_id` text NOT NULL,
+	`bot_id` text NOT NULL,
+	`role` text NOT NULL,
+	`joined_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	PRIMARY KEY(`team_id`, `bot_id`)
+);
+--> statement-breakpoint
+CREATE INDEX `team_bots_bot_idx` ON `team_bots` (`bot_id`);--> statement-breakpoint
+CREATE TABLE `team_events` (
+	`team_id` text NOT NULL,
+	`seq` integer NOT NULL,
+	`type` text NOT NULL,
+	`payload` text NOT NULL,
+	`ts` text NOT NULL,
+	PRIMARY KEY(`team_id`, `seq`)
+);
+--> statement-breakpoint
+CREATE TABLE `teams` (
+	`id` text PRIMARY KEY NOT NULL,
+	`source` text NOT NULL,
+	`title` text DEFAULT '' NOT NULL,
+	`human` text NOT NULL,
+	`status` text DEFAULT 'active' NOT NULL,
+	`workspace_dir` text NOT NULL,
+	`last_seq` integer DEFAULT 0 NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `teams_source_idx` ON `teams` (`source`,`updated_at`);

+ 1936 - 0
apps/server/drizzle/meta/0006_snapshot.json

@@ -0,0 +1,1936 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "2bd63c23-fd55-40ff-a354-6f6148012e1f",
+  "prevId": "6ffa15f1-3c63-4b6e-9a17-f5cddb1357a4",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_sources": {
+      "name": "skill_sources",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -43,6 +43,13 @@
       "when": 1792938535790,
       "tag": "0005_custom_skills",
       "breakpoints": true
+    },
+    {
+      "idx": 6,
+      "version": "6",
+      "when": 1793009091003,
+      "tag": "0006_bot_teams",
+      "breakpoints": true
     }
   ]
 }

+ 323 - 0
apps/server/src/api-reference/bot-reference.ts

@@ -0,0 +1,323 @@
+// Bot API(/bot/v1)协议参考(docs/design/13-bot-teams.md §9、ADR-0024)。
+// test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /bot/v1 路由完全一致,新增或删除接口时必须同步修改。
+
+import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiTypeDef } from '@agent-server/shared'
+
+export const BOT_OVERVIEW: ApiOverview = {
+  points: [
+    '**基础地址**:`$BASE`,所有接口位于 `/bot/v1` 下;请求和响应为 JSON(UTF-8),时间为 ISO 8601。',
+    '**鉴权**:`Authorization: Bearer <凭证>`。**Bot Key**(`bk_…`)每个业务平台一个,在后台"API Keys › Bot Keys"签发,只在平台后端使用;**浏览器令牌**(`bt_…`)由平台后端调用 `POST /bot/v1/browser-tokens` 换取。Bot Key 与 Agent Key 互不通用:Bot Key 不能调用 `/v1`,Agent Key 也不能调用 `/bot/v1`。',
+    '**可见范围**:每个 Bot Key 只能使用后台为它勾选的机器人,只能看到自己创建的工作组。',
+    '**工作组**:所有对话都在工作组中进行。一个工作组有 1 个成员(人,由平台提供 `{ id, name }`,AgentServer 不做认证)、0–1 个组长、0–多个组员,组长和组员至少 1 个。',
+    '**唤醒规则**:消息 @ 了谁就交给谁;不 @ 时,有组长交给组长,只有一个组员时交给它,否则返回 `mention_required`。同一机器人按顺序执行,执行期间收到的消息合并到下一轮。',
+    '**ID 前缀**:工作组 `team_`、机器人 `bot_`、消息 `tmsg_`、交互 `itx_`、文件 `file_`;机器人的执行过程是会话 `ses_` 与 Run `run_`。',
+    '**错误格式**与 Agent API 相同:`{ "error": { "code": "…", "message": "…", "details": {} } }`。',
+    '**SDK**:`@agent-server/client` 中的 `BotClient` 与 `@agent-server/chat-ui` 中的 `<TeamChat>`,安装地址见 `$BASE/sdk/index.json`。',
+  ],
+  flow: [
+    '平台后端用 Bot Key 换取浏览器令牌;前端 `GET /bot/v1/bots` 查看可用的机器人;',
+    '`POST /bot/v1/teams` 创建工作组,订阅 `GET /bot/v1/teams/:id/stream`;',
+    '需要附件时先 `POST /bot/v1/teams/:id/files` 上传,再 `POST /bot/v1/teams/:id/messages` 发消息(用 mentions 指定机器人);',
+    '事件流中依次出现 `bot.status`(工作中)、`message.created`(机器人回复,交付文件在 files 中);',
+    '出现 `interaction.required` 时由成员决定,调用 `POST /bot/v1/teams/:id/interactions/:iid` 回复;交付文件用 `GET /bot/v1/files/:id` 下载。',
+  ],
+}
+
+const team = (s: string) => `/bot/v1/teams/:id${s}`
+
+export const BOT_GROUPS: ApiEndpointGroup[] = [
+  {
+    id: 'auth',
+    title: '令牌',
+    desc: '业务平台的后端用 Bot Key 换取短期浏览器令牌,交给前端使用;Bot Key 不要出现在前端代码中。',
+    endpoints: [
+      {
+        method: 'POST', path: '/bot/v1/browser-tokens', summary: '签发浏览器令牌',
+        desc: '只能使用 Bot Key 调用(浏览器令牌不能再签发令牌)。令牌只能用于 `/bot/v1`;停用或删除 Bot Key 后,它签发的全部令牌立即失效。',
+        body: [{ name: 'ttlSeconds', type: 'number', desc: '有效期(秒),60 ~ 43200,默认 3600' }],
+        response: { status: 201, type: 'BrowserToken' },
+        sampleBody: { ttlSeconds: 600 },
+        errors: ['400 bad_request:ttlSeconds 超出范围', '403 forbidden:使用浏览器令牌或后台身份调用'],
+        example: `curl -X POST $BASE/bot/v1/browser-tokens \\
+  -H "Authorization: Bearer $BOT_KEY" -H "Content-Type: application/json" \\
+  -d '{"ttlSeconds":3600}'`,
+      },
+    ],
+  },
+  {
+    id: 'bots',
+    title: '机器人',
+    endpoints: [
+      {
+        method: 'GET', path: '/bot/v1/bots', summary: '本平台可用的机器人',
+        desc: '只返回在后台为该 Bot Key 勾选、且已启用的机器人。kind 为 lead(组长)或 worker(组员)。',
+        response: { status: 200, type: 'BotSummary[]' },
+      },
+    ],
+  },
+  {
+    id: 'teams',
+    title: '工作组',
+    desc: '工作组归属于创建它的 Bot Key,其他平台看不到。组内所有机器人共用一个共享目录(uploads/ 是成员上传的文件,outputs/ 是机器人交付的文件)。',
+    endpoints: [
+      {
+        method: 'POST', path: '/bot/v1/teams', summary: '创建工作组',
+        desc: '组长和组员加起来至少一个;组长必须是 lead 类型的机器人,组员必须是 worker 类型。',
+        body: [
+          { name: 'human', type: '{ id, name }', required: true, desc: '工作组中的成员(人)。由平台提供,用于显示和审计' },
+          { name: 'title', type: 'string', desc: '标题(≤ 100 字),省略时用机器人名称' },
+          { name: 'leadBotId', type: 'string', desc: '组长(0–1 个)' },
+          { name: 'workerBotIds', type: 'string[]', desc: '组员(0–20 个)' },
+        ],
+        response: { status: 201, type: 'TeamDetail' },
+        sampleBody: { title: 'API 调试', human: { id: 'u1', name: '张三' }, workerBotIds: [] },
+        errors: ['400 bad_request:没有机器人、机器人类型不符或已停用', '403 bot_not_allowed:机器人不在本平台可用列表中'],
+        example: `curl -X POST $BASE/bot/v1/teams -H "Authorization: Bearer $TOKEN" \\
+  -H "Content-Type: application/json" \\
+  -d '{"human":{"id":"u1","name":"张三"},"workerBotIds":["bot_01…"]}'`,
+      },
+      {
+        method: 'GET', path: '/bot/v1/teams', summary: '工作组列表',
+        desc: '本平台创建的工作组,按更新时间倒序。',
+        query: [
+          { name: 'status', type: '"active" | "paused" | "closed"', desc: '只列出该状态' },
+          { name: 'limit', type: 'number', desc: '1–500,默认 100' },
+        ],
+        response: { status: 200, type: 'Team[]' },
+      },
+      { method: 'GET', path: team(''), summary: '工作组详情', desc: '包含各机器人的当前状态和未处理的交互。', response: { status: 200, type: 'TeamDetail' } },
+      {
+        method: 'PATCH', path: team(''), summary: '修改标题',
+        body: [{ name: 'title', type: 'string', required: true, desc: '1–100 字' }],
+        response: { status: 200, type: 'TeamDetail' },
+        sampleBody: { title: '新标题' },
+      },
+      {
+        method: 'DELETE', path: team(''), summary: '删除工作组',
+        desc: '中断正在执行的工作,删除消息、共享目录中的文件和各机器人的会话;用量统计保留。',
+        response: { status: 204, type: '—' },
+        errors: ['409 team_busy:仍有机器人在执行,稍后再试'],
+      },
+      {
+        method: 'POST', path: team('/pause'), summary: '暂停',
+        desc: '中断正在执行的工作;暂停期间的消息只记录,恢复后交给机器人。',
+        response: { status: 200, type: 'TeamDetail' }, errors: ['409 invalid_status:当前状态不能执行这个操作'],
+      },
+      { method: 'POST', path: team('/resume'), summary: '恢复', response: { status: 200, type: 'TeamDetail' }, errors: ['409 invalid_status:当前状态不能执行这个操作'] },
+      {
+        method: 'POST', path: team('/close'), summary: '关闭',
+        desc: '不再接收消息,进行中的工作被中断;历史与文件只读保留,可以重新打开(各机器人的会话可以续接)。',
+        response: { status: 200, type: 'TeamDetail' }, errors: ['409 invalid_status:当前状态不能执行这个操作'],
+      },
+      { method: 'POST', path: team('/reopen'), summary: '重新打开', response: { status: 200, type: 'TeamDetail' }, errors: ['409 invalid_status:当前状态不能执行这个操作'] },
+    ],
+  },
+  {
+    id: 'members',
+    title: '成员',
+    endpoints: [
+      {
+        method: 'POST', path: team('/bots'), summary: '加入机器人',
+        desc: '类型由机器人决定(组长或组员);一个工作组最多一个组长。',
+        body: [{ name: 'botId', type: 'string', required: true, desc: '机器人 id' }],
+        response: { status: 200, type: 'TeamDetail' },
+        sampleBody: { botId: '' },
+        errors: ['409 already_member:已经在工作组中', '403 bot_not_allowed:机器人不在本平台可用列表中', '409 team_closed:工作组已关闭'],
+      },
+      {
+        method: 'DELETE', path: team('/bots/:botId'), summary: '移出机器人',
+        desc: '正在执行的工作会被中断。不能移出最后一个机器人。它在本组的会话保留,再次加入时续接。',
+        response: { status: 200, type: 'TeamDetail' },
+        errors: ['400 bad_request:不能移出最后一个机器人'],
+      },
+      {
+        method: 'POST', path: team('/bots/:botId/interrupt'), summary: '中断机器人的本轮工作',
+        response: { status: 202, type: '{ ok: true }' },
+      },
+    ],
+  },
+  {
+    id: 'messages',
+    title: '消息与事件流',
+    desc: '工作组的时间线由事件组成:消息、交互卡片与状态共用一个递增的 seq,断线后按 `Last-Event-ID` 续传。',
+    endpoints: [
+      {
+        method: 'POST', path: team('/messages'), summary: '发送消息(下达命令)',
+        desc: '以工作组成员的身份发送。返回后机器人在后台开始执行,进度和回复通过事件流推送。暂停期间只记录,不唤醒机器人。',
+        body: [
+          { name: 'text', type: 'string', required: true, desc: '1–100000 字' },
+          { name: 'mentions', type: 'string[]', desc: '被 @ 的机器人 id;省略时按唤醒规则(见概述)' },
+          { name: 'fileIds', type: 'string[]', desc: '已上传到本工作组的文件,作为附件交给机器人(图片会直接给模型看)' },
+        ],
+        response: { status: 201, type: 'TeamMessage' },
+        sampleBody: { text: '你好,介绍一下你能做什么', mentions: [] },
+        errors: ['400 mention_required:多个组员且没有组长时必须 @', '400 bad_request:被 @ 的机器人不在工作组中', '409 team_closed:工作组已关闭'],
+        example: `curl -X POST $BASE/bot/v1/teams/$TEAM/messages -H "Authorization: Bearer $TOKEN" \\
+  -H "Content-Type: application/json" \\
+  -d '{"text":"统计一下附件里的销售额","mentions":["bot_01…"],"fileIds":["file_01…"]}'`,
+      },
+      {
+        method: 'GET', path: team('/messages'), summary: '消息历史',
+        desc: '只包含 message.created 事件。',
+        query: [
+          { name: 'after', type: 'number', desc: '只返回 seq 大于它的事件' },
+          { name: 'limit', type: 'number', desc: '默认 500,最多 5000' },
+        ],
+        response: { status: 200, type: '{ items: TeamEvent[], lastSeq }' },
+      },
+      {
+        method: 'GET', path: team('/events'), summary: '全部事件(历史)',
+        desc: '加载工作组时使用:先取历史事件构建时间线,再从 lastSeq 开始订阅事件流。',
+        query: [
+          { name: 'after', type: 'number', desc: '只返回 seq 大于它的事件' },
+          { name: 'limit', type: 'number', desc: '默认 5000,最多 5000' },
+        ],
+        response: { status: 200, type: '{ items: TeamEvent[], lastSeq }' },
+      },
+      {
+        method: 'GET', path: team('/stream'), summary: '订阅事件流(SSE)',
+        desc: '先补发 seq 大于 `Last-Event-ID`(或 after 参数)的事件,然后实时推送;每 15 秒发送一次注释行保持连接。',
+        query: [{ name: 'after', type: 'number', desc: '不能设置请求头时使用,等同于 Last-Event-ID' }],
+        response: { status: 200, type: 'text/event-stream', desc: '每条为 `id: <seq>`、`event: <type>`、`data: <TeamEvent JSON>`' },
+      },
+      {
+        method: 'POST', path: team('/interactions/:iid'), summary: '回复审批或提问',
+        desc: '机器人触发审批或提问时,事件流中出现 interaction.required;由成员在这里回复。超时按拒绝处理。',
+        body: [
+          { name: 'decision', type: '"allow_once" | "allow_session" | "deny"', desc: '审批的决定' },
+          { name: 'reason', type: 'string', desc: '拒绝的理由(可选)' },
+          { name: 'answers', type: 'Record<问题 id, string | string[]>', desc: '提问的回答' },
+        ],
+        response: { status: 200, type: '{ ok: true }' },
+        sampleBody: { decision: 'allow_once' },
+        errors: ['409 already_resolved:已被回复、超时或取消', '404 not_found:交互不属于这个工作组'],
+      },
+    ],
+  },
+  {
+    id: 'files',
+    title: '文件',
+    endpoints: [
+      {
+        method: 'POST', path: team('/files'), summary: '上传文件',
+        bodyNote: '`multipart/form-data`,字段名任意,可以一次上传多个文件(最多 20 个)。文件保存到共享目录的 uploads/ 下,重名时自动加序号。',
+        response: { status: 201, type: 'File[]' },
+        errors: ['413 file_too_large:超过大小上限', '409 team_closed:工作组已关闭'],
+      },
+      { method: 'GET', path: team('/files'), summary: '共享目录中的文件', desc: '扫描后返回,包括机器人的中间文件。', response: { status: 200, type: 'File[]' } },
+      {
+        method: 'GET', path: '/bot/v1/files/:id', summary: '下载文件',
+        desc: '只能下载本平台工作组中的文件。预览 HTML / SVG 时禁止执行脚本。',
+        query: [{ name: 'inline', type: '1', desc: '以 inline 方式返回(用于预览)' }],
+        response: { status: 200, type: '文件内容' },
+      },
+    ],
+  },
+]
+
+export const BOT_TYPES: ApiTypeDef[] = [
+  {
+    name: 'BrowserToken', fields: [
+      { name: 'token', type: 'string', desc: '以 bt_ 开头,只能用于 /bot/v1' },
+      { name: 'expiresAt', type: 'string', desc: '过期时间(ISO 8601)' },
+    ],
+  },
+  {
+    name: 'BotSummary', fields: [
+      { name: 'id', type: 'string', desc: 'bot_ 开头' },
+      { name: 'name / avatar', type: 'string', desc: '名称(唯一,不含空格和 @)与头像(emoji)' },
+      { name: 'kind', type: '"lead" | "worker"', desc: '组长 / 组员' },
+      { name: 'description', type: 'string', desc: '岗位描述' },
+      { name: 'provider / model', type: 'string | null', desc: '所用引擎与模型' },
+    ],
+  },
+  {
+    name: 'Team', fields: [
+      { name: 'id', type: 'string', desc: 'team_ 开头' },
+      { name: 'title', type: 'string', desc: '' },
+      { name: 'human', type: '{ id, name }', desc: '成员(人)' },
+      { name: 'status', type: '"active" | "paused" | "closed"', desc: '进行中 / 已暂停 / 已关闭' },
+      { name: 'bots', type: 'TeamBot[]', desc: '组长排在最前面' },
+      { name: 'lastSeq', type: 'number', desc: '最后一个事件的 seq' },
+      { name: 'createdAt / updatedAt', type: 'string', desc: 'ISO 8601' },
+    ],
+  },
+  {
+    name: 'TeamBot', desc: 'BotSummary 的全部字段,另外包括:', fields: [
+      { name: 'role', type: '"lead" | "worker"', desc: '在本组中的角色' },
+      { name: 'status', type: '"idle" | "queued" | "working" | "waiting_input"', desc: '空闲 / 排队 / 工作中 / 等待成员处理交互' },
+      { name: 'sessionId', type: 'string | null', desc: '它在本组的会话(还没工作过时为 null)' },
+      { name: 'joinedAt', type: 'string', desc: '' },
+    ],
+  },
+  {
+    name: 'TeamDetail', desc: 'Team 的全部字段,另外包括:', fields: [
+      { name: 'pendingInteractions', type: '{ botId, sessionId, request: InteractionRequest }[]', desc: '未处理的交互' },
+    ],
+  },
+  {
+    name: 'TeamMessage', fields: [
+      { name: 'id', type: 'string', desc: 'tmsg_ 开头' },
+      { name: 'sender', type: 'object', desc: '`{ kind: "human", id, name }`、`{ kind: "bot", botId, name, avatar }` 或 `{ kind: "system" }`' },
+      { name: 'text', type: 'string', desc: '机器人消息为 Markdown' },
+      { name: 'mentions', type: 'string[]', desc: '被 @ 的机器人' },
+      { name: 'targets', type: 'string[]', desc: '这条消息唤醒的机器人' },
+      { name: 'files', type: '{ fileId, path, mime, size }[]', desc: '附件或交付文件' },
+      { name: 'runId / sessionId', type: 'string?', desc: '机器人消息:产生它的 Run 与会话' },
+      { name: 'level', type: '"info" | "warning" | "error"', desc: '系统消息的级别' },
+    ],
+  },
+  {
+    name: 'TeamEvent', desc: '工作组事件的信封', fields: [
+      { name: 'seq', type: 'number', desc: '工作组内单调递增,即 SSE 的 id' },
+      { name: 'type', type: 'string', desc: '事件类型,见"事件类型"' },
+      { name: 'teamId', type: 'string', desc: '' },
+      { name: 'ts', type: 'string', desc: 'ISO 8601' },
+      { name: 'payload', type: 'object', desc: '随类型不同' },
+    ],
+  },
+  {
+    name: 'File', fields: [
+      { name: 'id', type: 'string', desc: 'file_ 开头' },
+      { name: 'path', type: 'string', desc: '共享目录中的相对路径,如 uploads/销售.xlsx、outputs/报告.docx' },
+      { name: 'origin', type: '"upload" | "agent"', desc: '成员上传 / 机器人产出' },
+      { name: 'mime / size', type: 'string / number', desc: '' },
+    ],
+  },
+  {
+    name: 'InteractionRequest', desc: '与 Agent API 相同:kind 为 approval(审批)或 question(提问)', fields: [
+      { name: 'interactionId', type: 'string', desc: 'itx_ 开头' },
+      { name: 'kind', type: '"approval" | "question"', desc: '' },
+      { name: 'expiresAt', type: 'string', desc: '超时时间,超时按拒绝处理' },
+      { name: 'tool / title / input / detail / options', type: 'approval', desc: '要执行的工具、可读摘要、原始参数、详情、可选的决定' },
+      { name: 'questions', type: 'question', desc: '[{ id, question, header?, multiSelect, options: [{ label, description? }] }]' },
+    ],
+  },
+]
+
+export const BOT_EVENTS: ApiEventDef[] = [
+  { type: 'message.created', payload: 'TeamMessage', desc: '新消息:成员的命令、机器人的回复(交付文件在 files 中)、系统消息(成员变化、执行失败、暂停等)', persisted: true },
+  { type: 'interaction.required', payload: '{ botId, sessionId, request: InteractionRequest }', desc: '机器人需要成员审批或回答问题', persisted: true },
+  { type: 'interaction.resolved', payload: '{ botId, sessionId, interactionId, resolution, answers?, reason? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled', persisted: true },
+  { type: 'bot.status', payload: '{ botId, status, sessionId, runId }', desc: '机器人状态变化:idle / queued / working / waiting_input', persisted: false },
+  { type: 'file.changed', payload: '{ file: File, kind: "created" | "modified" }', desc: '共享目录中新建或修改了文件', persisted: false },
+  { type: 'team.updated', payload: 'Team', desc: '工作组的标题、状态或成员变化', persisted: false },
+]
+
+export const BOT_ERRORS: ApiErrorDef[] = [
+  { status: 400, code: 'bad_request', desc: '参数不正确(details 中有逐项说明)' },
+  { status: 400, code: 'mention_required', desc: '工作组有多个组员且没有组长,消息必须 @ 机器人' },
+  { status: 401, code: 'unauthorized', desc: '缺少 Authorization' },
+  { status: 401, code: 'invalid_api_key', desc: 'Bot Key 无效或已停用(Agent Key 也会得到这个错误)' },
+  { status: 401, code: 'invalid_token', desc: '浏览器令牌无效、过期、不是 Bot 令牌,或签发它的 Bot Key 已停用' },
+  { status: 403, code: 'forbidden', desc: '无权执行该操作' },
+  { status: 403, code: 'bot_not_allowed', desc: '机器人不对本平台开放' },
+  { status: 404, code: 'not_found', desc: '资源不存在,或不属于本平台' },
+  { status: 409, code: 'team_closed', desc: '工作组已关闭' },
+  { status: 409, code: 'invalid_status', desc: '工作组当前状态不能执行这个操作' },
+  { status: 409, code: 'already_member', desc: '机器人已经在工作组中' },
+  { status: 409, code: 'team_busy', desc: '删除时仍有机器人在执行' },
+  { status: 409, code: 'already_resolved', desc: '交互已被回复、超时或取消' },
+  { status: 413, code: 'file_too_large', desc: '上传文件超过大小上限' },
+  { status: 500, code: 'internal_error', desc: '服务器内部错误' },
+]

+ 25 - 12
apps/server/src/api-reference/reference.ts

@@ -1,15 +1,17 @@
-// 对外 API(/v1)协议参考:后台"API 协议"页面与 PDF 的唯一数据来源。
+// 对外 API 协议参考:后台"API 协议"页面与 PDF 的唯一数据来源。包括 Agent API(/v1,本文件)与 Bot API(/bot/v1,bot-reference.ts)。
 // test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /v1 路由完全一致,新增或删除接口时必须同步修改。
 
-import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiTypeDef } from '@agent-server/shared'
+import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiSpec, ApiTypeDef } from '@agent-server/shared'
+import { BOT_ERRORS, BOT_EVENTS, BOT_GROUPS, BOT_OVERVIEW, BOT_TYPES } from './bot-reference'
 
 export type { ApiEndpoint as Endpoint, ApiField as Field } from '@agent-server/shared'
 
 export const API_OVERVIEW: ApiOverview = {
   points: [
     '**基础地址**:`$BASE`,所有接口位于 `/v1` 下;请求和响应为 JSON(UTF-8),时间为 ISO 8601。',
-    '**鉴权**:`Authorization: Bearer <凭证>`。**API Key**(`ask_…`)每个业务平台一个,在后台签发,只在平台后端使用;**浏览器令牌**(`bt_…`)由平台后端调用 `POST /v1/browser-tokens` 换取,交给浏览器使用,默认 1 小时有效。',
-    '**可见范围**:每个 API Key 只能使用后台为它勾选的 Profile,只能看到自己创建的会话。',
+    '**鉴权**:`Authorization: Bearer <凭证>`。**Agent Key**(`ask_…`)每个业务平台一个,在后台签发,只在平台后端使用;**浏览器令牌**(`bt_…`)由平台后端调用 `POST /v1/browser-tokens` 换取,交给浏览器使用,默认 1 小时有效。',
+    '**可见范围**:每个 Agent Key 只能使用后台为它勾选的 Profile,只能看到自己创建的会话。',
+    '**两种 Key 互不通用**:Agent Key 只能调用 `/v1`;机器人与工作组使用 Bot API(`/bot/v1`)和单独签发的 Bot Key(`bk_…`),见"Bot API"。',
     '**ID 前缀**:会话 `ses_`、Run `run_`、交互 `itx_`、文件 `file_`、Profile `prf_`。',
     '**错误格式**:`{ "error": { "code": "session_busy", "message": "…", "details": {} } }`,错误码见最后一节。',
     '**跨域**:浏览器直接调用时,需在后台"设置 › 安全 › CORS 来源"中加入平台地址;请求不携带 Cookie。',
@@ -28,17 +30,17 @@ export const API_GROUPS: ApiEndpointGroup[] = [
   {
     id: 'auth',
     title: '令牌',
-    desc: '业务平台的后端用 API Key 换取短期浏览器令牌,交给前端使用;API Key 不要出现在前端代码中。',
+    desc: '业务平台的后端用 Agent Key 换取短期浏览器令牌,交给前端使用;Agent Key 不要出现在前端代码中。',
     endpoints: [
       {
         method: 'POST', path: '/v1/browser-tokens', summary: '签发浏览器令牌',
-        desc: '只能使用 API Key 调用(浏览器令牌不能再签发令牌)。令牌的权限与签发它的 API Key 相同;停用或删除 API Key 后,它签发的全部令牌立即失效。',
+        desc: '只能使用 Agent Key 调用(浏览器令牌不能再签发令牌)。令牌的权限与签发它的 Agent Key 相同;停用或删除 Agent Key 后,它签发的全部令牌立即失效。',
         body: [{ name: 'ttlSeconds', type: 'number', desc: '有效期(秒),60 ~ 43200,默认 3600' }],
         response: { status: 201, type: 'BrowserToken' },
         sampleBody: { ttlSeconds: 600 },
         errors: ['400 bad_request:ttlSeconds 超出范围', '403 forbidden:使用浏览器令牌调用'],
         example: `curl -X POST $BASE/v1/browser-tokens \\
-  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \\
+  -H "Authorization: Bearer $AGENT_KEY" -H "Content-Type: application/json" \\
   -d '{"ttlSeconds":3600}'
 # → 201 {"token":"bt_…","expiresAt":"2026-10-23T14:00:00.000Z"}`,
       },
@@ -50,7 +52,7 @@ export const API_GROUPS: ApiEndpointGroup[] = [
     endpoints: [
       {
         method: 'GET', path: '/v1/profiles', summary: '本平台可用的 Profile',
-        desc: '只返回在后台为该 API Key 勾选的 Profile。isDefault 表示该平台的默认 Profile(创建会话不指定 Profile 时使用)。',
+        desc: '只返回在后台为该 Agent Key 勾选的 Profile。isDefault 表示该平台的默认 Profile(创建会话不指定 Profile 时使用)。',
         response: { status: 200, type: 'ProfileSummary[]' },
       },
       {
@@ -63,7 +65,7 @@ export const API_GROUPS: ApiEndpointGroup[] = [
   {
     id: 'sessions',
     title: '会话',
-    desc: '一个会话对应一个独立的工作区和一段连续的对话。会话归属于创建它的 API Key,其他平台看不到。',
+    desc: '一个会话对应一个独立的工作区和一段连续的对话。会话归属于创建它的 Agent Key,其他平台看不到。',
     endpoints: [
       {
         method: 'POST', path: '/v1/sessions', summary: '创建会话',
@@ -225,7 +227,7 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
     endpoints: [
       {
         method: 'GET', path: '/v1/usage', summary: '本平台的用量',
-        desc: '只统计调用者所属 API Key 的会话;删除会话不影响历史用量。日期为服务器本地日期。',
+        desc: '只统计调用者所属 Agent Key 的会话;删除会话不影响历史用量。日期为服务器本地日期。',
         query: [{ name: 'days', type: 'number', desc: '最近几天(含今天),1 ~ 366,默认 30' }],
         response: { status: 200, type: 'UsageSummary' },
       },
@@ -365,8 +367,8 @@ export const API_EVENTS: ApiEventDef[] = [
 export const API_ERRORS: ApiErrorDef[] = [
   { status: 400, code: 'bad_request', desc: '参数不正确(details 中有逐项说明)' },
   { status: 401, code: 'unauthorized', desc: '缺少 Authorization' },
-  { status: 401, code: 'invalid_api_key', desc: 'API Key 无效或已停用' },
-  { status: 401, code: 'invalid_token', desc: '浏览器令牌无效、过期,或签发它的 API Key 已停用(SDK 会自动刷新一次)' },
+  { status: 401, code: 'invalid_api_key', desc: 'Agent Key 无效或已停用' },
+  { status: 401, code: 'invalid_token', desc: '浏览器令牌无效、过期,或签发它的 Agent Key 已停用(SDK 会自动刷新一次)' },
   { status: 403, code: 'forbidden', desc: '无权执行该操作' },
   { status: 403, code: 'profile_not_allowed', desc: 'Profile 不对本平台开放' },
   { status: 404, code: 'not_found', desc: '资源不存在,或不属于本平台' },
@@ -376,3 +378,14 @@ export const API_ERRORS: ApiErrorDef[] = [
   { status: 413, code: 'file_too_large', desc: '上传文件超过大小上限' },
   { status: 500, code: 'internal_error', desc: '服务器内部错误' },
 ]
+
+export const API_SPECS: ApiSpec[] = [
+  {
+    id: 'agent', title: 'Agent API', summary: '`/v1`:会话、消息与 Agent 执行;使用 Agent Key(`ask_…`)',
+    overview: API_OVERVIEW, groups: API_GROUPS, eventEnvelope: 'AgentEvent', types: API_TYPES, events: API_EVENTS, errors: API_ERRORS,
+  },
+  {
+    id: 'bot', title: 'Bot API', summary: '`/bot/v1`:机器人与工作组;使用 Bot Key(`bk_…`)',
+    overview: BOT_OVERVIEW, groups: BOT_GROUPS, eventEnvelope: 'TeamEvent', types: BOT_TYPES, events: BOT_EVENTS, errors: BOT_ERRORS,
+  },
+]

+ 54 - 33
apps/server/src/api-reference/render.ts

@@ -1,5 +1,6 @@
-// 把 API 协议参考渲染为独立的 HTML 文档(不引用任何外部资源):后台页面内嵌显示,也用于生成 PDF
-import { API_ERRORS, API_EVENTS, API_GROUPS, API_OVERVIEW, API_TYPES, type Endpoint, type Field } from './reference'
+// 把 API 协议参考渲染为独立的 HTML 文档(不引用任何外部资源):新窗口打开与生成 PDF。两套协议(Agent API、Bot API)各为一部分
+import type { ApiSpec } from '@agent-server/shared'
+import { API_SPECS, type Endpoint, type Field } from './reference'
 
 export interface RenderOptions {
   /** 文档中示例使用的服务地址 */
@@ -20,6 +21,7 @@ const errorItem = (x: string) => {
   return m ? `<li><b>${m[1]}</b> <code>${m[2]}</code>${inline(m[3]!)}</li>` : `<li>${inline(x)}</li>`
 }
 const anchor = (e: Endpoint) => `${e.method}-${e.path}`.toLowerCase().replace(/[^a-z0-9]+/g, '-')
+const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-')
 
 function fieldTable(fields: Field[], withRequired = true) {
   return `<table class="fields"><thead><tr><th>字段</th><th>类型</th>${withRequired ? '<th>必填</th>' : ''}<th>说明</th></tr></thead><tbody>${
@@ -40,16 +42,53 @@ function endpoint(e: Endpoint, base: string) {
 </section>`
 }
 
+/** 一部分的章节:概述、各接口分组、事件类型、数据结构、错误码(编号为"部分.节") */
+function sections(spec: ApiSpec) {
+  return [
+    ['overview', '概述'],
+    ...spec.groups.map(g => [g.id, g.title]),
+    ['event-types', '事件类型'],
+    ['types', '数据结构'],
+    ['errors', '错误码'],
+  ].map(([id, title], k) => ({ id: `${spec.id}-${id}`, title: title!, no: k + 1 }))
+}
+
+function specHtml(spec: ApiSpec, part: number, base: string) {
+  const sec = sections(spec)
+  const no = (id: string) => `${part}.${sec.find(x => x.id === `${spec.id}-${id}`)!.no}`
+  return `<section class="part" id="${spec.id}">
+<h2 class="part-title">第 ${part} 部分 · ${esc(spec.title)}</h2>
+<p class="group-desc">${inline(spec.summary)}</p>
+
+<h2 id="${spec.id}-overview">${no('overview')} 概述</h2>
+<ul>${spec.overview.points.map(x => `<li>${marked(x, base)}</li>`).join('')}</ul>
+<h4>典型流程</h4>
+<ol>${spec.overview.flow.map(x => `<li>${marked(x, base)}</li>`).join('')}</ol>
+
+${spec.groups.map(g => `<h2 id="${spec.id}-${g.id}">${no(g.id)} ${esc(g.title)}</h2>
+${g.desc ? `<p class="group-desc">${inline(g.desc)}</p>` : ''}
+${g.endpoints.map(e => endpoint(e, base)).join('\n')}`).join('\n')}
+
+<h2 id="${spec.id}-event-types">${no('event-types')} 事件类型</h2>
+<p>事件信封见"数据结构 › ${esc(spec.eventEnvelope)}"。"落库"为否的事件只在实时事件流中推送,不出现在历史事件中。</p>
+<table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead><tbody>
+${spec.events.map(e => `<tr><td><code>${esc(e.type)}</code></td><td><code class="type">${esc(e.payload)}</code></td><td>${inline(e.desc)}</td><td>${e.persisted ? '是' : '<span class="no">否</span>'}</td></tr>`).join('\n')}
+</tbody></table>
+
+<h2 id="${spec.id}-types">${no('types')} 数据结构</h2>
+${spec.types.map(t => `<h3 id="${spec.id}-type-${slug(t.name)}"><code class="path">${esc(t.name)}</code></h3>
+${t.desc ? `<p class="group-desc">${inline(t.desc)}</p>` : ''}${fieldTable(t.fields, false)}`).join('\n')}
+
+<h2 id="${spec.id}-errors">${no('errors')} 错误码</h2>
+<table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead><tbody>
+${spec.errors.map(e => `<tr><td>${e.status}</td><td><code>${esc(e.code)}</code></td><td>${inline(e.desc)}</td></tr>`).join('\n')}
+</tbody></table>
+</section>`
+}
+
 export function renderApiReferenceHtml(o: RenderOptions): string {
   const base = o.baseUrl.replace(/\/+$/, '')
   const date = o.generatedAt.toISOString().slice(0, 10)
-  const toc = [
-    ['overview', '1. 概述'],
-    ...API_GROUPS.map((g, i) => [g.id, `${i + 2}. ${g.title}`]),
-    ['event-types', `${API_GROUPS.length + 2}. 事件类型`],
-    ['types', `${API_GROUPS.length + 3}. 数据结构`],
-    ['errors', `${API_GROUPS.length + 4}. 错误码`],
-  ]
   return `<!doctype html>
 <html lang="zh-CN" data-theme="${o.theme}">
 <head>
@@ -90,6 +129,8 @@ code.path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
 .status { font-weight: 700; }
 .group-desc { color: var(--muted); }
 .no { color: var(--muted); }
+.part-title { font-size: 22px; border-bottom: 2px solid var(--fg); margin-top: 48px; }
+.part:first-of-type .part-title { margin-top: 24px; }
 @page { size: A4; margin: 14mm 12mm; }
 @media print {
   :root, [data-theme="dark"] { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --code-bg:#f6f8fa; --link:#0969da;
@@ -97,6 +138,7 @@ code.path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
   body { padding: 0; max-width: none; font-size: 11.5px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
   .endpoint, table, pre { break-inside: avoid; }
   h2 { break-after: avoid; }
+  .part + .part { break-before: page; }
 }
 </style>
 </head>
@@ -105,31 +147,10 @@ code.path { font-size: 13px; background: none; padding: 0; font-weight: 600; }
 <div class="meta">版本 ${esc(o.version)} · 服务地址 <code>${esc(base)}</code> · 生成于 ${date}</div>
 
 <h2>目录</h2>
-<ol class="toc">${toc.map(([id, t]) => `<li><a href="#${id}">${esc(t!.replace(/^\d+\. /, ''))}</a></li>`).join('')}</ol>
-
-<h2 id="overview">1. 概述</h2>
-<ul>${API_OVERVIEW.points.map(x => `<li>${marked(x, base)}</li>`).join('')}</ul>
-<h4>典型流程</h4>
-<ol>${API_OVERVIEW.flow.map(x => `<li>${marked(x, base)}</li>`).join('')}</ol>
-
-${API_GROUPS.map((g, i) => `<h2 id="${g.id}">${i + 2}. ${esc(g.title)}</h2>
-${g.desc ? `<p class="group-desc">${inline(g.desc)}</p>` : ''}
-${g.endpoints.map(e => endpoint(e, base)).join('\n')}`).join('\n')}
+${API_SPECS.map((spec, p) => `<h4><a href="#${spec.id}">第 ${p + 1} 部分 · ${esc(spec.title)}</a></h4>
+<ol class="toc">${sections(spec).map(x => `<li><a href="#${x.id}">${esc(x.title)}</a></li>`).join('')}</ol>`).join('\n')}
 
-<h2 id="event-types">${API_GROUPS.length + 2}. 事件类型</h2>
-<p>事件信封见"数据结构 › AgentEvent"。"落库"为否的事件只在实时事件流中推送,历史事件中只保留对应的 completed 事件。</p>
-<table><thead><tr><th>type</th><th>payload</th><th>说明</th><th>落库</th></tr></thead><tbody>
-${API_EVENTS.map(e => `<tr><td><code>${esc(e.type)}</code></td><td><code class="type">${esc(e.payload)}</code></td><td>${inline(e.desc)}</td><td>${e.persisted ? '是' : '<span class="no">否</span>'}</td></tr>`).join('\n')}
-</tbody></table>
-
-<h2 id="types">${API_GROUPS.length + 3}. 数据结构</h2>
-${API_TYPES.map(t => `<h3 id="type-${t.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}"><code class="path">${esc(t.name)}</code></h3>
-${t.desc ? `<p class="group-desc">${inline(t.desc)}</p>` : ''}${fieldTable(t.fields, false)}`).join('\n')}
-
-<h2 id="errors">${API_GROUPS.length + 4}. 错误码</h2>
-<table><thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead><tbody>
-${API_ERRORS.map(e => `<tr><td>${e.status}</td><td><code>${esc(e.code)}</code></td><td>${inline(e.desc)}</td></tr>`).join('\n')}
-</tbody></table>
+${API_SPECS.map((spec, p) => specHtml(spec, p + 1, base)).join('\n')}
 </body>
 </html>`
 }

+ 14 - 1
apps/server/src/app.ts

@@ -29,6 +29,9 @@ import { CustomSkillService } from './custom-skills/service'
 import { SkillSourceService } from './custom-skills/sources'
 import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
+import { BotKeyService, BotService } from './teams/bots'
+import { TeamService } from './teams/service'
+import { forgetGatewaySession } from './mcp-gateway/server'
 
 export interface AppLogger {
   info(o: object, m?: string): void
@@ -75,8 +78,18 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   )
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, mcpServers, customSkills, gatewayTokens, providers })
+  const bots = new BotService(db, profiles)
+  const botKeys = new BotKeyService(db, bots)
+  usage.extraSourceNames = () => botKeys.names()
+  const teams = new TeamService({
+    db, log, settings, profiles, sessions, runs, files, events, interactions, bots, botKeys,
+    forgetSession: id => {
+      gatewayTokens.forgetSession(id)
+      forgetGatewaySession(id)
+    },
+  })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams }
 }
 
 export type App = ReturnType<typeof createApp>

+ 63 - 0
apps/server/src/db/schema.ts

@@ -246,3 +246,66 @@ export const skillSources = sqliteTable('skill_sources', {
   createdAt: createdAt(),
   updatedAt: updatedAt(),
 })
+
+/** 机器人(docs/design/13-bot-teams.md):身份 + 关联的 Profile */
+export const bots = sqliteTable('bots', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull().unique(),
+  avatar: text('avatar').notNull().default('🤖'),
+  kind: text('kind', { enum: ['lead', 'worker'] }).notNull(),
+  description: text('description').notNull().default(''),
+  profileId: text('profile_id').notNull(),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
+/** Bot API 的密钥(ADR-0024),与 api_keys 互不通用 */
+export const botApiKeys = sqliteTable('bot_api_keys', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull(),
+  keyHash: text('key_hash').notNull().unique(),
+  keyPrefix: text('key_prefix').notNull(),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  allowedBotIds: text('allowed_bot_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  createdAt: createdAt(),
+  lastUsedAt: text('last_used_at'),
+})
+
+export const teams = sqliteTable('teams', {
+  id: text('id').primaryKey(),
+  /** Bot Key id,或后台的 'admin' */
+  source: text('source').notNull(),
+  title: text('title').notNull().default(''),
+  human: text('human', { mode: 'json' }).$type<{ id: string; name: string }>().notNull(),
+  status: text('status', { enum: ['active', 'paused', 'closed'] }).notNull().default('active'),
+  /** 共享目录:组内所有机器人会话的工作目录 */
+  workspaceDir: text('workspace_dir').notNull(),
+  lastSeq: integer('last_seq').notNull().default(0),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+}, t => [index('teams_source_idx').on(t.source, t.updatedAt)])
+
+export const teamBots = sqliteTable('team_bots', {
+  teamId: text('team_id').notNull(),
+  botId: text('bot_id').notNull(),
+  role: text('role', { enum: ['lead', 'worker'] }).notNull(),
+  joinedAt: text('joined_at').notNull().default(now),
+}, t => [primaryKey({ columns: [t.teamId, t.botId] }), index('team_bots_bot_idx').on(t.botId)])
+
+/** 每个"机器人 × 工作组"一个引擎会话;离开工作组后保留,再加入时续接 */
+export const teamBotSessions = sqliteTable('team_bot_sessions', {
+  teamId: text('team_id').notNull(),
+  botId: text('bot_id').notNull(),
+  sessionId: text('session_id').notNull().unique(),
+  /** 已交给这个机器人的工作组事件 seq */
+  lastReadSeq: integer('last_read_seq').notNull().default(0),
+}, t => [primaryKey({ columns: [t.teamId, t.botId] })])
+
+export const teamEvents = sqliteTable('team_events', {
+  teamId: text('team_id').notNull(),
+  seq: integer('seq').notNull(),
+  type: text('type').notNull(),
+  payload: text('payload', { mode: 'json' }).notNull(),
+  ts: text('ts').notNull(),
+}, t => [primaryKey({ columns: [t.teamId, t.seq] })])

+ 31 - 7
apps/server/src/http/auth.ts

@@ -5,12 +5,12 @@ import { BrowserTokenService } from '../services/browser-tokens'
 
 export const ADMIN_COOKIE = 'agent_admin'
 
-/** 调用方:上层平台(API Key)或后台管理员 */
+/** 调用方:上层平台(Agent Key / Bot Key)或后台管理员 */
 export interface Principal {
-  kind: 'apikey' | 'admin'
-  /** 写入 sessions.source:API Key id 或 'admin' */
+  kind: 'apikey' | 'botkey' | 'admin'
+  /** 写入 sessions.source / teams.source:Key id 或 'admin' */
   source: string
-  /** 使用浏览器令牌(由 API Key 签发)调用:权限与 Key 相同,但不能再签发令牌 */
+  /** 使用浏览器令牌(由 Key 签发)调用:权限与 Key 相同,但不能再签发令牌 */
   browser?: boolean
 }
 
@@ -29,7 +29,7 @@ export function isAdmin(app: App, req: FastifyRequest) {
   return app.adminAuth.validate(req.cookies[ADMIN_COOKIE])
 }
 
-/** /v1:API Key 或后台登录态均可 */
+/** /v1:Agent Key(及其浏览器令牌)或后台登录态;Bot Key 与 Bot 浏览器令牌一律拒绝(ADR-0024) */
 export function requireClient(app: App) {
   return async (req: FastifyRequest, _reply: FastifyReply) => {
     const key = bearer(req)
@@ -41,7 +41,7 @@ export function requireClient(app: App) {
     }
     if (key) {
       const id = app.apiKeys.verify(key)
-      if (!id) throw new AppError(401, 'invalid_api_key', 'API Key 无效或已停用')
+      if (!id) throw new AppError(401, 'invalid_api_key', 'Agent Key 无效或已停用')
       req.principal = { kind: 'apikey', source: id }
       return
     }
@@ -49,7 +49,31 @@ export function requireClient(app: App) {
       req.principal = { kind: 'admin', source: 'admin' }
       return
     }
-    throw new AppError(401, 'unauthorized', '缺少 API Key')
+    throw new AppError(401, 'unauthorized', '缺少 Agent Key')
+  }
+}
+
+/** /bot/v1:Bot Key(及其浏览器令牌)或后台登录态;Agent Key 与它的浏览器令牌一律拒绝(ADR-0024) */
+export function requireBotClient(app: App) {
+  return async (req: FastifyRequest, _reply: FastifyReply) => {
+    const key = bearer(req)
+    if (key && BrowserTokenService.isBrowserToken(key)) {
+      const id = app.browserTokens.verify(key, Date.now(), 'bot')
+      if (!id || !app.botKeys.isActive(id)) throw new AppError(401, 'invalid_token', '浏览器令牌无效或已过期')
+      req.principal = { kind: 'botkey', source: id, browser: true }
+      return
+    }
+    if (key) {
+      const id = app.botKeys.verify(key)
+      if (!id) throw new AppError(401, 'invalid_api_key', 'Bot Key 无效或已停用')
+      req.principal = { kind: 'botkey', source: id }
+      return
+    }
+    if (isAdmin(app, req)) {
+      req.principal = { kind: 'admin', source: 'admin' }
+      return
+    }
+    throw new AppError(401, 'unauthorized', '缺少 Bot Key')
   }
 }
 

+ 70 - 13
apps/server/src/http/routes/admin.ts

@@ -13,7 +13,7 @@ import { parse, type AuditKind, type KnowledgeInput } from '../../services/catal
 import { defaultSettings } from '../../services/settings'
 import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
 import { providerStatuses } from './v1'
-import { API_ERRORS, API_EVENTS, API_GROUPS, API_OVERVIEW, API_TYPES } from '../../api-reference/reference'
+import { API_SPECS } from '../../api-reference/reference'
 import { renderApiReferenceHtml } from '../../api-reference/render'
 import { APP_VERSION } from '../../config'
 
@@ -95,9 +95,7 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.get('/api-reference/meta', async () => ({ pdfAvailable: app.apiPdf.available }))
     // 后台页面用同一份协议定义自行渲染(可以在每个接口旁调试);HTML 版本用于 PDF 与新窗口打开
-    g.get('/api-reference.json', async () => ({
-      version: `v1 · 服务 ${APP_VERSION}`, overview: API_OVERVIEW, groups: API_GROUPS, types: API_TYPES, events: API_EVENTS, errors: API_ERRORS,
-    }))
+    g.get('/api-reference.json', async () => ({ version: `v1 · 服务 ${APP_VERSION}`, specs: API_SPECS }))
     g.get<RefQuery>('/api-reference.html', async (req, reply) => {
       reply.header('Content-Type', 'text/html; charset=utf-8')
       // 文档不需要脚本
@@ -402,7 +400,9 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.delete<IdParams>('/profiles/:id', async (req, reply) => {
       const refs = app.apiKeys.referencing(req.params.id)
-      if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 API Key 使用:${refs.join('、')},请先在 API Keys 页面取消勾选`)
+      if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 Agent Key 使用:${refs.join('、')},请先在 Agent Keys 页面取消勾选`)
+      const botRefs = app.bots.referencing(req.params.id)
+      if (botRefs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被机器人使用:${botRefs.join('、')},请先在机器人页面修改`)
       app.profiles.delete(req.params.id)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Profile:${req.params.id}` })
       reply.code(204)
@@ -439,39 +439,96 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       reply.code(204)
     })
 
-    // API Key
+    // Agent Key
     g.get('/api-keys', async () => app.apiKeys.list())
     g.post('/api-keys', async (req, reply) => {
       const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
       reply.code(201)
       const name = body.name
       const r = app.apiKeys.create(name, body)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 API Key:${name}` })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 Agent Key:${name}` })
       return r
     })
     g.patch<IdParams>('/api-keys/:id', async req => {
       const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
       app.apiKeys.setEnabled(req.params.id, enabled)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} API Key:${req.params.id}` })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} Agent Key:${req.params.id}` })
       return { ok: true }
     })
     g.put<IdParams>('/api-keys/:id/profiles', async req => {
       const body = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
       app.apiKeys.setProfiles(req.params.id, body)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 API Key 可用的 Profile:${req.params.id}`, detail: body })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Agent Key 可用的 Profile:${req.params.id}`, detail: body })
       return { ok: true }
     })
-    // API 协议页面调试:以某个平台的身份调用 /v1,签发 10 分钟的浏览器令牌(API Key 本身不离开服务端)
+    // API 协议页面调试:以某个平台的身份调用 /v1,签发 10 分钟的浏览器令牌(Agent Key 本身不离开服务端)
     g.post<IdParams>('/api-keys/:id/debug-token', async req => {
       const key = app.apiKeys.list().find(k => k.id === req.params.id)
-      if (!key) throw notFound('API Key')
-      if (!key.enabled) throw new AppError(400, 'bad_request', `API Key「${key.name}」已停用`)
+      if (!key) throw notFound('Agent Key')
+      if (!key.enabled) throw new AppError(400, 'bad_request', `Agent Key「${key.name}」已停用`)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试接口` })
       return app.browserTokens.issue(key.id, 600)
     })
     g.delete<IdParams>('/api-keys/:id', async (req, reply) => {
       app.apiKeys.delete(req.params.id)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 API Key:${req.params.id}` })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Agent Key:${req.params.id}` })
+      reply.code(204)
+    })
+
+    // 机器人与 Bot Key(docs/design/13-bot-teams.md §12.1)
+    const botSchema = z.object({
+      name: z.string(), avatar: z.string(), kind: z.enum(['lead', 'worker']), description: z.string(), profileId: z.string(), enabled: z.boolean(),
+    })
+    g.get('/bots', async () => app.bots.list())
+    g.post('/bots', async (req, reply) => {
+      const b = app.bots.create(parse(botSchema, req.body))
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `新建机器人:${b.name}` })
+      reply.code(201)
+      return b
+    })
+    g.put<IdParams>('/bots/:id', async req => {
+      const b = app.bots.update(req.params.id, parse(botSchema, req.body))
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改机器人:${b.name}` })
+      return b
+    })
+    g.delete<IdParams>('/bots/:id', async (req, reply) => {
+      const b = app.bots.get(req.params.id)
+      app.bots.delete(b.id)
+      app.botKeys.forgetBot(b.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除机器人:${b.name}` })
+      reply.code(204)
+    })
+    g.get('/bot-keys', async () => app.botKeys.list())
+    g.post('/bot-keys', async (req, reply) => {
+      const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedBotIds: z.array(z.string()) }), req.body)
+      const r = app.botKeys.create(body.name, body.allowedBotIds)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 Bot Key:${body.name}` })
+      reply.code(201)
+      return r
+    })
+    g.patch<IdParams>('/bot-keys/:id', async req => {
+      const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
+      app.botKeys.setEnabled(req.params.id, enabled)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} Bot Key:${req.params.id}` })
+      return { ok: true }
+    })
+    g.put<IdParams>('/bot-keys/:id/bots', async req => {
+      const { allowedBotIds } = parse(z.object({ allowedBotIds: z.array(z.string()) }), req.body)
+      app.botKeys.setBots(req.params.id, allowedBotIds)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Bot Key 可用的机器人:${req.params.id}`, detail: { allowedBotIds } })
+      return { ok: true }
+    })
+    // API 协议页面调试:以某个平台的身份调用 /bot/v1,签发 10 分钟的 Bot 浏览器令牌
+    g.post<IdParams>('/bot-keys/:id/debug-token', async req => {
+      const key = app.botKeys.list().find(k => k.id === req.params.id)
+      if (!key) throw notFound('Bot Key')
+      if (!key.enabled) throw new AppError(400, 'bad_request', `Bot Key「${key.name}」已停用`)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试 Bot API` })
+      return app.browserTokens.issue(key.id, 600, Date.now(), 'bot')
+    })
+    g.delete<IdParams>('/bot-keys/:id', async (req, reply) => {
+      app.botKeys.delete(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Bot Key:${req.params.id}` })
       reply.code(204)
     })
 

+ 3 - 3
apps/server/src/http/routes/v1.ts

@@ -51,10 +51,10 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   // 引擎状态缓存 30 秒,避免频繁调用 CLI
   let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
-  // 业务平台的后端用 API Key 换取浏览器令牌(ADR-0019)
+  // 业务平台的后端用 Agent Key 换取浏览器令牌(ADR-0019)
   f.post('/browser-tokens', async (req, reply) => {
     const p = principal(req)
-    if (p.kind !== 'apikey' || p.browser) throw new AppError(403, 'forbidden', '只能用 API Key 签发浏览器令牌')
+    if (p.kind !== 'apikey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Agent Key 签发浏览器令牌')
     const body = (req.body ?? {}) as CreateBrowserTokenBody
     const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds)
     if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`)
@@ -70,7 +70,7 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
     return statusCache.data
   })
 
-  // 平台只能看到 API Key 上配置的 Profile;isDefault 表示该平台的默认 Profile
+  // 平台只能看到 Agent Key 上配置的 Profile;isDefault 表示该平台的默认 Profile
   f.get('/profiles', async req => {
     const p0 = principal(req)
     const acc = p0.kind === 'admin' ? null : app.apiKeys.access(p0.source)

+ 4 - 2
apps/server/src/http/server.ts

@@ -11,6 +11,7 @@ import { config } from '../config'
 import { AppError } from '../lib/errors'
 import adminRoutes from './routes/admin'
 import v1Routes from './routes/v1'
+import botRoutes from '../teams/routes'
 import { registerGateway } from '../mcp-gateway/server'
 
 export async function buildServer(app: App, opts: { logger: FastifyServerOptions['loggerInstance'] }) {
@@ -30,10 +31,10 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
     return reply.code(status).send(body)
   })
 
-  // CORS:只对 /v1 开放给设置中允许的业务平台来源(不带 Cookie,使用 Authorization 头);后台与网关不开放
+  // CORS:只对 /v1、/bot/v1 开放给设置中允许的业务平台来源(不带 Cookie,使用 Authorization 头);后台与网关不开放
   f.addHook('onRequest', async (req, reply) => {
     const origin = req.headers.origin
-    if (!origin || !req.url.startsWith('/v1/') || !app.settings.get().security.corsOrigins.includes(origin)) return
+    if (!origin || !(req.url.startsWith('/v1/') || req.url.startsWith('/bot/v1/')) || !app.settings.get().security.corsOrigins.includes(origin)) return
     reply.header('access-control-allow-origin', origin)
     reply.header('vary', 'Origin')
     reply.header('access-control-expose-headers', 'content-disposition')
@@ -52,6 +53,7 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
   })
 
   await f.register(v1Routes, { prefix: '/v1', app })
+  await f.register(botRoutes, { prefix: '/bot/v1', app })
   registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream })
   await f.register(adminRoutes, { prefix: '/admin/v1', app })
 

+ 12 - 5
apps/server/src/http/sse.ts

@@ -1,11 +1,18 @@
 // SSE:补发 seq > lastId 的事件,然后实时推送(docs/design/04-api.md §5)
 import type { FastifyReply, FastifyRequest } from 'fastify'
-import type { AgentEvent } from '@agent-server/shared'
 import type { App } from '../app'
 
 const PING_MS = 15_000
 
 export function streamSessionEvents(app: App, req: FastifyRequest, reply: FastifyReply, sessionId: string, lastId: number) {
+  streamEvents(req, reply, lastId, after => app.events.list(sessionId, after), fn => app.events.subscribe(sessionId, fn))
+}
+
+/** 通用的 SSE 推送:会话事件与工作组事件共用(都带递增的 seq) */
+export function streamEvents<E extends { seq: number; type: string }>(
+  req: FastifyRequest, reply: FastifyReply, lastId: number,
+  list: (after: number) => E[], subscribe: (fn: (e: E) => void) => () => void,
+) {
   reply.hijack()
   const res = reply.raw
   // hijack 后 reply.header 不再生效:带上前面钩子设置的头(CORS 等)
@@ -19,17 +26,17 @@ export function streamSessionEvents(app: App, req: FastifyRequest, reply: Fastif
   res.write('retry: 2000\n\n')
 
   let sent = lastId
-  const write = (e: AgentEvent) => {
+  const write = (e: E) => {
     if (e.seq <= sent) return
     sent = e.seq
     res.write(`id: ${e.seq}\nevent: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`)
   }
 
   // 先订阅再补发,补发与实时之间的事件靠 seq 去重
-  const pending: AgentEvent[] = []
+  const pending: E[] = []
   let replaying = true
-  const unsubscribe = app.events.subscribe(sessionId, e => (replaying ? pending.push(e) : write(e)))
-  for (const e of app.events.list(sessionId, lastId)) write(e)
+  const unsubscribe = subscribe(e => (replaying ? pending.push(e) : write(e)))
+  for (const e of list(lastId)) write(e)
   replaying = false
   for (const e of pending) write(e)
 

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 13 - 8
apps/server/src/services/browser-tokens.ts

@@ -1,6 +1,7 @@
-// 浏览器令牌(ADR-0019):业务平台的后端用 API Key 换取短期令牌交给浏览器,API Key 不出现在前端代码中。
-// 令牌无状态(HMAC 签名,服务重启后仍然有效),携带签发它的 API Key id 与过期时间;
-// 每次使用都检查该 API Key 仍然启用,停用或删除 Key 即同时作废它签发的全部令牌。
+// 浏览器令牌(ADR-0019):业务平台的后端用 Agent Key 换取短期令牌交给浏览器,Agent Key 不出现在前端代码中。
+// 令牌无状态(HMAC 签名,服务重启后仍然有效),携带签发它的 Agent Key id 与过期时间;
+// 每次使用都检查该 Agent Key 仍然启用,停用或删除 Key 即同时作废它签发的全部令牌。
+// Bot Key 签发的令牌载荷带 t='bot',两种令牌不能互换使用(ADR-0024)。
 import crypto from 'node:crypto'
 import { eq } from 'drizzle-orm'
 import type { BrowserTokenDto } from '@agent-server/shared'
@@ -12,6 +13,9 @@ const PREFIX = 'bt_'
 export const BROWSER_TOKEN_DEFAULT_TTL = 3600
 export const BROWSER_TOKEN_MAX_TTL = 12 * 3600
 
+/** agent:Agent Key 签发,用于 /v1;bot:Bot Key 签发,用于 /bot/v1 */
+export type BrowserTokenKind = 'agent' | 'bot'
+
 export class BrowserTokenService {
   private secret: Buffer
 
@@ -29,15 +33,15 @@ export class BrowserTokenService {
     return token.startsWith(PREFIX)
   }
 
-  issue(apiKeyId: string, ttlSeconds = BROWSER_TOKEN_DEFAULT_TTL, now = Date.now()): BrowserTokenDto {
+  issue(apiKeyId: string, ttlSeconds = BROWSER_TOKEN_DEFAULT_TTL, now = Date.now(), kind: BrowserTokenKind = 'agent'): BrowserTokenDto {
     const ttl = Math.min(Math.max(60, Math.floor(ttlSeconds)), BROWSER_TOKEN_MAX_TTL)
     const exp = Math.floor(now / 1000) + ttl
-    const payload = Buffer.from(JSON.stringify({ k: apiKeyId, e: exp, n: crypto.randomBytes(6).toString('base64url') })).toString('base64url')
+    const payload = Buffer.from(JSON.stringify({ k: apiKeyId, e: exp, n: crypto.randomBytes(6).toString('base64url'), ...(kind === 'bot' ? { t: 'bot' } : {}) })).toString('base64url')
     return { token: `${PREFIX}${payload}.${this.sign(payload)}`, expiresAt: new Date(exp * 1000).toISOString() }
   }
 
-  /** 返回签发它的 API Key id;签名不符或已过期返回 null(Key 是否启用由调用方检查) */
-  verify(token: string, now = Date.now()): string | null {
+  /** 返回签发它的 Key id;签名不符、已过期或类型不符返回 null(Key 是否启用由调用方检查) */
+  verify(token: string, now = Date.now(), kind: BrowserTokenKind = 'agent'): string | null {
     if (!token.startsWith(PREFIX)) return null
     const [payload, sig] = token.slice(PREFIX.length).split('.')
     if (!payload || !sig) return null
@@ -45,8 +49,9 @@ export class BrowserTokenService {
     const given = Buffer.from(sig)
     if (expected.length !== given.length || !crypto.timingSafeEqual(expected, given)) return null
     try {
-      const p = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { k?: string; e?: number }
+      const p = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { k?: string; e?: number; t?: string }
       if (typeof p.k !== 'string' || typeof p.e !== 'number' || p.e * 1000 <= now) return null
+      if ((p.t === 'bot' ? 'bot' : 'agent') !== kind) return null
       return p.k
     } catch {
       return null

+ 7 - 7
apps/server/src/services/catalog.ts

@@ -1,4 +1,4 @@
-// Profile、资料库、API Key、审计:后台管理的配置类数据
+// Profile、资料库、Agent Key、审计:后台管理的配置类数据
 import crypto from 'node:crypto'
 import { and, desc, eq, ne } from 'drizzle-orm'
 import { z } from 'zod'
@@ -230,7 +230,7 @@ export class KnowledgeService {
   }
 }
 
-const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
+export const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
 
 export class ApiKeyService {
   private lastTouched = new Map<string, number>()
@@ -258,13 +258,13 @@ export class ApiKeyService {
   setProfiles(id: string, access: ApiKeyProfiles) {
     const checked = this.checkAccess(access)
     const r = this.db.update(apiKeys).set(checked).where(eq(apiKeys.id, id)).run()
-    if (r.changes === 0) throw notFound('API Key')
+    if (r.changes === 0) throw notFound('Agent Key')
   }
 
   /** 平台可用的 Profile 与默认 Profile */
   access(id: string): ApiKeyProfiles {
     const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get()
-    if (!row) throw notFound('API Key')
+    if (!row) throw notFound('Agent Key')
     const allowed = row.allowedProfileIds
     return { allowedProfileIds: allowed, defaultProfileId: row.defaultProfileId ?? allowed[0] ?? '' }
   }
@@ -274,7 +274,7 @@ export class ApiKeyService {
     const acc = this.access(id)
     const target = requested || acc.defaultProfileId
     if (!target || !acc.allowedProfileIds.includes(target)) {
-      throw new AppError(403, 'profile_not_allowed', requested ? '该平台无权使用这个 Profile' : '该平台没有可用的 Profile,请在后台为 API Key 配置')
+      throw new AppError(403, 'profile_not_allowed', requested ? '该平台无权使用这个 Profile' : '该平台没有可用的 Profile,请在后台为 Agent Key 配置')
     }
     return this.profiles.get(target)
   }
@@ -296,12 +296,12 @@ export class ApiKeyService {
 
   setEnabled(id: string, enabled: boolean) {
     const r = this.db.update(apiKeys).set({ enabled }).where(eq(apiKeys.id, id)).run()
-    if (r.changes === 0) throw notFound('API Key')
+    if (r.changes === 0) throw notFound('Agent Key')
   }
 
   delete(id: string) {
     const r = this.db.delete(apiKeys).where(eq(apiKeys.id, id)).run()
-    if (r.changes === 0) throw notFound('API Key')
+    if (r.changes === 0) throw notFound('Agent Key')
   }
 
   verify(key: string): string | null {

+ 20 - 11
apps/server/src/services/runs.ts

@@ -8,7 +8,7 @@ import { z } from 'zod'
 import type { AgentEventType, EventPayloads, ProviderId, RunDto, SendMessageBody, SessionDto } from '@agent-server/shared'
 import type { DB } from '../db'
 import { runs, sessions } from '../db/schema'
-import { conflict, notFound } from '../lib/errors'
+import { badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import type { AgentProvider, RunInput } from '../providers/types'
 import { ProviderRunError } from '../providers/types'
@@ -25,11 +25,13 @@ import type { PluginService } from './plugins'
 import type { UsageService } from './usage'
 import type { RunTokenRegistry } from '../mcp-gateway/tokens'
 import { gatewayUrl } from '../config'
-import type { SessionRow, SessionService } from './sessions'
+import { isTeamSource, type SessionRow, type SessionService } from './sessions'
 import type { SettingsService } from './settings'
 import { buildSystemPrompt } from './prompt'
 
 type RunRow = typeof runs.$inferSelect
+/** 交给引擎的附件:工作目录下的相对路径 */
+export interface RunAttachment { id: string; relPath: string; mime: string; size: number }
 
 const sendSchema: z.ZodType<SendMessageBody> = z.object({
   text: z.string().trim().min(1).max(100_000),
@@ -52,6 +54,8 @@ export class RunManager {
   private global: PQueue
   private perProvider: Record<ProviderId, PQueue>
   private active = new Map<string, ActiveRun>()
+  /** 用量统计的来源:默认是会话来源;工作组的会话由工作组服务换成工作组的来源(Bot Key 或 admin) */
+  usageSourceOf: (session: SessionRow) => string = s => s.source
 
   constructor(
     private deps: {
@@ -104,26 +108,31 @@ export class RunManager {
   /** 发送消息:登记 Run 并排队,立即返回 runId */
   send(session: SessionRow, body: SendMessageBody): RunDto {
     const data = parse(sendSchema, body)
+    if (isTeamSource(session.source)) throw badRequest('这是工作组中机器人的会话,请在工作组中发送消息')
+    return this.start(session, data.text, this.deps.files.getMany(session.id, data.attachments ?? []))
+  }
+
+  /** 登记 Run 并排队(工作组直接调用:附件是工作组目录中的文件) */
+  start(session: SessionRow, text: string, attachments: RunAttachment[]): RunDto {
     if (this.activeForSession(session.id)) throw conflict('session_busy', '会话正在运行,请等待完成或先中断')
     const provider = this.deps.providers[session.provider]
     if (!provider || !this.deps.settings.get().providers[session.provider].enabled) throw conflict('provider_unavailable', `引擎 ${session.provider} 当前不可用`)
     this.deps.sessions.assertProfileAllowed(session)
-    const attachments = this.deps.files.getMany(session.id, data.attachments ?? [])
 
     const runId = newId('run')
     this.deps.db.insert(runs).values({ id: runId, sessionId: session.id, status: 'queued' }).run()
     const active: ActiveRun = { runId, sessionId: session.id, controller: new AbortController(), status: 'queued' }
     this.active.set(runId, active)
 
-    this.deps.sessions.ensureTitle(session.id, data.text)
+    this.deps.sessions.ensureTitle(session.id, text)
     this.deps.events.append(session.id, runId, 'user.message', {
-      text: data.text,
+      text,
       attachments: attachments.map(f => ({ fileId: f.id, name: path.basename(f.relPath), mime: f.mime, size: f.size })),
     })
     const queue = this.perProvider[session.provider]
     this.deps.events.append(session.id, runId, 'run.queued', { position: queue.size + queue.pending })
 
-    void queue.add(() => this.global.add(() => this.execute(runId, session.id, provider, data.text, attachments.map(f => f.id))))
+    void queue.add(() => this.global.add(() => this.execute(runId, session.id, provider, text, attachments)))
       .catch(err => this.deps.log.error({ err, runId }, 'run task crashed'))
     return this.get(runId)
   }
@@ -146,7 +155,7 @@ export class RunManager {
     return toRunDto(r)
   }
 
-  private async execute(runId: string, sessionId: string, provider: AgentProvider, text: string, attachmentIds: string[]) {
+  private async execute(runId: string, sessionId: string, provider: AgentProvider, text: string, attachments: RunAttachment[]) {
     const a = this.active.get(runId)
     if (!a || a.controller.signal.aborted) return
     const { deps } = this
@@ -225,7 +234,7 @@ export class RunManager {
         skillsExcludedFromAll: deps.customSkills.excludedFromAll(),
         // Codex 插件与应用连接器使用账号绑定的数据,只在后台 Playground(source = admin)中开放
         codexPlugins: session.provider === 'codex' && session.source === 'admin',
-        input: await this.buildInput(session, text, attachmentIds),
+        input: await this.buildInput(session, text, attachments),
         access,
         settings,
         signal: a.controller.signal,
@@ -291,7 +300,7 @@ export class RunManager {
       const session = this.deps.db.select().from(sessions).where(eq(sessions.id, a.sessionId)).get()
       if (!run || !session) return
       this.deps.usage.record({
-        source: session.source, provider: session.provider, model: session.model, status,
+        source: this.usageSourceOf(session), provider: session.provider, model: session.model, status,
         inputTokens: run.inputTokens, outputTokens: run.outputTokens, cacheReadTokens: run.cacheReadTokens, cacheWriteTokens: run.cacheWriteTokens,
         costUsd: run.costUsd, durationMs: run.startedAt && run.endedAt ? Date.parse(run.endedAt) - Date.parse(run.startedAt) : 0,
       })
@@ -306,9 +315,9 @@ export class RunManager {
     return [...await this.deps.plugins.loadable().catch(() => []), ...extra]
   }
 
-  private async buildInput(session: SessionRow, text: string, attachmentIds: string[]): Promise<RunInput> {
+  private async buildInput(session: SessionRow, text: string, attachments: RunAttachment[]): Promise<RunInput> {
     const input: RunInput = { text, images: [], attachments: [] }
-    for (const f of this.deps.files.getMany(session.id, attachmentIds)) {
+    for (const f of attachments) {
       const name = path.basename(f.relPath)
       input.attachments.push({ path: f.relPath, name, mime: f.mime, size: f.size })
       if (IMAGE_MIMES.has(f.mime) && f.size <= MAX_INLINE_IMAGE) {

+ 32 - 3
apps/server/src/services/sessions.ts

@@ -13,6 +13,10 @@ import type { SettingsService } from './settings'
 
 export type SessionRow = typeof sessions.$inferSelect
 
+/** 工作组中机器人的会话:来源为 team:<工作组 id>,只能通过工作组驱动(docs/design/13-bot-teams.md §3.3) */
+export const TEAM_SOURCE_PREFIX = 'team:'
+export const isTeamSource = (source: string) => source.startsWith(TEAM_SOURCE_PREFIX)
+
 const createSchema: z.ZodType<CreateSessionBody> = z.object({
   profileId: z.string().optional(),
   model: z.string().min(1).optional(),
@@ -74,7 +78,24 @@ export class SessionService {
     return this.getRow(id)
   }
 
-  /** 会话来源可以使用的 Profile:后台不受限;平台只能用 API Key 上配置的 Profile */
+  /** 工作组中机器人的会话:工作目录是工作组的共享目录(已存在),Profile 由机器人决定 */
+  createForTeam(opts: { teamId: string; profile: ProfileDto; workspaceDir: string; title: string; systemPromptAppend: string }): SessionRow {
+    const { profile } = opts
+    if (!this.settings.get().providers[profile.provider].enabled) throw badRequest(`引擎 ${profile.provider} 未启用`)
+    const id = newId('ses')
+    this.db.insert(sessions).values({
+      id, source: `${TEAM_SOURCE_PREFIX}${opts.teamId}`, profileId: profile.id, provider: profile.provider, model: profile.model,
+      workspaceDir: opts.workspaceDir, permissionMode: null, systemPromptAppend: opts.systemPromptAppend, title: opts.title,
+    }).run()
+    return this.getRow(id)
+  }
+
+  /** 工作组在每次唤醒机器人前同步:机器人的 Profile、模型与工作组提示词 */
+  syncTeamSession(id: string, patch: { profileId: string; model: string; systemPromptAppend: string; title: string }) {
+    this.db.update(sessions).set(patch).where(eq(sessions.id, id)).run()
+  }
+
+  /** 会话来源可以使用的 Profile:后台不受限;平台只能用 Agent Key 上配置的 Profile */
   profileFor(source: string, requested?: string | null): ProfileDto {
     if (source === 'admin') return requested ? this.profiles.get(requested) : this.profiles.getDefault()
     return this.apiKeys.resolveProfile(source, requested)
@@ -93,7 +114,8 @@ export class SessionService {
 
   /** 平台的会话在每次运行前检查 Profile 是否仍对它开放 */
   assertProfileAllowed(row: SessionRow) {
-    if (row.source === 'admin') return
+    // 工作组的会话由工作组检查机器人是否可用
+    if (row.source === 'admin' || isTeamSource(row.source)) return
     const acc = this.apiKeys.access(row.source)
     if (!row.profileId || !acc.allowedProfileIds.includes(row.profileId)) {
       throw new AppError(403, 'profile_not_allowed', '该会话使用的 Profile 已不再对此平台开放,请新建会话')
@@ -145,6 +167,7 @@ export class SessionService {
 
   patch(id: string, source: string, body: unknown): SessionRow {
     const row = this.getRow(id, source)
+    if (isTeamSource(row.source)) throw badRequest('这是工作组中机器人的会话,请在工作组中操作')
     rejectProvider(body)
     const data = parse(patchSchema, body)
     if (source !== 'admin' && data.permissionMode !== undefined) throw badRequest('权限模式由 Profile 决定,不能修改')
@@ -178,7 +201,14 @@ export class SessionService {
 
   async delete(id: string, source: string) {
     const row = this.getRow(id, source)
+    if (isTeamSource(row.source)) throw badRequest('这是工作组中机器人的会话,请删除整个工作组')
     if (this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请先中断')
+    this.deleteRecords(id)
+    await this.files.removeWorkspace(row.workspaceDir, this.settings.get().paths.workspaceRoot)
+  }
+
+  /** 删除会话的数据库记录(不删除工作目录:工作组的会话共用工作组目录,由工作组删除) */
+  deleteRecords(id: string) {
     this.db.transaction(tx => {
       tx.delete(events).where(eq(events.sessionId, id)).run()
       tx.delete(interactions).where(eq(interactions.sessionId, id)).run()
@@ -187,7 +217,6 @@ export class SessionService {
     })
     this.files.deleteForSession(id)
     this.events.dropBuffer(id)
-    await this.files.removeWorkspace(row.workspaceDir, this.settings.get().paths.workspaceRoot)
   }
 }
 

+ 4 - 1
apps/server/src/services/usage.ts

@@ -39,6 +39,9 @@ const totalsSql = {
 }
 
 export class UsageService {
+  /** 由 app 注入:其他来源(Bot Key)的名称 */
+  extraSourceNames: () => Map<string, string> = () => new Map()
+
   constructor(private db: DB) {}
 
   record(r: UsageRecord, date = localDate()) {
@@ -75,7 +78,7 @@ export class UsageService {
       return acc
     }, zero())
 
-    const keyNames = new Map(this.db.select({ id: apiKeys.id, name: apiKeys.name }).from(apiKeys).all().map(k => [k.id, k.name]))
+    const keyNames = new Map([...this.db.select({ id: apiKeys.id, name: apiKeys.name }).from(apiKeys).all().map(k => [k.id, k.name] as const), ...this.extraSourceNames()])
     const bySource = this.db.select({ source: usageDaily.source, ...totalsSql }).from(usageDaily).where(where).groupBy(usageDaily.source).all()
       .map(r => ({ ...norm(r), name: r.source === 'admin' ? '后台 Playground' : keyNames.get(r.source) ?? `已删除的 Key(${r.source})` }))
       .sort((a, b) => b.runs - a.runs)

+ 178 - 0
apps/server/src/teams/bots.ts

@@ -0,0 +1,178 @@
+// 机器人与 Bot Key(docs/design/13-bot-teams.md §2、ADR-0024)
+import crypto from 'node:crypto'
+import { and, desc, eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type { BotDto, BotInput, BotKeyDto, BotSummary } from '@agent-server/shared'
+import type { DB } from '../db'
+import { botApiKeys, bots, teamBots } from '../db/schema'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { hashKey, parse, type ProfileService } from '../services/catalog'
+
+type BotRow = typeof bots.$inferSelect
+
+const botSchema: z.ZodType<BotInput> = z.object({
+  name: z.string().trim().min(1).max(30).refine(s => !/[\s@]/.test(s), '名称不能包含空格或 @'),
+  avatar: z.string().trim().min(1).max(8),
+  kind: z.enum(['lead', 'worker']),
+  description: z.string().max(4000),
+  profileId: z.string().min(1),
+  enabled: z.boolean(),
+})
+
+const toDto = (r: BotRow): BotDto => ({
+  id: r.id, name: r.name, avatar: r.avatar, kind: r.kind, description: r.description, profileId: r.profileId,
+  enabled: r.enabled, createdAt: r.createdAt, updatedAt: r.updatedAt,
+})
+
+export class BotService {
+  constructor(private db: DB, private profiles: ProfileService) {}
+
+  list(): BotDto[] {
+    return this.db.select().from(bots).orderBy(bots.kind, bots.name).all().map(toDto)
+  }
+
+  get(id: string): BotDto {
+    const row = this.db.select().from(bots).where(eq(bots.id, id)).get()
+    if (!row) throw notFound('机器人')
+    return toDto(row)
+  }
+
+  /** Bot API 中展示的信息:引擎和模型取自 Profile(Profile 已删除时为 null) */
+  summary(bot: BotDto): BotSummary {
+    let provider: BotSummary['provider'] = null
+    let model: string | null = null
+    try {
+      const p = this.profiles.get(bot.profileId)
+      provider = p.provider
+      model = p.model
+    } catch {
+      // Profile 已删除
+    }
+    return { id: bot.id, name: bot.name, avatar: bot.avatar, kind: bot.kind, description: bot.description, provider, model }
+  }
+
+  create(input: BotInput): BotDto {
+    const data = this.check(input)
+    const id = newId('bot')
+    this.db.insert(bots).values({ id, ...data }).run()
+    return this.get(id)
+  }
+
+  update(id: string, input: BotInput): BotDto {
+    const prev = this.get(id)
+    const data = this.check(input, id)
+    // 已在工作组中的机器人不能改变类型,否则组长人数规则会被打破
+    if (data.kind !== prev.kind && this.teamCount(id) > 0) throw badRequest('这个机器人已加入工作组,不能修改类型')
+    this.db.update(bots).set({ ...data, updatedAt: nowIso() }).where(eq(bots.id, id)).run()
+    return this.get(id)
+  }
+
+  delete(id: string) {
+    this.get(id)
+    const n = this.teamCount(id)
+    if (n > 0) throw new AppError(400, 'bot_in_use', `这个机器人还在 ${n} 个工作组中,请先把它移出或删除这些工作组`)
+    this.db.delete(bots).where(eq(bots.id, id)).run()
+  }
+
+  /** 引用了某个 Profile 的机器人名称(删除 Profile 前检查) */
+  referencing(profileId: string): string[] {
+    return this.list().filter(b => b.profileId === profileId).map(b => b.name)
+  }
+
+  private teamCount(botId: string) {
+    return this.db.select().from(teamBots).where(eq(teamBots.botId, botId)).all().length
+  }
+
+  private check(input: BotInput, selfId?: string) {
+    const data = parse(botSchema, input)
+    this.profiles.get(data.profileId)
+    const dup = this.db.select().from(bots).where(eq(bots.name, data.name)).get()
+    if (dup && dup.id !== selfId) throw conflict('name_taken', `已有同名机器人:${data.name}`)
+    return data
+  }
+}
+
+export class BotKeyService {
+  private lastTouched = new Map<string, number>()
+
+  constructor(private db: DB, private botService: BotService) {}
+
+  list(): BotKeyDto[] {
+    return this.db.select().from(botApiKeys).orderBy(desc(botApiKeys.createdAt)).all()
+      .map(r => ({ id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedBotIds: r.allowedBotIds, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt }))
+  }
+
+  /** 返回明文 Key,只在创建时出现一次 */
+  create(name: string, allowedBotIds: string[]): { key: string; botKey: BotKeyDto } {
+    if (!name.trim()) throw badRequest('名称不能为空')
+    const allowed = this.checkBots(allowedBotIds)
+    const key = `bk_${crypto.randomBytes(24).toString('base64url')}`
+    const id = newId('bkey')
+    this.db.insert(botApiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 9), allowedBotIds: allowed }).run()
+    return { key, botKey: this.list().find(k => k.id === id)! }
+  }
+
+  setBots(id: string, allowedBotIds: string[]) {
+    const r = this.db.update(botApiKeys).set({ allowedBotIds: this.checkBots(allowedBotIds) }).where(eq(botApiKeys.id, id)).run()
+    if (r.changes === 0) throw notFound('Bot Key')
+  }
+
+  setEnabled(id: string, enabled: boolean) {
+    const r = this.db.update(botApiKeys).set({ enabled }).where(eq(botApiKeys.id, id)).run()
+    if (r.changes === 0) throw notFound('Bot Key')
+  }
+
+  delete(id: string) {
+    const r = this.db.delete(botApiKeys).where(eq(botApiKeys.id, id)).run()
+    if (r.changes === 0) throw notFound('Bot Key')
+  }
+
+  /** 删除机器人后,从各个 Key 的可用列表中移除 */
+  forgetBot(botId: string) {
+    for (const k of this.list()) {
+      if (k.allowedBotIds.includes(botId)) this.db.update(botApiKeys).set({ allowedBotIds: k.allowedBotIds.filter(b => b !== botId) }).where(eq(botApiKeys.id, k.id)).run()
+    }
+  }
+
+  /** 来源可以使用的机器人 id;后台(admin)不受限,返回 null */
+  allowedBots(source: string): Set<string> | null {
+    if (source === 'admin') return null
+    const row = this.db.select().from(botApiKeys).where(eq(botApiKeys.id, source)).get()
+    return new Set(row?.allowedBotIds ?? [])
+  }
+
+  verify(key: string): string | null {
+    const row = this.db.select().from(botApiKeys).where(and(eq(botApiKeys.keyHash, hashKey(key)), eq(botApiKeys.enabled, true))).get()
+    if (!row) return null
+    this.touch(row.id)
+    return row.id
+  }
+
+  /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */
+  isActive(id: string): boolean {
+    const row = this.db.select({ id: botApiKeys.id }).from(botApiKeys).where(and(eq(botApiKeys.id, id), eq(botApiKeys.enabled, true))).get()
+    if (row) this.touch(id)
+    return !!row
+  }
+
+  names(): Map<string, string> {
+    return new Map(this.db.select({ id: botApiKeys.id, name: botApiKeys.name }).from(botApiKeys).all().map(r => [r.id, r.name]))
+  }
+
+  private checkBots(ids: string[]) {
+    const allowed = [...new Set(parse(z.array(z.string()), ids))]
+    if (!allowed.length) throw badRequest('至少选择一个机器人')
+    for (const id of allowed) this.botService.get(id)
+    return allowed
+  }
+
+  /** 最近使用时间每分钟最多写一次 */
+  private touch(id: string) {
+    const now = Date.now()
+    if ((this.lastTouched.get(id) ?? 0) < now - 60_000) {
+      this.lastTouched.set(id, now)
+      this.db.update(botApiKeys).set({ lastUsedAt: nowIso() }).where(eq(botApiKeys.id, id)).run()
+    }
+  }
+}

+ 68 - 0
apps/server/src/teams/events.ts

@@ -0,0 +1,68 @@
+// 工作组事件:每个工作组独立的 seq,落库(状态类事件除外)→ 内存缓冲 → 广播。与会话的 EventStore 相同的思路
+import { EventEmitter } from 'node:events'
+import { and, asc, eq, gt, sql } from 'drizzle-orm'
+import { TRANSIENT_TEAM_EVENT_TYPES, type TeamEvent, type TeamEventPayloads, type TeamEventType } from '@agent-server/shared'
+import type { DB } from '../db'
+import { teamEvents, teams } from '../db/schema'
+import { nowIso } from '../lib/ids'
+
+const BUFFER_SIZE = 1000
+
+export class TeamEventStore {
+  private bus = new EventEmitter()
+  private buffers = new Map<string, TeamEvent[]>()
+
+  constructor(private db: DB) {
+    this.bus.setMaxListeners(0)
+  }
+
+  append<T extends TeamEventType>(teamId: string, type: T, payload: TeamEventPayloads[T]): TeamEvent {
+    const ts = nowIso()
+    const persist = !TRANSIENT_TEAM_EVENT_TYPES.has(type)
+    const event = this.db.transaction(tx => {
+      const row = tx.update(teams)
+        .set({ lastSeq: sql`${teams.lastSeq} + 1`, ...(persist ? { updatedAt: ts } : {}) })
+        .where(eq(teams.id, teamId))
+        .returning({ seq: teams.lastSeq })
+        .get()
+      if (!row) throw new Error(`工作组不存在:${teamId}`)
+      if (persist) tx.insert(teamEvents).values({ teamId, seq: row.seq, type, payload, ts }).run()
+      return { seq: row.seq, type, teamId, ts, payload } as TeamEvent
+    })
+
+    const buf = this.buffers.get(teamId) ?? []
+    buf.push(event)
+    if (buf.length > BUFFER_SIZE) buf.splice(0, buf.length - BUFFER_SIZE)
+    this.buffers.set(teamId, buf)
+    this.bus.emit(teamId, event)
+    return event
+  }
+
+  /** seq > after 的事件:优先使用内存缓冲(包含状态类事件),不足时回落到数据库 */
+  list(teamId: string, after = 0, limit = 5000, types?: TeamEventType[]): TeamEvent[] {
+    const buf = this.buffers.get(teamId)
+    let out: TeamEvent[]
+    if (buf?.length && buf[0]!.seq <= after + 1) {
+      out = buf.filter(e => e.seq > after)
+    } else {
+      const persisted = this.db.select().from(teamEvents)
+        .where(and(eq(teamEvents.teamId, teamId), gt(teamEvents.seq, after)))
+        .orderBy(asc(teamEvents.seq))
+        .all()
+        .map(r => ({ seq: r.seq, type: r.type, teamId: r.teamId, ts: r.ts, payload: r.payload }) as TeamEvent)
+      const last = persisted.at(-1)?.seq ?? after
+      out = [...persisted, ...(buf ?? []).filter(e => e.seq > last)]
+    }
+    return (types ? out.filter(e => types.includes(e.type)) : out).slice(0, limit)
+  }
+
+  subscribe(teamId: string, fn: (e: TeamEvent) => void): () => void {
+    this.bus.on(teamId, fn)
+    return () => this.bus.off(teamId, fn)
+  }
+
+  remove(teamId: string) {
+    this.db.delete(teamEvents).where(eq(teamEvents.teamId, teamId)).run()
+    this.buffers.delete(teamId)
+  }
+}

+ 52 - 0
apps/server/src/teams/prompt.ts

@@ -0,0 +1,52 @@
+// 工作组中机器人的提示词与每轮输入(docs/design/13-bot-teams.md §4.3)
+import type { BotDto, TeamEventOf, TeamHuman, TeamMessage } from '@agent-server/shared'
+
+const ROLE = { lead: '组长', worker: '组员' } as const
+
+/** 追加在 Profile 提示词之后:身份、工作组成员、协作约定 */
+export function teamPrompt(opts: { title: string; human: TeamHuman; self: BotDto; bots: BotDto[] }): string {
+  const { self } = opts
+  const others = opts.bots.filter(b => b.id !== self.id)
+  const roster = others.length
+    ? others.map(b => `  - ${b.name}(${ROLE[b.kind]})${b.description ? `:${oneLine(b.description)}` : ''}`).join('\n')
+    : '  - (没有其他机器人)'
+  return `# 工作组
+你是工作组「${opts.title || '未命名'}」中的机器人「${self.name}」(${ROLE[self.kind]})。${self.description ? `\n你的岗位:${self.description.trim()}` : ''}
+
+- 成员(人类):${opts.human.name}
+- 其他机器人:
+${roster}
+- 每一轮你收到的是工作组里与你有关的新消息。你这一轮的**最终回复**会作为一条消息发到工作组,由成员阅读;工具调用等中间过程不会显示在工作组里,所以最终回复要能独立看懂。
+- 工作目录是工作组的共享目录,组内所有机器人共用:
+  - uploads/ 是成员上传的文件
+  - 交付的文件保存到 outputs/ 下,会以文件卡片显示在你的回复中
+  - 你的中间文件放在 .work/${self.name}/ 下;不要修改或删除其他机器人的文件
+- 在回复中写"@某个机器人"不会通知对方。需要其他机器人配合时,在回复中向成员说明。`
+}
+
+/** 本轮输入:自上次以来与该机器人有关的新消息 */
+export function teamInput(messages: TeamEventOf<'message.created'>[], selfId: string): string {
+  const lines = messages.map(e => {
+    const m = e.payload
+    const head = `[${clock(e.ts)}] ${senderLabel(m, selfId)}:`
+    const files = m.files.length ? `\n  附件:${m.files.map(f => f.path).join('、')}` : ''
+    return `${head}${m.text}${files}`
+  })
+  return `【工作组新消息】\n${lines.join('\n\n')}`
+}
+
+function senderLabel(m: TeamMessage, selfId: string): string {
+  const s = m.sender
+  if (s.kind === 'human') return `${s.name}(成员)${m.targets.includes(selfId) ? '' : '(不是发给你的)'}`
+  if (s.kind === 'bot') return `${s.name}(机器人)`
+  return '系统'
+}
+
+function clock(ts: string) {
+  return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
+}
+
+function oneLine(s: string) {
+  const t = s.replace(/\s+/g, ' ').trim()
+  return t.length > 120 ? `${t.slice(0, 120)}…` : t
+}

+ 135 - 0
apps/server/src/teams/routes.ts

@@ -0,0 +1,135 @@
+// Bot API(/bot/v1,docs/design/13-bot-teams.md §9、ADR-0024)
+import fs from 'node:fs'
+import path from 'node:path'
+import type { FastifyInstance, FastifyRequest } from 'fastify'
+import { z } from 'zod'
+import type { CreateBrowserTokenBody, CreateTeamBody, InteractionResponse, SendTeamMessageBody, TeamStatus } from '@agent-server/shared'
+import type { App } from '../app'
+import { AppError, badRequest, notFound } from '../lib/errors'
+import { BROWSER_TOKEN_MAX_TTL } from '../services/browser-tokens'
+import { parse } from '../services/catalog'
+import { principal, requireBotClient } from '../http/auth'
+import { streamEvents } from '../http/sse'
+
+type IdParams = { Params: { id: string } }
+type BotParams = { Params: { id: string; botId: string } }
+
+const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
+  z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
+  z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
+  z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
+])
+
+/** 已注册的 /bot/v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */
+export const registeredBotRoutes: { method: string; path: string }[] = []
+
+export default async function botRoutes(f: FastifyInstance, { app }: { app: App }) {
+  f.addHook('onRoute', r => {
+    for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredBotRoutes.push({ method: m, path: r.url })
+  })
+  f.addHook('preHandler', requireBotClient(app))
+
+  const source = (req: FastifyRequest) => principal(req).source
+  const teamFor = (req: FastifyRequest<IdParams>) => app.teams.getRow(req.params.id, source(req))
+
+  f.post('/browser-tokens', async (req, reply) => {
+    const p = principal(req)
+    if (p.kind !== 'botkey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Bot Key 签发浏览器令牌')
+    const body = (req.body ?? {}) as CreateBrowserTokenBody
+    const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds)
+    if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`)
+    reply.code(201)
+    return app.browserTokens.issue(p.source, ttl, Date.now(), 'bot')
+  })
+
+  // 这个 Key 可以使用的机器人(后台不受限)
+  f.get('/bots', async req => {
+    const allowed = app.botKeys.allowedBots(source(req))
+    return app.bots.list().filter(b => b.enabled && (!allowed || allowed.has(b.id))).map(b => app.bots.summary(b))
+  })
+
+  // ---- 工作组 ----
+  f.post('/teams', async (req, reply) => {
+    const row = await app.teams.create(source(req), (req.body ?? {}) as CreateTeamBody)
+    reply.code(201)
+    return app.teams.detail(row)
+  })
+
+  f.get<{ Querystring: { scope?: string; status?: TeamStatus; limit?: string } }>('/teams', async req =>
+    app.teams.list(source(req), { all: req.query.scope === 'all', status: req.query.status, limit: req.query.limit ? Number(req.query.limit) : undefined }))
+
+  f.get<IdParams>('/teams/:id', async req => app.teams.detail(teamFor(req)))
+  f.patch<IdParams>('/teams/:id', async req => app.teams.detail(app.teams.patch(req.params.id, source(req), req.body)))
+  f.delete<IdParams>('/teams/:id', async (req, reply) => {
+    await app.teams.delete(req.params.id, source(req))
+    reply.code(204)
+  })
+
+  for (const action of ['pause', 'resume', 'close', 'reopen'] as const) {
+    f.post<IdParams>(`/teams/:id/${action}`, async req => app.teams.detail(app.teams.setStatus(req.params.id, source(req), action)))
+  }
+
+  f.post<IdParams>('/teams/:id/bots', async req => {
+    const { botId } = parse(z.object({ botId: z.string().min(1) }), req.body)
+    return app.teams.detail(app.teams.addBot(req.params.id, source(req), botId))
+  })
+  f.delete<BotParams>('/teams/:id/bots/:botId', async req => app.teams.detail(app.teams.removeBot(req.params.id, source(req), req.params.botId)))
+  f.post<BotParams>('/teams/:id/bots/:botId/interrupt', async (req, reply) => {
+    app.teams.interrupt(req.params.id, source(req), req.params.botId)
+    reply.code(202)
+    return { ok: true }
+  })
+
+  // ---- 消息与事件 ----
+  f.post<IdParams>('/teams/:id/messages', async (req, reply) => {
+    const message = app.teams.postMessage(req.params.id, source(req), (req.body ?? {}) as SendTeamMessageBody)
+    reply.code(201)
+    return message
+  })
+
+  f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/teams/:id/messages', async req => {
+    const row = teamFor(req)
+    const items = app.teams.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 500), 5000), ['message.created'])
+    return { items, lastSeq: row.lastSeq }
+  })
+
+  f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/teams/:id/events', async req => {
+    const row = teamFor(req)
+    return { items: app.teams.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }
+  })
+
+  f.get<IdParams & { Querystring: { after?: string } }>('/teams/:id/stream', async (req, reply) => {
+    const row = teamFor(req)
+    const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0
+    streamEvents(req, reply, lastId, after => app.teams.events.list(row.id, after), fn => app.teams.events.subscribe(row.id, fn))
+  })
+
+  f.post<IdParams & { Params: { iid: string } }>('/teams/:id/interactions/:iid', async req => {
+    app.teams.respond(req.params.id, source(req), req.params.iid, parse(interactionResponseSchema, req.body))
+    return { ok: true }
+  })
+
+  // ---- 文件 ----
+  f.post<IdParams>('/teams/:id/files', async (req, reply) => {
+    const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576
+    const saved = await app.teams.upload(req.params.id, source(req), req.files({ limits: { fileSize: limitBytes, files: 20 } }))
+    reply.code(201)
+    return saved
+  })
+
+  f.get<IdParams>('/teams/:id/files', async req => app.teams.listFiles(req.params.id, source(req)))
+
+  f.get<IdParams & { Querystring: { inline?: string } }>('/files/:id', async (req, reply) => {
+    const file = app.files.get(req.params.id)
+    if (!file.sessionId.startsWith('team_')) throw notFound('文件')
+    const team = app.teams.getRow(file.sessionId, source(req))
+    const abs = path.join(team.workspaceDir, file.relPath)
+    if (!fs.existsSync(abs)) throw notFound('文件')
+    reply.header('Content-Type', file.mime)
+    reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(path.basename(file.relPath))}`)
+    reply.header('X-Content-Type-Options', 'nosniff')
+    // 预览 HTML/SVG 时禁止执行脚本
+    reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
+    return reply.send(fs.createReadStream(abs))
+  })
+}

+ 552 - 0
apps/server/src/teams/service.ts

@@ -0,0 +1,552 @@
+// 工作组(docs/design/13-bot-teams.md):组成规则、生命周期、消息与唤醒、机器人执行结果回帖、审批
+// 每个"机器人 × 工作组"一个引擎会话(来源 team:<id>),工作目录都是工作组的共享目录;同一机器人按顺序执行,
+// 执行期间到达的新消息在下一轮一起交给它
+import { and, desc, eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type {
+  AgentEvent, BotDto, CreateTeamBody, InteractionResponse, SendTeamMessageBody, TeamBotDto, TeamBotStatus, TeamDetailDto, TeamDto,
+  TeamEventOf, TeamFileRef, TeamInteractionDto, TeamMessage, TeamStatus,
+} from '@agent-server/shared'
+import type { Readable } from 'node:stream'
+import path from 'node:path'
+import type { DB } from '../db'
+import { sessions, teamBotSessions, teamBots, teams } from '../db/schema'
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { parse, type ProfileService } from '../services/catalog'
+import type { EventStore } from '../services/event-store'
+import type { FileService } from '../services/files'
+import type { InteractionService } from '../services/interactions'
+import type { RunAttachment, RunManager } from '../services/runs'
+import { isTeamSource, TEAM_SOURCE_PREFIX, type SessionRow, type SessionService } from '../services/sessions'
+import type { SettingsService } from '../services/settings'
+import type { BotKeyService, BotService } from './bots'
+import { TeamEventStore } from './events'
+import { teamInput, teamPrompt } from './prompt'
+
+export type TeamRow = typeof teams.$inferSelect
+
+const createSchema: z.ZodType<CreateTeamBody> = z.object({
+  title: z.string().trim().max(100).optional(),
+  human: z.object({ id: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(60) }),
+  leadBotId: z.string().optional(),
+  workerBotIds: z.array(z.string()).max(20).optional(),
+})
+
+const messageSchema: z.ZodType<SendTeamMessageBody> = z.object({
+  text: z.string().trim().min(1).max(100_000),
+  mentions: z.array(z.string()).max(20).optional(),
+  fileIds: z.array(z.string()).max(50).optional(),
+})
+
+interface BotRuntime {
+  status: TeamBotStatus
+  runId: string | null
+  /** 执行期间又收到了发给它的消息:本轮结束后再唤醒 */
+  pending: boolean
+  /** 正在准备开始一轮(同步文件索引中),这期间到达的消息同样记为 pending */
+  starting: boolean
+  /** 本轮最后一段回复与改动过的文件(用于回帖) */
+  lastText: string
+  changedPaths: Set<string>
+}
+
+interface Logger { warn(o: object, m?: string): void; error(o: object, m?: string): void }
+
+export class TeamService {
+  readonly events: TeamEventStore
+  private runtime = new Map<string, BotRuntime>()
+  /** 会话 → 工作组与机器人;null 表示不是工作组的当前会话 */
+  private sessionIndex = new Map<string, { teamId: string; botId: string } | null>()
+
+  constructor(
+    private deps: {
+      db: DB
+      log: Logger
+      settings: SettingsService
+      profiles: ProfileService
+      sessions: SessionService
+      runs: RunManager
+      files: FileService
+      events: EventStore
+      interactions: InteractionService
+      bots: BotService
+      botKeys: BotKeyService
+      /** 删除会话时清理网关的会话级状态 */
+      forgetSession: (sessionId: string) => void
+    },
+  ) {
+    this.events = new TeamEventStore(deps.db)
+    deps.events.subscribe('*', e => this.onSessionEvent(e))
+    // 用量记到工作组的来源(Bot Key 或 admin)上
+    deps.runs.usageSourceOf = s => (isTeamSource(s.source) ? this.findRow(s.source.slice(TEAM_SOURCE_PREFIX.length))?.source ?? s.source : s.source)
+  }
+
+  // ---- 工作组 ----
+
+  async create(source: string, body: CreateTeamBody): Promise<TeamRow> {
+    const data = parse(createSchema, body)
+    const workers = [...new Set(data.workerBotIds ?? [])]
+    const members: { bot: BotDto; role: BotDto['kind'] }[] = []
+    if (data.leadBotId) members.push({ bot: this.usableBot(source, data.leadBotId, 'lead'), role: 'lead' })
+    for (const id of workers) members.push({ bot: this.usableBot(source, id, 'worker'), role: 'worker' })
+    if (!members.length) throw badRequest('工作组至少需要一个机器人(组长或组员)')
+
+    const id = newId('team')
+    const workspaceDir = path.join(this.deps.settings.get().paths.workspaceRoot, id)
+    await this.deps.files.createWorkspace(workspaceDir)
+    const title = data.title || members.map(m => m.bot.name).join('、')
+    this.deps.db.transaction(tx => {
+      tx.insert(teams).values({ id, source, title, human: data.human, workspaceDir }).run()
+      for (const m of members) tx.insert(teamBots).values({ teamId: id, botId: m.bot.id, role: m.role }).run()
+    })
+    const lead = members.find(m => m.role === 'lead')
+    const ws = members.filter(m => m.role === 'worker').map(m => m.bot.name)
+    this.system(id, `工作组已创建。成员:${data.human.name}${lead ? `;组长:${lead.bot.name}` : ''}${ws.length ? `;组员:${ws.join('、')}` : ''}`)
+    return this.getRow(id)
+  }
+
+  getRow(id: string, source?: string): TeamRow {
+    const row = this.findRow(id)
+    // 非后台调用只能访问自己创建的工作组
+    if (!row || (source && source !== 'admin' && row.source !== source)) throw notFound('工作组')
+    return row
+  }
+
+  list(source: string, opts: { all?: boolean; status?: TeamStatus; limit?: number } = {}): TeamDto[] {
+    const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500)
+    return this.deps.db.select().from(teams)
+      .where(and(
+        opts.all && source === 'admin' ? undefined : eq(teams.source, source),
+        opts.status ? eq(teams.status, opts.status) : undefined,
+      ))
+      .orderBy(desc(teams.updatedAt), desc(teams.id))
+      .limit(limit)
+      .all()
+      .map(r => this.toDto(r))
+  }
+
+  toDto(r: TeamRow): TeamDto {
+    const links = new Map(this.deps.db.select().from(teamBotSessions).where(eq(teamBotSessions.teamId, r.id)).all().map(l => [l.botId, l.sessionId]))
+    const bots: TeamBotDto[] = this.memberRows(r.id).map(m => {
+      let summary
+      try {
+        summary = this.deps.bots.summary(this.deps.bots.get(m.botId))
+      } catch {
+        summary = { id: m.botId, name: '(已删除)', avatar: '❔', kind: m.role, description: '', provider: null, model: null }
+      }
+      return { ...summary, role: m.role, status: this.rt(r.id, m.botId).status, sessionId: links.get(m.botId) ?? null, joinedAt: m.joinedAt }
+    })
+    // 组长排在最前面
+    bots.sort((a, b) => (a.role === b.role ? a.joinedAt.localeCompare(b.joinedAt) : a.role === 'lead' ? -1 : 1))
+    return { id: r.id, title: r.title, human: r.human, status: r.status, source: r.source, bots, lastSeq: r.lastSeq, createdAt: r.createdAt, updatedAt: r.updatedAt }
+  }
+
+  detail(r: TeamRow): TeamDetailDto {
+    return { ...this.toDto(r), pendingInteractions: this.pendingInteractions(r.id) }
+  }
+
+  patch(id: string, source: string, body: unknown): TeamRow {
+    this.getRow(id, source)
+    const { title } = parse(z.object({ title: z.string().trim().min(1).max(100) }), body)
+    this.deps.db.update(teams).set({ title, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
+    this.changed(id)
+    return this.getRow(id)
+  }
+
+  addBot(id: string, source: string, botId: string): TeamRow {
+    const team = this.getRow(id, source)
+    if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭')
+    const members = this.memberRows(id)
+    if (members.some(m => m.botId === botId)) throw conflict('already_member', '这个机器人已经在工作组中')
+    const bot = this.usableBot(team.source, botId)
+    if (bot.kind === 'lead' && members.some(m => m.role === 'lead')) throw badRequest('工作组已经有组长,最多只能有一个')
+    this.deps.db.insert(teamBots).values({ teamId: id, botId, role: bot.kind }).run()
+    this.system(id, `${bot.name}(${bot.kind === 'lead' ? '组长' : '组员'})加入了工作组`)
+    this.changed(id)
+    return this.getRow(id)
+  }
+
+  removeBot(id: string, source: string, botId: string): TeamRow {
+    this.getRow(id, source)
+    const members = this.memberRows(id)
+    if (!members.some(m => m.botId === botId)) throw notFound('工作组中的机器人')
+    if (members.length <= 1) throw badRequest('工作组至少需要一个机器人,不能移除最后一个')
+    this.interruptBot(id, botId)
+    this.deps.db.delete(teamBots).where(and(eq(teamBots.teamId, id), eq(teamBots.botId, botId))).run()
+    this.runtime.delete(key(id, botId))
+    this.system(id, `${this.botName(botId)} 离开了工作组`)
+    this.changed(id)
+    return this.getRow(id)
+  }
+
+  /** 暂停:中断正在执行的工作,新消息只记录不唤醒;恢复后处理暂停期间收到的消息 */
+  setStatus(id: string, source: string, action: 'pause' | 'resume' | 'close' | 'reopen'): TeamRow {
+    const team = this.getRow(id, source)
+    const next: Record<typeof action, { from: TeamStatus[]; to: TeamStatus; text: string }> = {
+      pause: { from: ['active'], to: 'paused', text: '工作组已暂停,正在执行的工作已中断' },
+      resume: { from: ['paused'], to: 'active', text: '工作组已恢复' },
+      close: { from: ['active', 'paused'], to: 'closed', text: '工作组已关闭' },
+      reopen: { from: ['closed'], to: 'active', text: '工作组已重新打开' },
+    }
+    const t = next[action]
+    if (!t.from.includes(team.status)) throw conflict('invalid_status', `工作组当前状态(${team.status})不能执行这个操作`)
+    this.deps.db.update(teams).set({ status: t.to, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
+    if (t.to !== 'active') for (const m of this.memberRows(id)) this.interruptBot(id, m.botId)
+    this.system(id, t.text)
+    this.changed(id)
+    if (t.to === 'active') for (const m of this.memberRows(id)) this.wake(id, m.botId)
+    return this.getRow(id)
+  }
+
+  async delete(id: string, source: string) {
+    const team = this.getRow(id, source)
+    const sessionRows = this.deps.db.select().from(sessions).where(eq(sessions.source, `${TEAM_SOURCE_PREFIX}${id}`)).all()
+    for (const s of sessionRows) {
+      const active = this.deps.runs.activeForSession(s.id)
+      if (active) this.deps.runs.interrupt(active.runId)
+    }
+    // 等待中断完成(引擎退出),最多 10 秒
+    const end = Date.now() + 10_000
+    while (sessionRows.some(s => this.deps.runs.activeForSession(s.id)) && Date.now() < end) await new Promise(r => setTimeout(r, 50))
+    if (sessionRows.some(s => this.deps.runs.activeForSession(s.id))) throw conflict('team_busy', '工作组中仍有机器人在执行,请稍后再试')
+
+    for (const s of sessionRows) {
+      this.deps.sessions.deleteRecords(s.id)
+      this.deps.forgetSession(s.id)
+      this.sessionIndex.delete(s.id)
+    }
+    this.deps.db.transaction(tx => {
+      tx.delete(teamBotSessions).where(eq(teamBotSessions.teamId, id)).run()
+      tx.delete(teamBots).where(eq(teamBots.teamId, id)).run()
+      tx.delete(teams).where(eq(teams.id, id)).run()
+    })
+    this.events.remove(id)
+    this.deps.files.deleteForSession(id)
+    for (const k of [...this.runtime.keys()]) if (k.startsWith(`${id}/`)) this.runtime.delete(k)
+    await this.deps.files.removeWorkspace(team.workspaceDir, this.deps.settings.get().paths.workspaceRoot)
+  }
+
+  // ---- 消息 ----
+
+  postMessage(id: string, source: string, body: SendTeamMessageBody): TeamMessage {
+    const team = this.getRow(id, source)
+    if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭,不能发送消息')
+    const data = parse(messageSchema, body)
+    const members = this.memberRows(id)
+    const mentions = [...new Set(data.mentions ?? [])]
+    for (const m of mentions) if (!members.some(x => x.botId === m)) throw badRequest(`被 @ 的机器人不在工作组中:${m}`)
+
+    let targets = mentions
+    if (!targets.length) {
+      const lead = members.find(m => m.role === 'lead')
+      if (lead) targets = [lead.botId]
+      else if (members.length === 1) targets = [members[0]!.botId]
+      else throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
+    }
+    const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
+    const message: TeamMessage = {
+      id: newId('tmsg'), sender: { kind: 'human', id: team.human.id, name: team.human.name }, text: data.text, mentions, files, targets,
+    }
+    this.events.append(id, 'message.created', message)
+    if (team.status === 'active') for (const t of targets) this.wake(id, t)
+    return message
+  }
+
+  // ---- 文件 ----
+
+  async upload(id: string, source: string, parts: AsyncIterable<{ filename: string; file: Readable & { truncated: boolean } }>) {
+    const team = this.getRow(id, source)
+    if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭')
+    const limitBytes = this.deps.settings.get().limits.uploadMaxMB * 1048576
+    const saved = []
+    for await (const part of parts) {
+      saved.push(await this.deps.files.saveUpload({
+        sessionId: id, workspaceDir: team.workspaceDir, filename: part.filename, stream: part.file, limitBytes, truncated: () => part.file.truncated,
+      }))
+    }
+    if (!saved.length) throw badRequest('没有收到文件')
+    return saved
+  }
+
+  async listFiles(id: string, source: string) {
+    const team = this.getRow(id, source)
+    await this.scanFiles(team)
+    return this.deps.files.list(id)
+  }
+
+  // ---- 交互与中断 ----
+
+  respond(id: string, source: string, interactionId: string, body: InteractionResponse) {
+    this.getRow(id, source)
+    const own = new Set(this.teamSessionIds(id))
+    this.deps.interactions.respond(interactionId, body, sid => own.has(sid))
+  }
+
+  interrupt(id: string, source: string, botId: string) {
+    this.getRow(id, source)
+    if (!this.memberRows(id).some(m => m.botId === botId)) throw notFound('工作组中的机器人')
+    this.interruptBot(id, botId)
+  }
+
+  pendingInteractions(teamId: string): TeamInteractionDto[] {
+    return this.deps.db.select().from(teamBotSessions).where(eq(teamBotSessions.teamId, teamId)).all()
+      .flatMap(l => this.deps.interactions.pendingFor(l.sessionId).map(request => ({ botId: l.botId, sessionId: l.sessionId, request })))
+  }
+
+  // ---- 唤醒与执行 ----
+
+  /** 唤醒机器人:空闲时立即开始一轮;正在执行时记下,本轮结束后再处理 */
+  private wake(teamId: string, botId: string) {
+    const team = this.findRow(teamId)
+    if (!team || team.status !== 'active') return
+    const rt = this.rt(teamId, botId)
+    if (rt.status !== 'idle' || rt.starting) {
+      rt.pending = true
+      return
+    }
+    rt.pending = false
+    rt.starting = true
+    void this.startRun(team, botId)
+      .catch(err => {
+        this.deps.log.warn({ err, teamId, botId }, 'team bot run failed to start')
+        this.system(teamId, `${this.botName(botId)} 无法开始工作:${(err as Error).message}`, 'error')
+      })
+      .finally(() => {
+        rt.starting = false
+        // 没有开始新一轮(没有发给它的新消息或启动失败)但准备期间又来了消息:再检查一次
+        if (rt.pending && rt.status === 'idle') this.wake(teamId, botId)
+      })
+  }
+
+  /** 自上次以来的新消息;只有发给它的消息才会让它开始一轮。组长能看到组里的全部新消息,组员只看到发给它的 */
+  private unreadFor(teamId: string, botId: string, role: BotDto['kind']) {
+    const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
+    const unread = this.events.list(teamId, link?.lastReadSeq ?? 0, 5000, ['message.created']) as TeamEventOf<'message.created'>[]
+    if (!unread.some(e => e.payload.targets.includes(botId))) return null
+    const relevant = unread.filter(e => !(e.payload.sender.kind === 'bot' && e.payload.sender.botId === botId)
+      && (role === 'lead' || e.payload.targets.includes(botId)))
+    return { relevant, readUpTo: unread.at(-1)!.seq }
+  }
+
+  private async startRun(team: TeamRow, botId: string) {
+    const member = this.memberRows(team.id).find(m => m.botId === botId)
+    if (!member || !this.unreadFor(team.id, botId, member.role)) return
+    const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).get()
+    const lastRead = link?.lastReadSeq ?? 0
+
+    const bot = this.deps.bots.get(botId)
+    if (!bot.enabled) throw new Error('机器人已停用')
+    const allowed = this.deps.botKeys.allowedBots(team.source)
+    if (allowed && !allowed.has(botId)) throw new Error('这个机器人已不再对当前 Bot Key 开放')
+    const profile = this.deps.profiles.get(bot.profileId)
+    const prompt = teamPrompt({ title: team.title, human: team.human, self: bot, bots: this.memberBots(team.id) })
+    const title = `${team.title} · ${bot.name}`
+
+    let session: SessionRow | null = null
+    if (link) {
+      try {
+        session = this.deps.sessions.getRow(link.sessionId)
+      } catch {
+        session = null
+      }
+    }
+    // 机器人换了不同引擎的 Profile:原会话无法续接,开始新会话
+    if (session && session.provider !== profile.provider) {
+      this.sessionIndex.set(session.id, null)
+      session = null
+      this.system(team.id, `${bot.name} 的引擎已更换,开始新的会话(之前的对话记忆不再保留)`, 'warning')
+    }
+    if (!session) {
+      session = this.deps.sessions.createForTeam({ teamId: team.id, profile, workspaceDir: team.workspaceDir, title, systemPromptAppend: prompt })
+      this.deps.db.insert(teamBotSessions).values({ teamId: team.id, botId, sessionId: session.id, lastReadSeq: lastRead })
+        .onConflictDoUpdate({ target: [teamBotSessions.teamId, teamBotSessions.botId], set: { sessionId: session.id } }).run()
+      this.sessionIndex.set(session.id, { teamId: team.id, botId })
+      this.changed(team.id)
+    } else {
+      this.deps.sessions.syncTeamSession(session.id, { profileId: profile.id, model: profile.model, systemPromptAppend: prompt, title })
+      session = this.deps.sessions.getRow(session.id)
+    }
+
+    // 共享目录中其他机器人产生的文件先登记到这个会话的文件索引,本轮结束时只把本轮改动的文件算作它的产出
+    await this.deps.files.scan(session.id, team.workspaceDir)
+    const fresh = this.findRow(team.id)
+    const input = this.unreadFor(team.id, botId, member.role)
+    if (!fresh || fresh.status !== 'active' || !input) return
+    const { relevant, readUpTo } = input
+
+    const attachments: RunAttachment[] = []
+    for (const e of relevant) {
+      for (const f of e.payload.files) {
+        try {
+          const row = this.deps.files.get(f.fileId)
+          if (row.sessionId === team.id) attachments.push({ id: row.id, relPath: row.relPath, mime: row.mime, size: row.size })
+        } catch {
+          // 文件已删除
+        }
+      }
+    }
+    this.deps.runs.start(session, teamInput(relevant, botId), attachments)
+    this.deps.db.update(teamBotSessions).set({ lastReadSeq: readUpTo })
+      .where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).run()
+  }
+
+  /** 把机器人会话中的事件转成工作组的状态、交互与回帖 */
+  private onSessionEvent(e: AgentEvent) {
+    const ref = this.lookup(e.sessionId)
+    if (!ref) return
+    const rt = this.rt(ref.teamId, ref.botId)
+    switch (e.type) {
+      case 'run.queued':
+        Object.assign(rt, { lastText: '', changedPaths: new Set<string>() })
+        this.setBotStatus(ref, e.sessionId, 'queued', e.runId)
+        break
+      case 'run.started':
+        this.setBotStatus(ref, e.sessionId, 'working', e.runId)
+        break
+      case 'message.completed':
+        if (e.payload.text.trim()) rt.lastText = e.payload.text
+        break
+      case 'file.changed':
+        rt.changedPaths.add(e.payload.path)
+        break
+      case 'interaction.required':
+        this.events.append(ref.teamId, 'interaction.required', { botId: ref.botId, sessionId: e.sessionId, request: e.payload })
+        this.setBotStatus(ref, e.sessionId, 'waiting_input', e.runId)
+        break
+      case 'interaction.resolved':
+        this.events.append(ref.teamId, 'interaction.resolved', { botId: ref.botId, sessionId: e.sessionId, ...e.payload })
+        if (rt.status === 'waiting_input' && e.runId && !this.deps.interactions.hasPending(e.runId)) this.setBotStatus(ref, e.sessionId, 'working', e.runId)
+        break
+      case 'run.completed':
+      case 'run.failed':
+      case 'run.interrupted':
+        void this.onRunEnd(ref, e).catch(err => this.deps.log.error({ err, ...ref }, 'team run end handling failed'))
+        break
+    }
+  }
+
+  private async onRunEnd(ref: { teamId: string; botId: string }, e: AgentEvent) {
+    const team = this.findRow(ref.teamId)
+    if (!team) return
+    const rt = this.rt(ref.teamId, ref.botId)
+    const name = this.botName(ref.botId)
+    await this.scanFiles(team)
+    const outputs = this.deps.files.list(team.id).filter(f => rt.changedPaths.has(f.path) && f.path.startsWith('outputs/'))
+    const files: TeamFileRef[] = outputs.map(f => ({ fileId: f.id, path: f.path, mime: f.mime, size: f.size }))
+
+    if (e.type === 'run.completed') {
+      if (rt.lastText || files.length) {
+        const bot = this.findBot(ref.botId)
+        this.events.append(team.id, 'message.created', {
+          id: newId('tmsg'), sender: { kind: 'bot', botId: ref.botId, name, avatar: bot?.avatar ?? '🤖' },
+          text: rt.lastText, mentions: [], files, targets: [], runId: e.runId ?? undefined, sessionId: e.sessionId,
+        })
+      } else {
+        this.system(team.id, `${name} 本轮没有回复`, 'warning', e)
+      }
+    } else if (e.type === 'run.failed') {
+      this.system(team.id, `${name} 执行失败:${e.payload.message}`, 'error', e)
+    } else if (e.type === 'run.interrupted') {
+      this.system(team.id, `${name} 的本轮工作已中断${e.payload.reason === 'restart' ? '(服务重启)' : ''}`, 'warning', e)
+    }
+    this.setBotStatus(ref, e.sessionId, 'idle', null)
+    if (rt.pending) this.wake(ref.teamId, ref.botId)
+  }
+
+  // ---- 内部工具 ----
+
+  private setBotStatus(ref: { teamId: string; botId: string }, sessionId: string, status: TeamBotStatus, runId: string | null) {
+    const rt = this.rt(ref.teamId, ref.botId)
+    rt.status = status
+    rt.runId = runId
+    if (this.findRow(ref.teamId)) this.events.append(ref.teamId, 'bot.status', { botId: ref.botId, status, sessionId, runId })
+  }
+
+  private interruptBot(teamId: string, botId: string) {
+    const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
+    const active = link && this.deps.runs.activeForSession(link.sessionId)
+    if (active) this.deps.runs.interrupt(active.runId)
+    this.rt(teamId, botId).pending = false
+  }
+
+  private system(teamId: string, text: string, level: TeamMessage['level'] = 'info', e?: AgentEvent) {
+    this.events.append(teamId, 'message.created', {
+      id: newId('tmsg'), sender: { kind: 'system' }, text, mentions: [], files: [], targets: [], level,
+      ...(e ? { runId: e.runId ?? undefined, sessionId: e.sessionId } : {}),
+    })
+  }
+
+  private changed(teamId: string) {
+    const row = this.findRow(teamId)
+    if (row) this.events.append(teamId, 'team.updated', this.toDto(row))
+  }
+
+  private async scanFiles(team: TeamRow) {
+    for (const c of await this.deps.files.scan(team.id, team.workspaceDir)) this.events.append(team.id, 'file.changed', c)
+  }
+
+  private fileRef(teamId: string, fileId: string): TeamFileRef {
+    const row = this.deps.files.get(fileId)
+    if (row.sessionId !== teamId) throw notFound('文件')
+    return { fileId: row.id, path: row.relPath, mime: row.mime, size: row.size }
+  }
+
+  /** 来源可以使用、已启用、类型符合的机器人 */
+  private usableBot(source: string, botId: string, kind?: BotDto['kind']): BotDto {
+    const bot = this.deps.bots.get(botId)
+    const allowed = this.deps.botKeys.allowedBots(source)
+    if (allowed && !allowed.has(botId)) throw new AppError(403, 'bot_not_allowed', `无权使用机器人:${bot.name}`)
+    if (!bot.enabled) throw badRequest(`机器人已停用:${bot.name}`)
+    if (kind && bot.kind !== kind) throw badRequest(kind === 'lead' ? `${bot.name} 不是组长类型的机器人` : `${bot.name} 是组长类型的机器人,请作为组长加入`)
+    return bot
+  }
+
+  private teamSessionIds(teamId: string): string[] {
+    return this.deps.db.select({ id: sessions.id }).from(sessions).where(eq(sessions.source, `${TEAM_SOURCE_PREFIX}${teamId}`)).all().map(r => r.id)
+  }
+
+  private memberRows(teamId: string) {
+    return this.deps.db.select().from(teamBots).where(eq(teamBots.teamId, teamId)).all()
+  }
+
+  private memberBots(teamId: string): BotDto[] {
+    const ids = this.memberRows(teamId).map(m => m.botId)
+    return ids.length ? this.deps.bots.list().filter(b => ids.includes(b.id)) : []
+  }
+
+  private findRow(id: string): TeamRow | undefined {
+    return this.deps.db.select().from(teams).where(eq(teams.id, id)).get()
+  }
+
+  private findBot(id: string): BotDto | null {
+    try {
+      return this.deps.bots.get(id)
+    } catch {
+      return null
+    }
+  }
+
+  private botName(id: string) {
+    return this.findBot(id)?.name ?? '(已删除的机器人)'
+  }
+
+  private rt(teamId: string, botId: string): BotRuntime {
+    let r = this.runtime.get(key(teamId, botId))
+    if (!r) {
+      r = { status: 'idle', runId: null, pending: false, starting: false, lastText: '', changedPaths: new Set() }
+      this.runtime.set(key(teamId, botId), r)
+    }
+    return r
+  }
+
+  private lookup(sessionId: string) {
+    if (this.sessionIndex.has(sessionId)) return this.sessionIndex.get(sessionId)!
+    const link = this.deps.db.select().from(teamBotSessions).where(eq(teamBotSessions.sessionId, sessionId)).get()
+    const ref = link ? { teamId: link.teamId, botId: link.botId } : null
+    this.sessionIndex.set(sessionId, ref)
+    return ref
+  }
+}
+
+const key = (teamId: string, botId: string) => `${teamId}/${botId}`

+ 25 - 3
apps/server/test/api-reference.test.ts

@@ -2,11 +2,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import pino from 'pino'
 import { createApp, type App } from '../src/app'
 import { API_EVENTS, API_GROUPS } from '../src/api-reference/reference'
+import { BOT_EVENTS, BOT_GROUPS } from '../src/api-reference/bot-reference'
 import { renderApiReferenceHtml } from '../src/api-reference/render'
 import { findChrome } from '../src/api-reference/pdf'
 import { buildServer } from '../src/http/server'
 import { registeredV1Routes } from '../src/http/routes/v1'
-import type { EventPayloads } from '@agent-server/shared'
+import { registeredBotRoutes } from '../src/teams/routes'
+import type { EventPayloads, TeamEventPayloads } from '@agent-server/shared'
 
 let app: App
 let server: Awaited<ReturnType<typeof buildServer>>
@@ -26,6 +28,21 @@ describe('API 协议文档', () => {
     expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
   })
 
+  it('Bot API:与实际注册的 /bot/v1 路由完全一致(新增或删除接口时必须同步修改 bot-reference.ts)', () => {
+    const key = (m: string, p: string) => `${m} ${p}`
+    const actual = new Set(registeredBotRoutes.map(r => key(r.method, r.path)))
+    const documented = new Set(BOT_GROUPS.flatMap(g => g.endpoints.map(e => key(e.method, e.path))))
+    expect([...actual].filter(x => !documented.has(x)), '未写入文档的接口').toEqual([])
+    expect([...documented].filter(x => !actual.has(x)), '文档中有、但实际不存在的接口').toEqual([])
+  })
+
+  it('Bot API 事件类型与工作组事件模型一致', () => {
+    const all: Record<keyof TeamEventPayloads, true> = {
+      'message.created': true, 'bot.status': true, 'interaction.required': true, 'interaction.resolved': true, 'file.changed': true, 'team.updated': true,
+    }
+    expect(BOT_EVENTS.map(e => e.type).sort()).toEqual(Object.keys(all).sort())
+  })
+
   it('事件类型与统一事件模型一致', () => {
     // EventPayloads 的键在类型层面,这里用一个完整的对象字面量确保编译期同步
     const all: Record<keyof EventPayloads, true> = {
@@ -39,6 +56,10 @@ describe('API 协议文档', () => {
   it('渲染:示例使用传入的服务地址;转义特殊字符;没有脚本', () => {
     const html = renderApiReferenceHtml({ baseUrl: 'https://agent.example.com/', theme: 'dark', version: 'v1', generatedAt: new Date('2026-10-23T00:00:00Z') })
     expect(html).toContain('curl -X POST https://agent.example.com/v1/sessions')
+    // 两套协议都在同一份文档中
+    expect(html).toContain('第 1 部分 · Agent API')
+    expect(html).toContain('第 2 部分 · Bot API')
+    expect(html).toContain('https://agent.example.com/bot/v1/teams')
     expect(html).not.toContain('$BASE')
     expect(html).toContain('data-theme="dark"')
     expect(html).toContain('&lt;凭证&gt;')
@@ -59,8 +80,9 @@ describe('API 协议文档', () => {
   it('调试:协议 JSON 含概述与示例请求体;以平台身份调试时签发 10 分钟令牌,停用的 Key 不能调试', async () => {
     const cookies = { agent_admin: app.adminAuth.login('test-password').id }
     const ref = (await server.inject({ method: 'GET', url: '/admin/v1/api-reference.json', cookies })).json()
-    expect(ref.overview.points.length).toBeGreaterThan(3)
-    expect(ref.groups.flatMap((g: { endpoints: { path: string; sampleBody?: unknown }[] }) => g.endpoints).find((e: { path: string }) => e.path === '/v1/sessions/:id/messages').sampleBody).toHaveProperty('text')
+    expect(ref.specs.map((s: { id: string }) => s.id)).toEqual(['agent', 'bot'])
+    expect(ref.specs[0].overview.points.length).toBeGreaterThan(3)
+    expect(ref.specs[0].groups.flatMap((g: { endpoints: { path: string; sampleBody?: unknown }[] }) => g.endpoints).find((e: { path: string }) => e.path === '/v1/sessions/:id/messages').sampleBody).toHaveProperty('text')
 
     const builtin = app.profiles.getBuiltin().id
     const k = app.apiKeys.create('调试', { allowedProfileIds: [builtin], defaultProfileId: builtin })

+ 290 - 0
apps/server/test/teams.test.ts

@@ -0,0 +1,290 @@
+// Bot 工作组一期(docs/design/13-bot-teams.md §13):Bot Key 与鉴权隔离、组成规则、唤醒规则、顺序执行与合并、审批、生命周期
+import fs from 'node:fs'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { ProviderStatusDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { buildServer } from '../src/http/server'
+import type { AgentProvider, RunContext } from '../src/providers/types'
+
+/** 假引擎:回复"收到:<输入最后一行>";输入含"审批"时请求审批;含"慢"时等待 release();把产出写到 outputs/ */
+class FakeProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  runs: RunContext[] = []
+  private gates: (() => void)[] = []
+  release() {
+    for (const g of this.gates.splice(0)) g()
+  }
+  async run(ctx: RunContext) {
+    this.runs.push(ctx)
+    ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
+    if (ctx.input.text.includes('慢')) {
+      await new Promise<void>((resolve, reject) => {
+        this.gates.push(resolve)
+        ctx.signal.addEventListener('abort', () => reject(new Error('aborted')))
+      })
+    }
+    if (ctx.input.text.includes('审批')) {
+      const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'deny'] })
+      if (outcome.resolution !== 'allowed') throw new Error('denied')
+      fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
+      fs.writeFileSync(path.join(ctx.workspaceDir, `outputs/${ctx.sessionId}.md`), '# 报告')
+      ctx.emit('file.changed', { fileId: 'x', path: `outputs/${ctx.sessionId}.md`, kind: 'created', size: 8, mime: 'text/markdown' })
+    }
+    const last = ctx.input.text.trim().split('\n').filter(Boolean).at(-1) ?? ''
+    ctx.emit('message.completed', { messageId: `m${this.runs.length}`, text: `收到:${last}` })
+    return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
+  }
+  async status(): Promise<ProviderStatusDto> {
+    return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
+  }
+}
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+const fake = new FakeProvider()
+let adminCookie: Record<string, string>
+let botKey: string
+let apiKey: string
+const ids: Record<string, string> = {}
+const bk = () => ({ authorization: `Bearer ${botKey}` })
+const human = { id: 'u1', name: '张三' }
+
+async function waitFor<T>(fn: () => T | undefined | false, ms = 3000): Promise<T> {
+  const end = Date.now() + ms
+  while (Date.now() < end) {
+    const v = fn()
+    if (v) return v
+    await new Promise(r => setTimeout(r, 20))
+  }
+  throw new Error('timeout')
+}
+
+const messages = (teamId: string) =>
+  app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
+const botReplies = (teamId: string, botId: string) => messages(teamId).filter(m => m.sender.kind === 'bot' && m.sender.botId === botId)
+const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle')
+
+async function createTeam(body: object, headers = bk()) {
+  const res = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers, payload: { human, ...body } })
+  return res
+}
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: fake } })
+  server = await buildServer(app, { logger: log })
+  const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
+  const c = login.cookies[0]!
+  adminCookie = { [c.name]: c.value }
+  const profileId = app.profiles.getBuiltin().id
+  for (const [name, kind] of [['数据员', 'worker'], ['文档员', 'worker'], ['组长', 'lead'], ['外部员', 'worker']] as const) {
+    const res = await server.inject({
+      method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
+      payload: { name, avatar: '🤖', kind, description: `${name}的岗位`, profileId, enabled: true },
+    })
+    expect(res.statusCode).toBe(201)
+    ids[name] = res.json().id
+  }
+  const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['数据员'], ids['文档员'], ids['组长']] } })
+  expect(key.statusCode).toBe(201)
+  botKey = key.json().key
+  expect(botKey).toMatch(/^bk_/)
+  apiKey = app.apiKeys.create('agent', { allowedProfileIds: [profileId], defaultProfileId: profileId }).key
+})
+afterAll(() => server.close())
+
+describe('Bot Key 与 API Key 互不通用(ADR-0024)', () => {
+  it('Bot Key 不能访问 /v1,API Key 不能访问 /bot/v1', async () => {
+    expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: bk() })).statusCode).toBe(401)
+    expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: { authorization: `Bearer ${apiKey}` } })).statusCode).toBe(401)
+    expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: bk() })).statusCode).toBe(200)
+  })
+
+  it('两种浏览器令牌不能互换', async () => {
+    const botToken = (await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: bk(), payload: {} })).json().token as string
+    const agentToken = (await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${apiKey}` }, payload: {} })).json().token as string
+    expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(200)
+    expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(401)
+    expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${agentToken}` } })).statusCode).toBe(401)
+    // 浏览器令牌不能再签发令牌
+    expect((await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: { authorization: `Bearer ${botToken}` }, payload: {} })).statusCode).toBe(403)
+  })
+
+  it('只列出 Key 允许的机器人;后台 Cookie 可以访问 Bot API', async () => {
+    const names = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json().map((b: { name: string }) => b.name)
+    expect(names.sort()).toEqual(['数据员', '文档员', '组长'].sort())
+    const all = (await server.inject({ method: 'GET', url: '/bot/v1/bots', cookies: adminCookie })).json()
+    expect(all).toHaveLength(4)
+  })
+})
+
+describe('组成规则', () => {
+  it('至少一个机器人;组长和组员类型要对;Key 不允许的机器人返回 403', async () => {
+    expect((await createTeam({})).statusCode).toBe(400)
+    expect((await createTeam({ leadBotId: ids['数据员'] })).statusCode).toBe(400)
+    expect((await createTeam({ workerBotIds: [ids['组长']] })).statusCode).toBe(400)
+    expect((await createTeam({ workerBotIds: [ids['外部员']] })).statusCode).toBe(403)
+  })
+
+  it('不能移除最后一个机器人,不能有两个组长', async () => {
+    const t = (await createTeam({ leadBotId: ids['组长'] })).json() as TeamDetailDto
+    expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}/bots/${ids['组长']}`, headers: bk() })).statusCode).toBe(400)
+    const added = await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/bots`, headers: bk(), payload: { botId: ids['数据员'] } })
+    expect(added.statusCode).toBe(200)
+    expect(added.json().bots.map((b: { role: string }) => b.role)).toEqual(['lead', 'worker'])
+  })
+
+  it('其他 Key 看不到这个工作组', async () => {
+    const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
+    const other = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台B', allowedBotIds: [ids['数据员']] } })
+    const res = await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: { authorization: `Bearer ${other.json().key}` } })
+    expect(res.statusCode).toBe(404)
+  })
+})
+
+describe('消息与执行', () => {
+  let team: TeamDetailDto
+
+  beforeAll(async () => {
+    team = (await createTeam({ title: '测试组', workerBotIds: [ids['数据员'], ids['文档员']] })).json()
+  })
+
+  it('多个组员且没有组长时必须 @', async () => {
+    const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '大家好' } })
+    expect(res.statusCode).toBe(400)
+    expect(res.json().error.code).toBe('mention_required')
+  })
+
+  it('@ 组员:在共享目录中执行,回复发到工作组;只唤醒被 @ 的机器人', async () => {
+    const before = fake.runs.length
+    const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '@数据员 统计一下', mentions: [ids['数据员']] } })
+    expect(res.statusCode).toBe(201)
+    const reply = await waitFor(() => botReplies(team.id, ids['数据员']!)[0])
+    expect(reply.text).toContain('统计一下')
+    expect(fake.runs.length).toBe(before + 1)
+    const ctx = fake.runs.at(-1)!
+    expect(ctx.workspaceDir).toBe(app.teams.getRow(team.id).workspaceDir)
+    expect(ctx.systemPromptAppend).toContain('工作组「测试组」中的机器人「数据员」')
+    expect(ctx.input.text).toContain('【工作组新消息】')
+    expect(botReplies(team.id, ids['文档员']!)).toHaveLength(0)
+    await waitFor(() => idle(team.id))
+  })
+
+  it('再次 @ 同一个组员时续接同一个会话,只交给它新消息', async () => {
+    const first = app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['数据员'])!.sessionId
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '再按月份分一下', mentions: [ids['数据员']] } })
+    await waitFor(() => botReplies(team.id, ids['数据员']!).length === 2)
+    const ctx = fake.runs.at(-1)!
+    expect(ctx.sessionId).toBe(first)
+    expect(ctx.input.text).toContain('再按月份分一下')
+    expect(ctx.input.text).not.toContain('统计一下')
+    await waitFor(() => idle(team.id))
+  })
+
+  it('执行期间收到的消息在本轮结束后合并成下一轮', async () => {
+    const send = (text: string) => server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text, mentions: [ids['文档员']] } })
+    const before = fake.runs.length
+    await send('慢一点写')
+    await waitFor(() => app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['文档员'])!.status === 'working')
+    await send('补充一')
+    await send('补充二')
+    expect(fake.runs.length).toBe(before + 1)
+    fake.release()
+    await waitFor(() => botReplies(team.id, ids['文档员']!).length === 2)
+    expect(fake.runs.length).toBe(before + 2)
+    const merged = fake.runs.at(-1)!.input.text
+    expect(merged).toContain('补充一')
+    expect(merged).toContain('补充二')
+    expect(merged).not.toContain('慢一点写')
+    await waitFor(() => idle(team.id))
+  })
+
+  it('审批卡片显示在工作组中,由成员处理;交付文件出现在回复中', async () => {
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '这个需要审批', mentions: [ids['文档员']] } })
+    const pending = await waitFor(() => app.teams.pendingInteractions(team.id)[0])
+    expect(pending.botId).toBe(ids['文档员'])
+    const detail = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}`, headers: bk() })).json() as TeamDetailDto
+    expect(detail.pendingInteractions).toHaveLength(1)
+    expect(detail.bots.find(b => b.id === ids['文档员'])!.status).toBe('waiting_input')
+    const events = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/events`, headers: bk() })).json().items as TeamEvent[]
+    expect(events.some(e => e.type === 'interaction.required')).toBe(true)
+
+    const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } })
+    expect(res.statusCode).toBe(200)
+    const reply = await waitFor(() => botReplies(team.id, ids['文档员']!).find(m => m.files.length))
+    expect(reply.files[0]!.path).toMatch(/^outputs\/.*\.md$/)
+    const file = await server.inject({ method: 'GET', url: `/bot/v1/files/${reply.files[0]!.fileId}`, headers: bk() })
+    expect(file.statusCode).toBe(200)
+    expect(file.body).toBe('# 报告')
+    await waitFor(() => idle(team.id))
+  })
+
+  it('工作组中的会话不能经 /v1 直接发消息或删除', async () => {
+    const sid = app.teams.toDto(app.teams.getRow(team.id)).bots[0]!.sessionId!
+    expect((await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, cookies: adminCookie, payload: { text: 'x' } })).statusCode).toBe(400)
+    expect((await server.inject({ method: 'DELETE', url: `/v1/sessions/${sid}`, cookies: adminCookie })).statusCode).toBe(400)
+  })
+
+  it('上传文件作为附件交给机器人', async () => {
+    const boundary = 'xBx'
+    const up = await server.inject({
+      method: 'POST', url: `/bot/v1/teams/${team.id}/files`,
+      headers: { ...bk(), 'content-type': `multipart/form-data; boundary=${boundary}` },
+      payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n\r\n--${boundary}--\r\n`,
+    })
+    expect(up.statusCode).toBe(201)
+    const fileId = up.json()[0].id
+    const replies = botReplies(team.id, ids['数据员']!).length
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '看看附件', mentions: [ids['数据员']], fileIds: [fileId] } })
+    await waitFor(() => fake.runs.at(-1)?.input.attachments.some(a => a.path === 'uploads/a.csv'))
+    expect(fake.runs.at(-1)!.input.text).toContain('附件:uploads/a.csv')
+    await waitFor(() => idle(team.id))
+    // 文档员之前产生的 outputs/ 文件不能算到数据员的这一轮上
+    await waitFor(() => botReplies(team.id, ids['数据员']!).length === replies + 1)
+    expect(botReplies(team.id, ids['数据员']!).at(-1)!.files).toEqual([])
+  })
+})
+
+describe('默认处理人与生命周期', () => {
+  it('只有一个组员时不 @ 也交给它;有组长时交给组长', async () => {
+    const solo = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${solo.id}/messages`, headers: bk(), payload: { text: '你好' } })
+    await waitFor(() => botReplies(solo.id, ids['数据员']!)[0])
+
+    const led = (await createTeam({ leadBotId: ids['组长'], workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${led.id}/messages`, headers: bk(), payload: { text: '安排一下' } })
+    await waitFor(() => botReplies(led.id, ids['组长']!)[0])
+    expect(botReplies(led.id, ids['数据员']!)).toHaveLength(0)
+    await waitFor(() => idle(led.id))
+  })
+
+  it('暂停时消息只记录,恢复后处理;关闭后不能发消息;删除清理目录与会话', async () => {
+    const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/pause`, headers: bk() })).json().status).toBe('paused')
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: '暂停期间的消息' } })
+    await new Promise(r => setTimeout(r, 100))
+    expect(botReplies(t.id, ids['数据员']!)).toHaveLength(0)
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/resume`, headers: bk() })
+    const reply = await waitFor(() => botReplies(t.id, ids['数据员']!)[0])
+    expect(reply.text).toContain('暂停期间的消息')
+    await waitFor(() => idle(t.id))
+
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/close`, headers: bk() })
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: 'x' } })).statusCode).toBe(409)
+
+    const dir = app.teams.getRow(t.id).workspaceDir
+    const sid = app.teams.toDto(app.teams.getRow(t.id)).bots[0]!.sessionId!
+    expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(204)
+    expect(fs.existsSync(dir)).toBe(false)
+    expect(() => app.sessions.getRow(sid)).toThrow()
+    expect((await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(404)
+  })
+
+  it('还在工作组中的机器人不能删除;用量记到 Bot Key 上', async () => {
+    expect((await server.inject({ method: 'DELETE', url: `/admin/v1/bots/${ids['数据员']}`, cookies: adminCookie })).statusCode).toBe(400)
+    const usage = app.usage.summary({ days: 1 })
+    expect(usage.bySource.some(s => s.name === '平台A' && s.runs > 0)).toBe(true)
+  })
+})

+ 5 - 4
docs/README.md

@@ -23,7 +23,7 @@ docs/
 │  ├─ 10-deployment.md        部署与运维
 │  ├─ 11-roadmap.md           里程碑与待决事项
 │  ├─ 12-custom-skills.md     自定义技能(导入或在后台编写,两个引擎共用)
-│  └─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(设计中)
+│  └─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期已实施)
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```
@@ -54,8 +54,8 @@ docs/
 | [0020](adr/0020-codex-fork-on-config-change.md) | Codex 会话配置变化时分叉 | Accepted |
 | [0021](adr/0021-third-party-mcp-via-gateway.md) | 第三方 MCP 服务经 MCP Gateway 转接 | Accepted |
 | [0022](adr/0022-managed-skill-dirs-readable-in-sandbox.md) | 服务管理的技能目录在沙箱中只读可见;Codex 的禁止规则绕开可读路径 | Accepted |
-| [0023](adr/0023-bot-teams-collaboration-model.md) | Bot 工作组:协调者模式,每个机器人一个独立会话 | Proposed |
-| [0024](adr/0024-bot-api-separate-keys.md) | Bot API 使用独立路径和独立的 Bot Key | Proposed |
+| [0023](adr/0023-bot-teams-collaboration-model.md) | Bot 工作组:协调者模式,每个机器人一个独立会话 | Accepted |
+| [0024](adr/0024-bot-api-separate-keys.md) | Bot API 使用独立路径和独立的 Bot Key | Accepted |
 
 ## 调研
 
@@ -67,7 +67,8 @@ docs/
 | 术语 | 含义 |
 |---|---|
 | Provider / 引擎 | 底层 Agent 实现:`claude` 或 `codex` |
-| Platform / 平台 | 接入 AgentServer 的一个上层业务前端(持有一个 API Key);服务不区分终端使用者 |
+| Platform / 平台 | 接入 AgentServer 的一个上层业务前端(持有 Agent Key 和/或 Bot Key);服务不区分终端使用者 |
+| Agent Key / Bot Key | 后台"API Keys"页面签发的两类密钥:Agent Key(`ask_`,原名 API Key)只能调用 `/v1`,按 Profile 授权;Bot Key(`bk_`)只能调用 `/bot/v1`,按机器人授权(ADR-0024) |
 | Session / 会话 | 一段多轮对话,绑定一个 Provider、一个 Workspace |
 | Run | 会话中的一次"用户发言 → Agent 完成"的执行过程 |
 | Event | Run 过程中产生的统一事件(文本增量、工具调用、交互请求…) |

+ 1 - 1
docs/adr/0023-bot-teams-collaboration-model.md

@@ -1,6 +1,6 @@
 # ADR-0023 Bot 工作组的协作模型
 
-- 状态:Proposed · 日期:2026-10-26 · 设计见 [13-bot-teams](../design/13-bot-teams.md)
+- 状态:Accepted · 日期:2026-10-26 · 设计见 [13-bot-teams](../design/13-bot-teams.md)
 
 ## 背景
 需要在后台配置多个不同岗位的机器人。使用者把机器人拉到一起下达命令;复杂任务由一个管理机器人拆分、分派给其他机器人完成,简单工作也可以直接指定某个机器人去做。现有内核只支持"一个人对一个 Agent",引擎(Claude / Codex)的会话也都是单 Agent 的。

+ 1 - 1
docs/adr/0024-bot-api-separate-keys.md

@@ -1,6 +1,6 @@
 # ADR-0024 Bot API 与独立的 Bot Key
 
-- 状态:Proposed · 日期:2026-10-26 · 补充 ADR-0015、ADR-0019 · 设计见 [13-bot-teams](../design/13-bot-teams.md)
+- 状态:Accepted · 日期:2026-10-26 · 补充 ADR-0015、ADR-0019 · 设计见 [13-bot-teams](../design/13-bot-teams.md)
 
 ## 背景
 Bot 工作组(ADR-0023)通过 API 对外提供。它和现有的 Agent API(`/v1`,会话、消息)是两类能力,使用方和权限范围都不同:Agent API 的 Key 按 Profile 授权,Bot 的 Key 应当按机器人授权。两者不应该互相通用。

+ 2 - 2
docs/design/11-roadmap.md

@@ -30,8 +30,8 @@
 - 工作区自动清理、回归用例批量重放
 - 网络加强(nftables 按 cgroup 拦截局域网)
 
-### M4:Bot 工作组(设计中,见 13、ADR-0023/0024)
-- 一期:机器人定义、Bot Key 与 `/bot/v1`、工作组与消息、@ 组员的单项工作、审批卡片;`BotClient`、`<TeamChat>`;Bot Playground 基本形态
+### M4:Bot 工作组(一期 ✅ 2026-10-26,见 13、ADR-0023/0024)
+- ✅ 一期:机器人定义、Bot Key 与 `/bot/v1`、工作组与消息、@ 组员的单项工作、审批卡片;`BotClient`、`<TeamChat>`;Bot Playground 基本形态
 - 二期:组长、能力卡片、任务与子任务、限额与暂停、组长申请加入机器人;Playground 的替身组员、逐步执行、任务 / 输入 / 用量页签
 - 三期:`BotClient`、`<TeamChat>` 随 SDK 发布与接入示例;复制到 Playground、回归用例
 

+ 34 - 14
docs/design/13-bot-teams.md

@@ -1,6 +1,6 @@
 # 13 Bot 工作组
 
-> 状态:**设计中(2026-10-26)**,尚未实施。决策见 ADR-0023(工作组协作模型)、ADR-0024(Bot API 与独立的 Bot Key)。
+> 状态:**一期已实施(2026-10-26)**,二期、三期待做;实施中的调整见 §15。决策见 ADR-0023(工作组协作模型)、ADR-0024(Bot API 与独立的 Bot Key)。
 
 ## 1. 背景与目标
 
@@ -64,12 +64,13 @@
 
 - 每个"机器人 × 工作组"对应一个独立的引擎 Session(`team_bot_sessions`),机器人的记忆就是这个 Session。离开工作组后不带走,也不跨工作组。
 - Session 的来源(`sessions.source`)记为 `team:<teamId>`,不会出现在 Agent API 的会话列表中。
-- 每个 Session 有自己的工作区,另外把工作组的**共享目录**作为额外的可写路径,通过三层访问策略合并进去(ADR-0010):
+- 组内所有机器人会话的**工作目录就是工作组的共享目录**(实施时的调整,见 §15)。共享目录与会话工作区同级,沿用现有的访问策略、systemd 可写目录和删除保护:
 
 ```
-<dataDir>/teams/<teamId>/shared/
-├─ uploads/     成员上传的文件
-└─ outputs/     各机器人交付的文件(消息里以文件卡片显示)
+<workspaceRoot>/<teamId>/
+├─ uploads/       成员上传的文件
+├─ outputs/       各机器人交付的文件(消息里以文件卡片显示)
+└─ .work/<机器人>/ 各机器人的中间文件
 ```
 
 - 同一个机器人在同一个工作组内**按顺序执行**。执行期间到达的新消息会合并起来,在下一轮一起交给它。不同机器人之间并行执行,受 RunManager 的全局和按引擎的并发限制。
@@ -78,7 +79,7 @@
 
 ### 4.1 消息
 
-工作组的消息记录(`team_messages`)是成员看到的时间线,和机器人内部的执行过程(引擎事件)分开存放:
+工作组的消息记录(`team_events` 中的 `message.created` 事件)是成员看到的时间线,和机器人内部的执行过程(引擎事件)分开存放:
 
 | 发送者 | 消息类型 |
 |---|---|
@@ -201,17 +202,18 @@ Gateway 根据 Run 令牌中的 `teamId`、`botId` 和角色,只列出该角
 |---|---|
 | `bots` | id、name、avatar、kind(lead / worker)、description(岗位描述)、profileId、enabled、时间戳 |
 | `bot_api_keys` | id、name、keyHash、keyPrefix(`bk_`)、enabled、allowedBotIds、时间戳 |
-| `teams` | id、source(Bot Key id 或 admin)、title、human(`{id, name}`)、status、limits、debug(`{stepMode, stubBotIds}`,仅 admin,见 §12.2)、sharedDir、时间戳 |
+| `teams` | id、source(Bot Key id 或 admin)、title、human(`{id, name}`)、status、workspaceDir(共享目录)、lastSeq、时间戳;二期增加 limits、debug(`{stepMode, stubBotIds}`,仅 admin,见 §12.2) |
 | `team_bots` | teamId、botId、role(lead / worker)、joinedAt |
 | `team_bot_sessions` | teamId、botId → sessionId、lastReadSeq |
-| `team_messages` | teamId、seq、sender(human / bot / system)、senderId、kind、text、mentions、fileIds、jobId、taskId、runId、ts |
+| `team_events` | teamId、seq、type、payload、ts。消息是 `message.created` 事件(sender、text、mentions、files、targets、runId);状态类事件(`bot.status`、`file.changed`、`team.updated`)只在内存缓冲中 |
 | `jobs` | id、teamId、title、plan、status、summary、用量合计、限额计数、时间戳 |
 | `tasks` | id、jobId、teamId、botId、title、instructions、dependsOn、status、result、fileIds、runId、时间戳 |
 
 现有表的改动:
 - `sessions.source` 增加 `team:<id>`
-- `interactions` 增加可为空的 `teamId`,用于在工作组事件流中推送和查询
-- `usage_daily` 的 source 增加 Bot Key 维度;另外按 botId 汇总,用于后台统计
+- `interactions` 不改:通过机器人会话关联到工作组
+- `usage_daily` 的 source 记为工作组的来源(Bot Key id 或 admin);按 botId 汇总留到二期
+- `files` 不改:工作组共享目录的文件以 teamId 登记(`files.session_id = teamId`),只能经 `/bot/v1/files/:id` 下载
 
 ## 9. Bot API
 
@@ -259,12 +261,13 @@ SSE 事件:
 |---|---|
 | 引擎适配器、沙箱 | 不改 |
 | Profile | 不改,机器人引用 Profile |
-| 文件访问策略 | 合并时加入共享目录(可写),属于基线层,Profile 不能取消 |
-| RunManager | 新增由调度器发起 Run 的入口;同一机器人 Session 按顺序执行已经满足 |
+| 文件访问策略 | 不改:共享目录就是机器人会话的工作目录 |
+| RunManager | 新增 `start()` 入口(附件是共享目录中的文件);`/v1` 不能向工作组的会话发消息;用量来源可由工作组换成 Bot Key |
+| 会话服务 | `createForTeam()`;工作组的会话不能经 `/v1` 修改或删除,删除工作组时只删记录、不删共享目录以外的东西 |
 | MCP Gateway | Run 令牌带上 teamId、botId、role、jobId、taskId;新增 §5.4 的协作工具;`team_request_bot` 复用 Gateway 内发起审批的现有流程 |
-| 交互服务 | 交互关联 teamId,推送到工作组事件流 |
+| 交互服务 | 不改:工作组服务订阅机器人会话的事件,转成工作组的 `interaction.*` 事件 |
 | 鉴权 | 按路径前缀区分 Key 类型(ADR-0024) |
-| 后台 | 新增"机器人""Bot Keys""工作组"三个页面(§12) |
+| 后台 | 一期新增"机器人"(含 Bot Keys 页签)和"Bot Playground"两个页面(§12) |
 
 新增模块:`apps/server/src/teams/`,包括工作组服务、消息服务、调度器、协作工具、能力卡片和 Bot API 路由。
 
@@ -356,3 +359,20 @@ SSE 事件:
 | T2 | 组长能否申请移除组员 | 一期、二期不支持,由成员操作 |
 | T3 | 工作组长期运行后,组长的 Session 越来越长 | 依赖引擎自身的压缩;任务结束时由组长写一份任务纪要到共享目录,以后再观察是否需要每个任务重新开一个组长 Session |
 | T4 | 能力卡片里是否列出全部 MCP 工具 | 只列服务名和说明,工具较多时不展开 |
+
+## 15. 一期实施记录(2026-10-26)
+
+已实现:机器人与 Bot Key、`/bot/v1` 全部一期接口与鉴权隔离、工作组组成规则与生命周期、唤醒规则、同一机器人顺序执行与消息合并、审批卡片、共享目录与文件、工作组 SSE;`BotClient`、`<TeamChat>`;后台"机器人"页面与 Bot Playground(时间线、成员、过程、文件页签)。组长在一期只按普通机器人回复(任务与协作工具在二期)。
+
+与前文设计相比的调整:
+
+| 设计 | 实施 | 原因 |
+|---|---|---|
+| 每个会话有自己的工作区,共享目录作为额外可写路径 | 会话的工作目录就是共享目录 `<workspaceRoot>/<teamId>` | 系统提示词中"工作目录 / uploads / outputs"的约定保持一致;沿用现有访问策略、systemd 可写目录与删除保护,不需要改访问策略 |
+| `team_messages` 表 | `team_events` 表,消息是其中的 `message.created` 事件 | 消息、交互卡片与状态共用一个 seq,SSE 断线续传与会话事件流一致 |
+| `interactions` 增加 teamId | 不改表 | 由机器人会话关联到工作组即可 |
+
+实现要点:
+- 每轮开始前先把共享目录同步到该机器人会话的文件索引,本轮结束时只有本轮改动的 `outputs/` 文件挂到它的回复上。两个机器人同时改动文件时仍可能互相计入,二期再细化。
+- 服务重启时未完成的工作被标记为中断;没有交给机器人的消息不会自动重新处理,成员再次发消息时一并交给它。
+- 已用真实 Claude(haiku)验收:两个组员并行处理、其中一个触发审批并在工作组中批准、交付文件以卡片显示、续接会话保留记忆;Bot Key 访问 `/v1` 返回 401。

+ 7 - 7
docs/integration.md

@@ -8,21 +8,21 @@
 浏览器(平台前端)
   │  ① POST /api/agent-token(平台自己的接口,校验平台登录态)
   ▼
-平台后端 ── ② POST /v1/browser-tokens(携带 API Key)──► AgentServer
+平台后端 ── ② POST /v1/browser-tokens(携带 Agent Key)──► AgentServer
   │  返回 { token: "bt_…", expiresAt }(默认 1 小时)
   ▼
 浏览器 ── ③ 用 bt_ 令牌直接调用 AgentServer 的 /v1(跨域,SSE 流式)──► AgentServer
 ```
 
-- **API Key 只放在平台后端**,不要写进前端代码或前端环境变量。
-- 浏览器令牌的权限与 API Key 相同:只能使用该 Key 可用的 Profile,只能看到该 Key 创建的会话。停用或删除 Key 后,令牌立即失效。
-- 一个平台一个 API Key,AgentServer 不区分平台内的具体用户。如果需要区分,在平台侧记录会话 ID 与用户的对应关系。
+- **Agent Key 只放在平台后端**,不要写进前端代码或前端环境变量。
+- 浏览器令牌的权限与 Agent Key 相同:只能使用该 Key 可用的 Profile,只能看到该 Key 创建的会话。停用或删除 Key 后,令牌立即失效。
+- 一个平台一个 Agent Key,AgentServer 不区分平台内的具体用户。如果需要区分,在平台侧记录会话 ID 与用户的对应关系。
 
 ## 1. 在 AgentServer 后台准备
 
-1. **API Keys › 签发 API Key**:勾选这个平台可以使用的 Profile,并设好默认 Profile。Key 只显示一次,存到平台后端的密钥配置里。
+1. **API Keys › Agent Keys › 签发 Agent Key**:勾选这个平台可以使用的 Profile,并设好默认 Profile。Key 只显示一次,存到平台后端的密钥配置里。
 2. **设置 › 安全 › CORS 来源**:加入平台前端的地址,例如 `https://tools.example.com`(只写协议、域名和端口,不带路径)。
-3. **API Keys** 页面底部的"业务平台接入"卡片里有当前 SDK 版本的安装命令,可以直接复制。
+3. **Agent Keys** 页面底部的"业务平台接入"卡片里有当前 SDK 版本的安装命令,可以直接复制。
 
 ## 2. 平台后端:签发浏览器令牌
 
@@ -148,7 +148,7 @@ await client.usage(30)                                    // 本平台最近 30
 | 现象 | 原因与处理 |
 |---|---|
 | 浏览器控制台报 CORS 错误 | 平台地址没有加入"设置 › 安全 › CORS 来源",或协议、端口写得不一致 |
-| 所有请求都返回 401 `invalid_token` | 令牌过期后刷新失败:检查平台的 `/api/agent-token` 接口,以及 API Key 是否已被停用 |
+| 所有请求都返回 401 `invalid_token` | 令牌过期后刷新失败:检查平台的 `/api/agent-token` 接口,以及 Agent Key 是否已被停用 |
 | 403 `profile_not_allowed` | 创建会话时指定的 Profile 不在该 Key 的可用列表中 |
 | 流式输出一段一段地出现,或很久才出现 | 平台与 AgentServer 之间有反向代理在缓冲响应:对 `/v1/sessions/*/stream` 关闭缓冲(nginx:`proxy_buffering off`) |
 | 安装时报找不到 `@agent-server/shared` | 三个包要用 URL 一起安装(它们是对等依赖,不在 npm 仓库中) |

+ 293 - 0
packages/chat-ui/src/components/TeamChat.vue

@@ -0,0 +1,293 @@
+<script setup lang="ts">
+import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
+import type { BotClient } from '@agent-server/client'
+import type { FileDto, TeamBotDto, TeamMessage } from '@agent-server/shared'
+import { downloadFile, provideClient } from '../context'
+import type { TeamItem, TeamSession } from '../team-store'
+import Composer from './Composer.vue'
+import FileCard from './FileCard.vue'
+import InteractionCard from './InteractionCard.vue'
+import MarkdownView from './MarkdownView.vue'
+
+type OpenedFile = { fileId: string; path: string; mime: string; size: number }
+
+const props = defineProps<{ client: BotClient; session: TeamSession; readonly?: boolean; placeholder?: string }>()
+const emit = defineEmits<{
+  openFile: [file: OpenedFile]
+  /** 查看机器人某一轮的完整执行过程(不监听时不显示入口) */
+  openRun: [ref: { botId: string; sessionId: string; runId: string | null }]
+}>()
+provideClient(props.client)
+
+const inst = getCurrentInstance()
+const hasOpenFile = !!inst?.vnode.props?.onOpenFile
+const canOpenRun = !!inst?.vnode.props?.onOpenRun
+function openFile(f: OpenedFile) {
+  if (hasOpenFile) emit('openFile', f)
+  else void downloadFile(props.client, f.fileId, f.path.split('/').pop() ?? f.path)
+}
+
+const state = computed(() => props.session.state)
+const team = computed(() => state.value.team)
+const bots = computed(() => team.value?.bots ?? [])
+const busy = computed(() => bots.value.filter(b => b.status !== 'idle'))
+const closed = computed(() => team.value?.status === 'closed')
+const botById = (id: string) => bots.value.find(b => b.id === id)
+
+const STATUS_TEXT: Record<TeamBotDto['status'], string> = { idle: '空闲', queued: '排队中', working: '工作中', waiting_input: '等待你的确认' }
+
+// @ 选择:点选机器人,或在文字中输入"@名称"
+const mentions = ref<string[]>([])
+function toggleMention(id: string) {
+  mentions.value = mentions.value.includes(id) ? mentions.value.filter(m => m !== id) : [...mentions.value, id]
+}
+function mentionsIn(text: string) {
+  return bots.value.filter(b => new RegExp(`@${b.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}_])`, 'u').test(text)).map(b => b.id)
+}
+
+const scroller = ref<HTMLElement>()
+const composer = ref<InstanceType<typeof Composer>>()
+const stick = ref(true)
+function onScroll() {
+  const el = scroller.value
+  if (el) stick.value = el.scrollHeight - el.scrollTop - el.clientHeight < 60
+}
+function scrollToBottom(smooth = false) {
+  const el = scroller.value
+  if (el) el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
+}
+watch(() => [state.value.items.length, busy.value.length], async () => {
+  if (!stick.value) return
+  await nextTick()
+  scrollToBottom()
+})
+watch(() => state.value.loading, async loading => {
+  if (!loading) {
+    await nextTick()
+    scrollToBottom()
+  }
+})
+
+async function onSend(text: string, files: FileDto[]) {
+  stick.value = true
+  const picked = [...new Set([...mentions.value, ...mentionsIn(text)])]
+  try {
+    await props.session.send(text, picked, files)
+    mentions.value = []
+  } catch {
+    composer.value?.setText(text) // 失败时把内容放回输入框
+  }
+}
+
+function time(ts: string) {
+  return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
+}
+
+function senderOf(item: Extract<TeamItem, { kind: 'message' }>) {
+  const s = item.message.sender
+  if (s.kind === 'human') return { avatar: '', name: s.name }
+  if (s.kind === 'bot') return { avatar: s.avatar, name: s.name }
+  return { avatar: '', name: '' }
+}
+
+/** 点选 @ 的机器人:文字中已经写了"@名称"的不再重复显示 */
+const mentionNames = (m: TeamMessage) =>
+  m.mentions.map(id => botById(id)?.name).filter((n): n is string => !!n && !m.text.includes(`@${n}`)).map(n => `@${n}`).join(' ')
+
+onMounted(() => composer.value?.focus())
+defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => composer.value?.setText(t) })
+</script>
+
+<template>
+  <div class="agent-chat team-chat">
+    <div ref="scroller" class="scroller" @scroll="onScroll">
+      <div class="column">
+        <div v-if="state.loading" class="empty">加载中…</div>
+        <template v-else>
+          <template v-for="item in state.items" :key="item.kind + item.id">
+            <!-- 成员消息 -->
+            <div v-if="item.kind === 'message' && item.message.sender.kind === 'human'" class="row human">
+              <div class="meta"><span class="name">{{ senderOf(item).name }}</span><span class="time">{{ time(item.ts) }}</span></div>
+              <div class="bubble">
+                <span v-if="mentionNames(item.message)" class="to">{{ mentionNames(item.message) }}</span>
+                {{ item.message.text }}
+              </div>
+              <div v-if="item.message.files.length" class="files">
+                <FileCard v-for="f in item.message.files" :key="f.fileId" :file-id="f.fileId" :path="f.path" :size="f.size" :mime="f.mime" @open="openFile" />
+              </div>
+            </div>
+
+            <!-- 机器人消息 -->
+            <div v-else-if="item.kind === 'message' && item.message.sender.kind === 'bot'" class="row bot">
+              <div class="avatar">{{ senderOf(item).avatar }}</div>
+              <div class="body">
+                <div class="meta">
+                  <span class="name">{{ senderOf(item).name }}</span><span class="time">{{ time(item.ts) }}</span>
+                  <button
+                    v-if="canOpenRun && item.message.sessionId" type="button" class="link" title="查看这一轮的完整执行过程"
+                    @click="emit('openRun', { botId: (item.message.sender as { botId: string }).botId, sessionId: item.message.sessionId!, runId: item.message.runId ?? null })"
+                  >
+                    <span class="codicon codicon-list-tree" />过程
+                  </button>
+                </div>
+                <MarkdownView v-if="item.message.text" :source="item.message.text" />
+                <div v-if="item.message.files.length" class="files">
+                  <FileCard v-for="f in item.message.files" :key="f.fileId" :file-id="f.fileId" :path="f.path" :size="f.size" :mime="f.mime" @open="openFile" />
+                </div>
+              </div>
+            </div>
+
+            <!-- 系统消息 -->
+            <div v-else-if="item.kind === 'message'" class="row system" :class="item.message.level">
+              <span>{{ item.message.text }}</span>
+              <button
+                v-if="canOpenRun && item.message.sessionId" type="button" class="link"
+                @click="emit('openRun', { botId: '', sessionId: item.message.sessionId!, runId: item.message.runId ?? null })"
+              >
+                过程
+              </button>
+            </div>
+
+            <!-- 审批与提问 -->
+            <div v-else class="row bot">
+              <div class="avatar">{{ botById(item.botId)?.avatar ?? '🤖' }}</div>
+              <div class="body">
+                <div class="meta"><span class="name">{{ botById(item.botId)?.name ?? '机器人' }}</span><span class="time">{{ time(item.ts) }}</span></div>
+                <InteractionCard :item="item" :readonly="readonly" :reply="readonly ? undefined : session.reply" />
+              </div>
+            </div>
+          </template>
+          <div v-if="!state.items.length" class="empty"><slot name="empty">工作组还没有消息</slot></div>
+        </template>
+
+        <div v-for="b in busy" :key="b.id" class="working">
+          <span class="codicon codicon-loading codicon-modifier-spin" />{{ b.avatar }} {{ b.name }} {{ STATUS_TEXT[b.status] }}…
+          <button v-if="!readonly && b.status !== 'waiting_input'" type="button" class="link" title="中断这个机器人的本轮工作" @click="session.interrupt(b.id)">停止</button>
+        </div>
+      </div>
+    </div>
+    <button v-if="!stick" type="button" class="to-bottom" @click="stick = true; scrollToBottom(true)">
+      <span class="codicon codicon-arrow-down" />回到最新
+    </button>
+    <div v-if="!readonly" class="composer-wrap">
+      <div v-if="state.error" class="error">{{ state.error }}</div>
+      <div v-if="state.connection === 'reconnecting'" class="conn">连接中断,正在重连…</div>
+      <div v-if="team?.status === 'paused'" class="conn">工作组已暂停:消息会先记录,恢复后再处理</div>
+      <div v-if="closed" class="conn">工作组已关闭,不能再发送消息</div>
+      <Composer
+        ref="composer" :running="false" :disabled="closed" :upload="session.upload"
+        :placeholder="placeholder ?? (bots.length > 1 ? '输入命令,@ 指定机器人(有组长时不 @ 会交给组长)' : '输入命令')"
+        @send="onSend"
+      >
+        <template #toolbar>
+          <button
+            v-for="b in bots" :key="b.id" type="button" class="mention" :class="{ on: mentions.includes(b.id) }"
+            :title="`@${b.name}${b.role === 'lead' ? '(组长)' : ''}:${b.description}`" @click="toggleMention(b.id)"
+          >
+            {{ b.avatar }} {{ b.name }}
+          </button>
+          <slot name="toolbar" />
+        </template>
+      </Composer>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.agent-chat {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  background: var(--app-primary-background);
+}
+.scroller { flex: 1; min-height: 0; overflow-y: auto; }
+.column { max-width: 760px; margin: 0 auto; padding: 16px 16px 24px; }
+.empty { padding: 48px 0; text-align: center; color: var(--app-secondary-foreground); }
+
+.row { margin: 10px 0; user-select: text; }
+.meta { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 12px; }
+.name { font-weight: 600; }
+.time { color: var(--app-secondary-foreground); }
+.files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+
+.human .bubble {
+  display: inline-block;
+  max-width: 100%;
+  padding: 4px 6px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-medium);
+  background: var(--app-input-background);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+.to { color: var(--app-link-foreground); margin-right: 4px; }
+
+.bot { display: flex; gap: 8px; }
+.avatar {
+  flex: none;
+  width: 26px;
+  height: 26px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: var(--app-input-secondary-background);
+  border: 1px solid var(--app-input-border);
+  font-size: 14px;
+}
+.body { flex: 1; min-width: 0; }
+
+.system { text-align: center; font-size: 12px; color: var(--app-secondary-foreground); }
+.system.error { color: var(--app-error-foreground); }
+.system.warning { color: var(--app-warning); }
+
+.link {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  padding: 0 4px;
+  border: none;
+  background: transparent;
+  color: var(--app-secondary-foreground);
+  cursor: pointer;
+  font-size: 12px;
+}
+.link:hover { color: var(--app-primary-foreground); }
+
+.working { display: flex; align-items: center; gap: 6px; padding: 4px 0 0 34px; color: var(--app-secondary-foreground); font-size: 12px; }
+.composer-wrap { max-width: 760px; width: 100%; margin: 0 auto; padding: 0 16px 16px; }
+.error { margin-bottom: 6px; font-size: 12px; color: var(--app-error-foreground); }
+.conn { margin-bottom: 6px; font-size: 12px; color: var(--app-warning); }
+
+.mention {
+  padding: 1px 8px;
+  border: 1px solid transparent;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--app-primary-foreground) 10%, transparent);
+  color: var(--app-primary-foreground);
+  cursor: pointer;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.mention:hover { background: color-mix(in srgb, var(--app-primary-foreground) 18%, transparent); }
+.mention.on { border-color: var(--app-claude-orange); background: color-mix(in srgb, var(--app-claude-orange) 18%, transparent); }
+
+.to-bottom {
+  position: absolute;
+  left: 50%;
+  bottom: 120px;
+  transform: translateX(-50%);
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 3px 10px;
+  border: 1px solid var(--app-input-border);
+  border-radius: 999px;
+  background: var(--app-input-secondary-background);
+  box-shadow: var(--app-shadow);
+  cursor: pointer;
+  font-size: 12px;
+}
+</style>

+ 8 - 5
packages/chat-ui/src/context.ts

@@ -1,19 +1,22 @@
 import { inject, onBeforeUnmount, provide, ref, watch, type InjectionKey, type Ref } from 'vue'
 import type { AgentClient } from '@agent-server/client'
 
-const CLIENT_KEY: InjectionKey<AgentClient> = Symbol('agent-client')
+/** 组件只需要带鉴权地下载文件:AgentClient 与 BotClient 都满足 */
+export type FileClient = Pick<AgentClient, 'fileBlob'>
 
-export function provideClient(client: AgentClient) {
+const CLIENT_KEY: InjectionKey<FileClient> = Symbol('agent-client')
+
+export function provideClient(client: FileClient) {
   provide(CLIENT_KEY, client)
 }
 
-export function useClient(): AgentClient {
+export function useClient(): FileClient {
   const c = inject(CLIENT_KEY)
   if (!c) throw new Error('chat-ui: 缺少 AgentClient(请在外层使用 <AgentChat> 或 provideClient)')
   return c
 }
 
-/** 带鉴权地加载文件为 object URL(API Key 模式下 <img src> 无法携带请求头) */
+/** 带鉴权地加载文件为 object URL(Agent Key 模式下 <img src> 无法携带请求头) */
 export function useFileObjectUrl(fileId: Ref<string | null>): Ref<string | null> {
   const client = useClient()
   const url = ref<string | null>(null)
@@ -37,7 +40,7 @@ export function useFileObjectUrl(fileId: Ref<string | null>): Ref<string | null>
   return url
 }
 
-export async function downloadFile(client: AgentClient, fileId: string, name: string) {
+export async function downloadFile(client: FileClient, fileId: string, name: string) {
   const blob = await client.fileBlob(fileId)
   const href = URL.createObjectURL(blob)
   const a = document.createElement('a')

+ 3 - 1
packages/chat-ui/src/index.ts

@@ -7,7 +7,9 @@ export { default as FileCard } from './components/FileCard.vue'
 export { default as InteractionCard } from './components/InteractionCard.vue'
 export { default as MarkdownView } from './components/MarkdownView.vue'
 export { default as Timeline } from './components/Timeline.vue'
+export { default as TeamChat } from './components/TeamChat.vue'
 export { default as ToolCall } from './components/ToolCall.vue'
-export { downloadFile, provideClient, useClient, useFileObjectUrl } from './context'
+export { downloadFile, provideClient, useClient, useFileObjectUrl, type FileClient } from './context'
 export { applyEvent, createChatState, useChatSession, type ChatSession, type ChatState, type RunState, type TimelineItem } from './store'
+export { applyTeamEvent, useTeamSession, type TeamItem, type TeamSession, type TeamState } from './team-store'
 export { basename, fileIcon, formatSize, renderMarkdown, toolLabel } from './util'

+ 115 - 0
packages/chat-ui/src/team-store.ts

@@ -0,0 +1,115 @@
+// 工作组时间线:由工作组事件流增量构建(docs/design/13-bot-teams.md §12)
+import { reactive } from 'vue'
+import type { BotClient } from '@agent-server/client'
+import type {
+  FileDto, InteractionRequest, InteractionResolution, InteractionResponse, TeamDetailDto, TeamEvent, TeamMessage,
+} from '@agent-server/shared'
+
+export type TeamItem =
+  | { kind: 'message'; id: string; seq: number; ts: string; message: TeamMessage }
+  | {
+    kind: 'interaction'; id: string; seq: number; ts: string; botId: string; sessionId: string
+    request: InteractionRequest; resolution: InteractionResolution | null; answers?: Record<string, string | string[]>; reason?: string
+  }
+
+export interface TeamState {
+  team: TeamDetailDto | null
+  items: TeamItem[]
+  lastSeq: number
+  /** 共享目录有文件变化时递增,文件列表据此刷新 */
+  filesVersion: number
+  connection: 'connecting' | 'open' | 'reconnecting' | 'closed'
+  loading: boolean
+  error: string | null
+}
+
+export function applyTeamEvent(s: TeamState, e: TeamEvent) {
+  if (e.seq <= s.lastSeq) return
+  s.lastSeq = e.seq
+  switch (e.type) {
+    case 'message.created':
+      s.items.push({ kind: 'message', id: e.payload.id, seq: e.seq, ts: e.ts, message: e.payload })
+      break
+    case 'interaction.required':
+      s.items.push({
+        kind: 'interaction', id: e.payload.request.interactionId, seq: e.seq, ts: e.ts, botId: e.payload.botId, sessionId: e.payload.sessionId,
+        request: e.payload.request, resolution: null,
+      })
+      break
+    case 'interaction.resolved': {
+      const item = s.items.findLast(i => i.kind === 'interaction' && i.id === e.payload.interactionId)
+      if (item?.kind === 'interaction') Object.assign(item, { resolution: e.payload.resolution, answers: e.payload.answers, reason: e.payload.reason })
+      break
+    }
+    case 'bot.status': {
+      const bot = s.team?.bots.find(b => b.id === e.payload.botId)
+      if (bot) Object.assign(bot, { status: e.payload.status, sessionId: e.payload.sessionId ?? bot.sessionId })
+      break
+    }
+    case 'team.updated':
+      if (s.team) Object.assign(s.team, e.payload)
+      break
+    case 'file.changed':
+      s.filesVersion++
+      break
+  }
+}
+
+/** 连接一个工作组:加载详情与历史 → 订阅实时事件 → 提供操作方法 */
+export function useTeamSession(client: BotClient, teamId: string) {
+  const state = reactive<TeamState>({ team: null, items: [], lastSeq: 0, filesVersion: 0, connection: 'connecting', loading: true, error: null }) as TeamState
+  let unsubscribe: (() => void) | null = null
+  let disposed = false
+
+  const refresh = async () => {
+    const t = await client.teams.get(teamId)
+    if (state.team) Object.assign(state.team, t)
+    else state.team = t
+  }
+
+  const start = async () => {
+    try {
+      await refresh()
+      const { items } = await client.teams.events(teamId)
+      for (const e of items) applyTeamEvent(state, e)
+      // 历史中未处理的交互:以详情中的待处理列表为准(服务重启后旧交互已取消)
+      const pending = new Set(state.team!.pendingInteractions.map(p => p.request.interactionId))
+      for (const i of state.items) if (i.kind === 'interaction' && !i.resolution && !pending.has(i.id)) i.resolution = 'cancelled'
+    } catch (err) {
+      state.error = (err as Error).message
+    } finally {
+      state.loading = false
+    }
+    if (disposed) return
+    unsubscribe = client.subscribe(teamId, {
+      onEvent: e => applyTeamEvent(state, e),
+      onStatus: st => (state.connection = st),
+      onError: err => console.warn('[team-chat] stream error', err),
+    }, { after: state.lastSeq })
+  }
+  void start()
+
+  return {
+    state,
+    teamId,
+    refresh,
+    async send(text: string, mentions: string[], files: FileDto[] = []) {
+      state.error = null
+      try {
+        await client.teams.send(teamId, { text, mentions, fileIds: files.map(f => f.id) })
+      } catch (err) {
+        state.error = (err as Error).message
+        throw err
+      }
+    },
+    upload: (files: File[]) => client.teams.upload(teamId, files),
+    reply: (interactionId: string, body: InteractionResponse) => client.teams.reply(teamId, interactionId, body),
+    interrupt: (botId: string) => client.teams.interruptBot(teamId, botId),
+    dispose() {
+      disposed = true
+      unsubscribe?.()
+    },
+  }
+}
+
+export type TeamSession = ReturnType<typeof useTeamSession>

+ 65 - 0
packages/client/src/bot.ts

@@ -0,0 +1,65 @@
+// Bot API 客户端(/bot/v1,docs/design/13-bot-teams.md §9)。使用 Bot Key 或 Bot 浏览器令牌,与 AgentClient 的 Key 互不通用
+import type {
+  BotSummary,
+  BrowserTokenDto,
+  CreateTeamBody,
+  FileDto,
+  InteractionResponse,
+  SendTeamMessageBody,
+  TeamDetailDto,
+  TeamDto,
+  TeamEvent,
+  TeamMessage,
+  TeamStatus,
+} from '@agent-server/shared'
+import { AgentApiError, HttpClient, query, type StreamHandlers } from './http'
+
+export class BotClient extends HttpClient {
+  /** 平台后端调用(需要 Bot Key):签发交给浏览器使用的短期令牌 */
+  issueBrowserToken = (ttlSeconds?: number) => this.request<BrowserTokenDto>('POST', '/bot/v1/browser-tokens', ttlSeconds === undefined ? {} : { ttlSeconds })
+  /** 这个 Key 可以使用的机器人 */
+  bots = () => this.request<BotSummary[]>('GET', '/bot/v1/bots')
+
+  teams = {
+    create: (body: CreateTeamBody) => this.request<TeamDetailDto>('POST', '/bot/v1/teams', body),
+    list: (q: { scope?: 'all'; status?: TeamStatus; limit?: number } = {}) => this.request<TeamDto[]>('GET', `/bot/v1/teams${query(q)}`),
+    get: (id: string) => this.request<TeamDetailDto>('GET', `/bot/v1/teams/${id}`),
+    update: (id: string, body: { title: string }) => this.request<TeamDetailDto>('PATCH', `/bot/v1/teams/${id}`, body),
+    remove: (id: string) => this.request<void>('DELETE', `/bot/v1/teams/${id}`),
+    pause: (id: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/pause`),
+    resume: (id: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/resume`),
+    close: (id: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/close`),
+    reopen: (id: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/reopen`),
+    addBot: (id: string, botId: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/bots`, { botId }),
+    removeBot: (id: string, botId: string) => this.request<TeamDetailDto>('DELETE', `/bot/v1/teams/${id}/bots/${botId}`),
+    interruptBot: (id: string, botId: string) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/bots/${botId}/interrupt`),
+    send: (id: string, body: SendTeamMessageBody) => this.request<TeamMessage>('POST', `/bot/v1/teams/${id}/messages`, body),
+    messages: (id: string, after = 0) => this.request<{ items: TeamEvent[]; lastSeq: number }>('GET', `/bot/v1/teams/${id}/messages?after=${after}`),
+    events: (id: string, after = 0) => this.request<{ items: TeamEvent[]; lastSeq: number }>('GET', `/bot/v1/teams/${id}/events?after=${after}`),
+    files: (id: string) => this.request<FileDto[]>('GET', `/bot/v1/teams/${id}/files`),
+    upload: (id: string, files: Blob[] | File[]) => {
+      const form = new FormData()
+      for (const f of files) form.append('file', f, (f as File).name ?? 'upload')
+      return this.request<FileDto[]>('POST', `/bot/v1/teams/${id}/files`, form)
+    },
+    reply: (id: string, interactionId: string, body: InteractionResponse) =>
+      this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/interactions/${interactionId}`, body),
+  }
+
+  /** 文件下载地址(同源 Cookie 鉴权时可直接用于 <img>/<a>) */
+  fileUrl(fileId: string, inline = false) {
+    return `${this.opts.baseUrl}/bot/v1/files/${fileId}${inline ? '?inline=1' : ''}`
+  }
+
+  /** 带鉴权头下载文件为 Blob */
+  async fileBlob(fileId: string): Promise<Blob> {
+    const res = await this.send(`/bot/v1/files/${fileId}?inline=1`, {})
+    if (!res.ok) throw new AgentApiError(res.status, 'http_error', `HTTP ${res.status}`)
+    return res.blob()
+  }
+
+  /** 订阅工作组事件流;断线自动重连并从最后收到的 seq 续传。返回取消函数 */
+  subscribe(teamId: string, handlers: StreamHandlers<TeamEvent>, opts: { after?: number } = {}): () => void {
+    return this.stream(`/bot/v1/teams/${teamId}/stream`, handlers, opts.after ?? 0)
+  }
+}

+ 155 - 0
packages/client/src/http.ts

@@ -0,0 +1,155 @@
+// 请求与 SSE 的公共部分:AgentClient(/v1)与 BotClient(/bot/v1)共用
+// SSE 用 fetch + ReadableStream 实现:可携带 Authorization 头,断线后按 Last-Event-ID 续传
+import type { AgentEvent, ApiErrorBody, BrowserTokenDto } from '@agent-server/shared'
+
+export interface AgentClientOptions {
+  /** 服务地址,例如 https://agent.example.com;同源时可为空字符串 */
+  baseUrl: string
+  /** Agent Key:只在业务平台的后端使用,不要放进浏览器代码 */
+  apiKey?: string
+  /**
+   * 浏览器中使用:返回浏览器令牌(由平台后端用 Agent Key 换取,见 browserTokenProvider)。
+   * 令牌失效(401)时会以 { refresh: true } 再调用一次并重试
+   */
+  getToken?: (opts: { refresh: boolean }) => string | Promise<string>
+  fetch?: typeof fetch
+}
+
+export class AgentApiError extends Error {
+  constructor(readonly status: number, readonly code: string, message: string, readonly details?: unknown) {
+    super(message)
+  }
+}
+
+export class HttpClient {
+  private readonly fetchImpl: typeof fetch
+
+  constructor(protected readonly opts: AgentClientOptions) {
+    this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
+  }
+
+  private async headers(extra?: Record<string, string>, refresh = false): Promise<Record<string, string>> {
+    const token = this.opts.getToken ? await this.opts.getToken({ refresh }) : this.opts.apiKey
+    return { ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra }
+  }
+
+  /** 发请求;使用浏览器令牌时,令牌失效则刷新后重试一次。Cookie 只在同源(后台)时携带 */
+  protected async send(path: string, init: RequestInit & { headers?: Record<string, string> }): Promise<Response> {
+    const go = async (refresh: boolean) => this.fetchImpl(`${this.opts.baseUrl}${path}`, {
+      ...init, credentials: 'same-origin', headers: await this.headers(init.headers, refresh),
+    })
+    const res = await go(false)
+    if (res.status === 401 && this.opts.getToken) {
+      await res.body?.cancel()
+      return go(true)
+    }
+    return res
+  }
+
+  async request<T>(method: string, path: string, body?: unknown): Promise<T> {
+    const isForm = typeof FormData !== 'undefined' && body instanceof FormData
+    const res = await this.send(path, {
+      method,
+      headers: body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined,
+      body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
+    })
+    if (!res.ok) {
+      const err = (await res.json().catch(() => null)) as ApiErrorBody | null
+      throw new AgentApiError(res.status, err?.error.code ?? 'http_error', err?.error.message ?? `HTTP ${res.status}`, err?.error.details)
+    }
+    if (res.status === 204) return undefined as T
+    return (await res.json()) as T
+  }
+
+  /** 订阅 SSE 事件流;断线自动重连并从最后收到的 seq 续传。返回取消函数 */
+  protected stream<E extends { seq: number }>(path: string, handlers: StreamHandlers<E>, after: number): () => void {
+    let lastSeq = after
+    let stopped = false
+    let controller: AbortController | null = null
+    let retry = 0
+
+    const connect = async () => {
+      while (!stopped) {
+        controller = new AbortController()
+        try {
+          const res = await this.send(path, {
+            headers: { accept: 'text/event-stream', 'last-event-id': String(lastSeq) },
+            signal: controller.signal,
+          })
+          if (!res.ok || !res.body) {
+            const err = (await res.json().catch(() => null)) as ApiErrorBody | null
+            throw new AgentApiError(res.status, err?.error.code ?? 'stream_error', err?.error.message ?? `HTTP ${res.status}`)
+          }
+          retry = 0
+          handlers.onStatus?.('open')
+          for await (const event of parseSse<E>(res.body)) {
+            lastSeq = Math.max(lastSeq, event.seq)
+            handlers.onEvent(event)
+          }
+        } catch (err) {
+          if (stopped) return
+          if (err instanceof AgentApiError && (err.status === 401 || err.status === 404)) {
+            handlers.onStatus?.('closed')
+            handlers.onError?.(err)
+            return
+          }
+          handlers.onError?.(err)
+        }
+        if (stopped) return
+        handlers.onStatus?.('reconnecting')
+        await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** retry++, 15000)))
+      }
+    }
+    void connect()
+    return () => {
+      stopped = true
+      controller?.abort()
+      handlers.onStatus?.('closed')
+    }
+  }
+}
+
+/**
+ * 浏览器令牌缓存:fetchToken 调用平台自己的后端(后端再用 Agent Key 调 issueBrowserToken)。
+ * 在过期前 60 秒自动换新,并发请求共用同一次获取
+ */
+export function browserTokenProvider(fetchToken: () => Promise<BrowserTokenDto>): NonNullable<AgentClientOptions['getToken']> {
+  let current: BrowserTokenDto | null = null
+  let pending: Promise<BrowserTokenDto> | null = null
+  return async ({ refresh }) => {
+    const fresh = current && Date.parse(current.expiresAt) - Date.now() > 60_000
+    if (fresh && !refresh) return current!.token
+    pending ??= fetchToken().finally(() => { pending = null })
+    current = await pending
+    return current.token
+  }
+}
+
+export interface StreamHandlers<E = AgentEvent> {
+  onEvent: (e: E) => void
+  onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
+  onError?: (err: unknown) => void
+}
+
+async function* parseSse<E>(body: ReadableStream<Uint8Array>): AsyncGenerator<E> {
+  const reader = body.getReader()
+  const decoder = new TextDecoder()
+  let buf = ''
+  while (true) {
+    const { value, done } = await reader.read()
+    if (done) return
+    buf += decoder.decode(value, { stream: true })
+    let idx: number
+    while ((idx = buf.indexOf('\n\n')) >= 0) {
+      const chunk = buf.slice(0, idx)
+      buf = buf.slice(idx + 2)
+      const data = chunk.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('\n')
+      if (data) yield JSON.parse(data) as E
+    }
+  }
+}
+
+export function query(q: Record<string, string | number | boolean | undefined>) {
+  const s = Object.entries(q).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&')
+  return s ? `?${s}` : ''
+}

+ 7 - 150
packages/client/src/index.ts

@@ -1,8 +1,6 @@
 // AgentServer TS 客户端(docs/design/04-api.md §6)
-// SSE 用 fetch + ReadableStream 实现:可携带 Authorization 头,断线后按 Last-Event-ID 续传
 import type {
   AgentEvent,
-  ApiErrorBody,
   BrowserTokenDto,
   CreateSessionBody,
   FileDto,
@@ -18,70 +16,16 @@ import type {
   SessionDetailDto,
   SessionDto,
 } from '@agent-server/shared'
+import { AgentApiError, HttpClient, query, type StreamHandlers } from './http'
 
-export interface AgentClientOptions {
-  /** 服务地址,例如 https://agent.example.com;同源时可为空字符串 */
-  baseUrl: string
-  /** API Key:只在业务平台的后端使用,不要放进浏览器代码 */
-  apiKey?: string
-  /**
-   * 浏览器中使用:返回浏览器令牌(由平台后端用 API Key 换取,见 browserTokenProvider)。
-   * 令牌失效(401)时会以 { refresh: true } 再调用一次并重试
-   */
-  getToken?: (opts: { refresh: boolean }) => string | Promise<string>
-  fetch?: typeof fetch
-}
-
-export class AgentApiError extends Error {
-  constructor(readonly status: number, readonly code: string, message: string, readonly details?: unknown) {
-    super(message)
-  }
-}
+export { AgentApiError, browserTokenProvider, HttpClient, type AgentClientOptions, type StreamHandlers } from './http'
+export { BotClient } from './bot'
 
 export type ProfileSummary = Pick<ProfileDto, 'id' | 'name' | 'description' | 'provider' | 'model' | 'permissionMode' | 'isDefault'>
 
-export class AgentClient {
-  private readonly fetchImpl: typeof fetch
-
-  constructor(private readonly opts: AgentClientOptions) {
-    this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
-  }
-
-  private async headers(extra?: Record<string, string>, refresh = false): Promise<Record<string, string>> {
-    const token = this.opts.getToken ? await this.opts.getToken({ refresh }) : this.opts.apiKey
-    return { ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra }
-  }
-
-  /** 发请求;使用浏览器令牌时,令牌失效则刷新后重试一次。Cookie 只在同源(后台)时携带 */
-  private async send(path: string, init: RequestInit & { headers?: Record<string, string> }): Promise<Response> {
-    const go = async (refresh: boolean) => this.fetchImpl(`${this.opts.baseUrl}${path}`, {
-      ...init, credentials: 'same-origin', headers: await this.headers(init.headers, refresh),
-    })
-    const res = await go(false)
-    if (res.status === 401 && this.opts.getToken) {
-      await res.body?.cancel()
-      return go(true)
-    }
-    return res
-  }
-
-  async request<T>(method: string, path: string, body?: unknown): Promise<T> {
-    const isForm = typeof FormData !== 'undefined' && body instanceof FormData
-    const res = await this.send(path, {
-      method,
-      headers: body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined,
-      body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
-    })
-    if (!res.ok) {
-      const err = (await res.json().catch(() => null)) as ApiErrorBody | null
-      throw new AgentApiError(res.status, err?.error.code ?? 'http_error', err?.error.message ?? `HTTP ${res.status}`, err?.error.details)
-    }
-    if (res.status === 204) return undefined as T
-    return (await res.json()) as T
-  }
-
+export class AgentClient extends HttpClient {
   providers = () => this.request<ProviderStatusDto[]>('GET', '/v1/providers')
-  /** 平台后端调用(需要 API Key):签发交给浏览器使用的短期令牌 */
+  /** 平台后端调用(需要 Agent Key):签发交给浏览器使用的短期令牌 */
   issueBrowserToken = (ttlSeconds?: number) => this.request<BrowserTokenDto>('POST', '/v1/browser-tokens', ttlSeconds === undefined ? {} : { ttlSeconds })
   /** 本平台最近 N 天的用量 */
   usage = (days = 30) => this.request<UsageSummaryDto>('GET', `/v1/usage?days=${days}`)
@@ -120,7 +64,7 @@ export class AgentClient {
     return `${this.opts.baseUrl}/v1/files/${fileId}${inline ? '?inline=1' : ''}`
   }
 
-  /** 带鉴权头下载文件为 Blob(API Key 模式下预览图片用) */
+  /** 带鉴权头下载文件为 Blob(Agent Key 模式下预览图片用) */
   async fileBlob(fileId: string): Promise<Blob> {
     const res = await this.send(`/v1/files/${fileId}?inline=1`, {})
     if (!res.ok) throw new AgentApiError(res.status, 'http_error', `HTTP ${res.status}`)
@@ -129,95 +73,8 @@ export class AgentClient {
 
   /** 订阅会话事件流;断线自动重连并从最后收到的 seq 续传。返回取消函数 */
   subscribe(sessionId: string, handlers: StreamHandlers, opts: { after?: number } = {}): () => void {
-    let lastSeq = opts.after ?? 0
-    let stopped = false
-    let controller: AbortController | null = null
-    let retry = 0
-
-    const connect = async () => {
-      while (!stopped) {
-        controller = new AbortController()
-        try {
-          const res = await this.send(`/v1/sessions/${sessionId}/stream`, {
-            headers: { accept: 'text/event-stream', 'last-event-id': String(lastSeq) },
-            signal: controller.signal,
-          })
-          if (!res.ok || !res.body) {
-            const err = (await res.json().catch(() => null)) as ApiErrorBody | null
-            throw new AgentApiError(res.status, err?.error.code ?? 'stream_error', err?.error.message ?? `HTTP ${res.status}`)
-          }
-          retry = 0
-          handlers.onStatus?.('open')
-          for await (const event of parseSse(res.body)) {
-            lastSeq = Math.max(lastSeq, event.seq)
-            handlers.onEvent(event)
-          }
-        } catch (err) {
-          if (stopped) return
-          if (err instanceof AgentApiError && (err.status === 401 || err.status === 404)) {
-            handlers.onStatus?.('closed')
-            handlers.onError?.(err)
-            return
-          }
-          handlers.onError?.(err)
-        }
-        if (stopped) return
-        handlers.onStatus?.('reconnecting')
-        await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** retry++, 15000)))
-      }
-    }
-    void connect()
-    return () => {
-      stopped = true
-      controller?.abort()
-      handlers.onStatus?.('closed')
-    }
-  }
-}
-
-/**
- * 浏览器令牌缓存:fetchToken 调用平台自己的后端(后端再用 API Key 调 issueBrowserToken)。
- * 在过期前 60 秒自动换新,并发请求共用同一次获取
- */
-export function browserTokenProvider(fetchToken: () => Promise<BrowserTokenDto>): NonNullable<AgentClientOptions['getToken']> {
-  let current: BrowserTokenDto | null = null
-  let pending: Promise<BrowserTokenDto> | null = null
-  return async ({ refresh }) => {
-    const fresh = current && Date.parse(current.expiresAt) - Date.now() > 60_000
-    if (fresh && !refresh) return current!.token
-    pending ??= fetchToken().finally(() => { pending = null })
-    current = await pending
-    return current.token
-  }
-}
-
-export interface StreamHandlers {
-  onEvent: (e: AgentEvent) => void
-  onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
-  onError?: (err: unknown) => void
-}
-
-async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<AgentEvent> {
-  const reader = body.getReader()
-  const decoder = new TextDecoder()
-  let buf = ''
-  while (true) {
-    const { value, done } = await reader.read()
-    if (done) return
-    buf += decoder.decode(value, { stream: true })
-    let idx: number
-    while ((idx = buf.indexOf('\n\n')) >= 0) {
-      const chunk = buf.slice(0, idx)
-      buf = buf.slice(idx + 2)
-      const data = chunk.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('\n')
-      if (data) yield JSON.parse(data) as AgentEvent
-    }
+    return this.stream(`/v1/sessions/${sessionId}/stream`, handlers, opts.after ?? 0)
   }
 }
 
-function query(q: Record<string, string | number | boolean | undefined>) {
-  const s = Object.entries(q).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&')
-  return s ? `?${s}` : ''
-}
-
 export type * from '@agent-server/shared'

+ 13 - 2
packages/shared/src/api.ts

@@ -476,15 +476,26 @@ export interface ApiOverview {
   flow: string[]
 }
 
-export interface ApiReferenceDto {
-  version: string
+/** 一套协议:Agent API(/v1,会话)或 Bot API(/bot/v1,机器人与工作组) */
+export interface ApiSpec {
+  id: 'agent' | 'bot'
+  title: string
+  /** 一句话说明,以及使用哪种 Key */
+  summary: string
   overview: ApiOverview
   groups: ApiEndpointGroup[]
+  /** 事件信封的类型名(数据结构中的一项) */
+  eventEnvelope: string
   types: ApiTypeDef[]
   events: ApiEventDef[]
   errors: ApiErrorDef[]
 }
 
+export interface ApiReferenceDto {
+  version: string
+  specs: ApiSpec[]
+}
+
 // ---- MCP 服务(经 MCP Gateway 转接,两个引擎共用,ADR-0021)----
 
 /** 工具策略:auto 直接调用;ask 跟随权限模式(需审批时询问);off 不提供 */

+ 1 - 0
packages/shared/src/index.ts

@@ -1,3 +1,4 @@
 export * from './events'
 export * from './api'
 export * from './settings'
+export * from './teams'

+ 149 - 0
packages/shared/src/teams.ts

@@ -0,0 +1,149 @@
+// Bot 工作组(docs/design/13-bot-teams.md):机器人、Bot Key、工作组与工作组事件
+import type { FileDto } from './api'
+import type { InteractionRequest, InteractionResolution, ProviderId } from './events'
+
+/** 组长(lead)负责拆分和分派任务;组员(worker)执行具体工作 */
+export type BotKind = 'lead' | 'worker'
+
+export interface BotDto {
+  id: string
+  name: string
+  /** 一个 emoji 或一两个字 */
+  avatar: string
+  kind: BotKind
+  /** 岗位描述:擅长什么、不负责什么 */
+  description: string
+  profileId: string
+  enabled: boolean
+  createdAt: string
+  updatedAt: string
+}
+
+export type BotInput = Pick<BotDto, 'name' | 'avatar' | 'kind' | 'description' | 'profileId' | 'enabled'>
+
+/** Bot API 中可见的机器人信息(不含 Profile 配置) */
+export interface BotSummary {
+  id: string
+  name: string
+  avatar: string
+  kind: BotKind
+  description: string
+  provider: ProviderId | null
+  model: string | null
+}
+
+export interface BotKeyDto {
+  id: string
+  name: string
+  prefix: string
+  enabled: boolean
+  allowedBotIds: string[]
+  createdAt: string
+  lastUsedAt: string | null
+}
+
+export type TeamStatus = 'active' | 'paused' | 'closed'
+export type TeamBotStatus = 'idle' | 'queued' | 'working' | 'waiting_input'
+
+/** 工作组里的人类成员:由调用方提供的显示身份,AgentServer 不做认证 */
+export interface TeamHuman {
+  id: string
+  name: string
+}
+
+export interface TeamBotDto extends BotSummary {
+  role: BotKind
+  status: TeamBotStatus
+  /** 这个机器人在本工作组中的引擎会话(还没有工作过时为 null) */
+  sessionId: string | null
+  joinedAt: string
+}
+
+export interface TeamDto {
+  id: string
+  title: string
+  human: TeamHuman
+  status: TeamStatus
+  /** Bot Key id 或 'admin' */
+  source: string
+  bots: TeamBotDto[]
+  lastSeq: number
+  createdAt: string
+  updatedAt: string
+}
+
+export interface TeamInteractionDto {
+  botId: string
+  sessionId: string
+  request: InteractionRequest
+}
+
+export interface TeamDetailDto extends TeamDto {
+  pendingInteractions: TeamInteractionDto[]
+}
+
+export interface CreateTeamBody {
+  title?: string
+  human: TeamHuman
+  leadBotId?: string
+  workerBotIds?: string[]
+}
+
+export interface SendTeamMessageBody {
+  text: string
+  /** 被 @ 的机器人 id;不 @ 时:有组长交给组长,只有一个组员时交给它,否则返回 400 */
+  mentions?: string[]
+  /** 已上传到本工作组的文件 */
+  fileIds?: string[]
+}
+
+export type TeamSender =
+  | { kind: 'human'; id: string; name: string }
+  | { kind: 'bot'; botId: string; name: string; avatar: string }
+  | { kind: 'system' }
+
+export interface TeamFileRef {
+  fileId: string
+  path: string
+  mime: string
+  size: number
+}
+
+export interface TeamMessage {
+  id: string
+  sender: TeamSender
+  text: string
+  mentions: string[]
+  files: TeamFileRef[]
+  /** 这条消息唤醒的机器人 */
+  targets: string[]
+  /** 机器人消息:产生它的 Run 与会话,用于查看完整执行过程 */
+  runId?: string
+  sessionId?: string
+  /** 系统消息的级别 */
+  level?: 'info' | 'warning' | 'error'
+}
+
+export interface TeamEventPayloads {
+  'message.created': TeamMessage
+  'bot.status': { botId: string; status: TeamBotStatus; sessionId: string | null; runId: string | null }
+  'interaction.required': TeamInteractionDto
+  'interaction.resolved': { botId: string; sessionId: string; interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string }
+  'file.changed': { file: FileDto; kind: 'created' | 'modified' }
+  'team.updated': TeamDto
+}
+
+export type TeamEventType = keyof TeamEventPayloads
+
+export interface TeamEventOf<T extends TeamEventType> {
+  seq: number
+  type: T
+  teamId: string
+  ts: string
+  payload: TeamEventPayloads[T]
+}
+
+export type TeamEvent = { [T in TeamEventType]: TeamEventOf<T> }[TeamEventType]
+
+/** 不落库、只保留在内存缓冲中的事件(状态类,断线续传后以接口返回的最新状态为准) */
+export const TRANSIENT_TEAM_EVENT_TYPES: ReadonlySet<TeamEventType> = new Set(['bot.status', 'file.changed', 'team.updated'])