Jelajahi Sumber

feat: Bot 工作组三期(SDK 0.3.0 发布 BotClient / TeamChat、示例平台工作组页面、子任务重试、复制到 Playground、回归用例)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 Minggu lalu
induk
melakukan
d81412fefa

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

@@ -12,6 +12,7 @@ const router = useRouter()
 const nav = [
   { to: '/playground', icon: 'codicon-comment-discussion', label: 'Playground' },
   { to: '/bot-playground', icon: 'codicon-organization', label: 'Bot Playground' },
+  { to: '/bot-cases', icon: 'codicon-beaker', label: '回归用例' },
   { to: '/sessions', icon: 'codicon-history', label: '会话记录' },
   { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },

+ 9 - 1
apps/admin/src/lib/api.ts

@@ -2,7 +2,7 @@
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
   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,
+  ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
@@ -153,6 +153,14 @@ export const admin = {
   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`),
+  /** 复制到 Playground:按相同成员组成新建后台工作组 */
+  copyTeam: (id: string) => req<TeamDto>('POST', `/teams/${id}/copy`),
+  teamCases: () => req<TeamCaseDto[]>('GET', '/team-cases'),
+  createTeamCase: (name: string, teamId: string) => req<TeamCaseDto>('POST', '/team-cases', { name, teamId }),
+  updateTeamCase: (id: string, body: { name?: string; text?: string }) => req<TeamCaseDto>('PATCH', `/team-cases/${id}`, body),
+  deleteTeamCase: (id: string) => req<void>('DELETE', `/team-cases/${id}`),
+  teamCaseRuns: (id: string) => req<TeamCaseRunDto[]>('GET', `/team-cases/${id}/runs`),
+  replayTeamCases: (ids: string[]) => req<TeamDto[]>('POST', '/team-cases/replay', { ids }),
   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),

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

@@ -9,6 +9,7 @@ export const router = createRouter({
     { 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: '/bot-cases/:caseId?', name: 'bot-cases', component: () => import('./views/BotCasesView.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') },

+ 210 - 0
apps/admin/src/views/BotCasesView.vue

@@ -0,0 +1,210 @@
+<script setup lang="ts">
+// 回归用例(docs/design/13-bot-teams.md §12.2,09-admin-ui.md §3.6):成员组成 + 第一条命令。
+// 在 Bot Playground 中把工作组"保存为回归用例",在这里批量重放(每次新建一个后台工作组,会真实执行),把每次的结果列在一起人工比对
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { BotAvatar } from '@agent-server/chat-ui'
+import type { BotDto, JobDto, TeamCaseDto, TeamCaseRunDto } from '@agent-server/shared'
+import SidePane from '../components/SidePane.vue'
+import { admin, errorMessage } from '../lib/api'
+import { relativeTime, toast } from '../lib/ui'
+
+const route = useRoute()
+const router = useRouter()
+const caseId = computed(() => (route.params.caseId as string | undefined) || null)
+
+const cases = ref<TeamCaseDto[]>([])
+const bots = ref<BotDto[]>([])
+const checked = ref<string[]>([])
+const current = computed(() => cases.value.find(c => c.id === caseId.value) ?? null)
+const runs = ref<TeamCaseRunDto[]>([])
+const busy = ref(false)
+const expanded = ref<string[]>([])
+const editing = ref<{ name: string; text: string } | null>(null)
+
+const botOf = (id: string) => bots.value.find(b => b.id === id)
+const JOB_STATUS: Record<JobDto['status'], { text: string; cls: string }> = {
+  running: { text: '进行中', cls: 'text-accent' }, blocked: { text: '超出限额', cls: 'text-warning' },
+  completed: { text: '已完成', cls: 'text-success' }, failed: { text: '失败', cls: 'text-error' }, cancelled: { text: '已取消', cls: 'text-fg-muted' },
+}
+const money = (n: number) => `$${n.toFixed(n >= 1 ? 2 : 4)}`
+
+function runLabel(r: TeamCaseRunDto | null) {
+  if (!r) return { text: '未重放', cls: 'text-fg-muted' }
+  if (r.state === 'running') return { text: '执行中', cls: 'text-accent' }
+  if (r.job) return JOB_STATUS[r.job.status]
+  return { text: '已回复', cls: 'text-success' }
+}
+
+async function load() {
+  try {
+    cases.value = await admin.teamCases()
+    checked.value = checked.value.filter(id => cases.value.some(c => c.id === id))
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function loadRuns() {
+  runs.value = caseId.value ? await admin.teamCaseRuns(caseId.value).catch(() => []) : []
+}
+watch(caseId, () => {
+  runs.value = []
+  void loadRuns()
+  editing.value = null
+}, { immediate: true })
+
+async function replay(ids: string[]) {
+  if (!ids.length) return
+  if (!confirm(`重放 ${ids.length} 个用例?每个用例会新建一个工作组并真实执行(替身组员除外),会产生成本。`)) return
+  busy.value = true
+  try {
+    await admin.replayTeamCases(ids)
+    toast(`已开始重放 ${ids.length} 个用例`, 'success')
+    await Promise.all([load(), loadRuns()])
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = false
+  }
+}
+
+async function remove(c: TeamCaseDto) {
+  if (!confirm(`删除回归用例「${c.name}」?重放产生的工作组会保留。`)) return
+  try {
+    await admin.deleteTeamCase(c.id)
+    await load()
+    if (caseId.value === c.id) await router.push('/bot-cases')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+// 修改名称和命令
+async function saveEdit() {
+  if (!current.value || !editing.value) return
+  try {
+    await admin.updateTeamCase(current.value.id, { name: editing.value.name.trim(), text: editing.value.text.trim() })
+    editing.value = null
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+// 有重放在执行时定时刷新
+let timer: ReturnType<typeof setInterval> | undefined
+onMounted(async () => {
+  await load()
+  bots.value = await admin.bots().catch(() => [])
+  timer = setInterval(() => {
+    if (runs.value.some(r => r.state === 'running') || cases.value.some(c => c.lastRun?.state === 'running')) void Promise.all([load(), loadRuns()])
+  }, 5000)
+})
+onBeforeUnmount(() => clearInterval(timer))
+</script>
+
+<template>
+  <SidePane title="回归用例" :width="280">
+    <template #actions>
+      <button
+        type="button" class="icon-btn" :disabled="busy || !cases.length"
+        :title="checked.length ? `重放所选的 ${checked.length} 个用例` : '重放全部用例'" @click="replay(checked.length ? checked : cases.map(c => c.id))"
+      >
+        <span class="codicon codicon-run-all" />
+      </button>
+      <button type="button" class="icon-btn" title="刷新" @click="load(); loadRuns()"><span class="codicon codicon-refresh" /></button>
+    </template>
+    <template #side>
+      <div v-if="!cases.length" class="px-5 py-3 hint">还没有用例。在 Bot Playground 中打开一个工作组,点顶栏的 <span class="codicon codicon-beaker" /> 保存为回归用例。</div>
+      <div
+        v-for="c in cases" :key="c.id"
+        class="group flex items-center gap-2 h-[48px] pl-3 pr-2 cursor-pointer hover:bg-hover"
+        :class="{ '!bg-active': c.id === caseId }" @click="router.push(`/bot-cases/${c.id}`)"
+      >
+        <input v-model="checked" type="checkbox" :value="c.id" title="选择后批量重放" @click.stop>
+        <div class="flex-1 min-w-0">
+          <div class="truncate">{{ c.name }}</div>
+          <div class="flex items-center gap-1 text-[11px] text-fg-muted truncate">
+            <BotAvatar v-for="id in [c.leadBotId, ...c.workerBotIds].filter(Boolean)" :key="id!" :value="botOf(id!)?.avatar ?? '❔'" :size="14" />
+            <span class="ml-0.5" :class="runLabel(c.lastRun).cls">{{ runLabel(c.lastRun).text }}</span>
+            <span v-if="c.runCount">· {{ c.runCount }} 次</span>
+          </div>
+        </div>
+        <button type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click.stop="remove(c)"><span class="codicon codicon-trash" /></button>
+      </div>
+    </template>
+
+    <div v-if="!current" class="flex-1 flex flex-col items-center justify-center gap-3 p-8 bg-bg-side">
+      <span class="codicon codicon-beaker text-[40px] text-fg-muted" />
+      <div class="text-[15px] font-600">回归用例</div>
+      <div class="hint max-w-120 text-center">用例保存工作组的成员组成和成员发出的第一条消息(含附件)。修改机器人、Profile 或升级引擎后批量重放,比对每次的结果。替身组员在重放时仍是替身,需要在 Playground 中填写结果。</div>
+    </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">{{ current.name }}</span>
+        <span class="flex-1" />
+        <button type="button" class="btn-ghost h-6" @click="editing = { name: current.name, text: current.text }"><span class="codicon codicon-edit" />修改</button>
+        <button type="button" class="btn-primary h-6" :disabled="busy" @click="replay([current.id])"><span class="codicon codicon-play" />重放</button>
+      </header>
+      <div class="flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-4 text-[13px]">
+        <div class="card p-3 flex flex-col gap-2">
+          <div class="flex flex-wrap items-center gap-3">
+            <span class="hint">成员:{{ current.humanName }}</span>
+            <span v-if="current.leadBotId" class="inline-flex items-center gap-1">
+              <BotAvatar :value="botOf(current.leadBotId)?.avatar ?? '❔'" :size="18" />{{ botOf(current.leadBotId)?.name ?? '(已删除)' }}<span class="badge bg-code text-accent">组长</span>
+            </span>
+            <span v-for="id in current.workerBotIds" :key="id" class="inline-flex items-center gap-1">
+              <BotAvatar :value="botOf(id)?.avatar ?? '❔'" :size="18" />{{ botOf(id)?.name ?? '(已删除)' }}
+              <span v-if="current.stubBotIds.includes(id)" class="badge bg-code text-warning">替身</span>
+            </span>
+          </div>
+          <div v-if="current.limits && Object.keys(current.limits).length" class="hint">
+            限额:{{ Object.entries(current.limits).map(([k, v]) => `${{ maxTasksPerJob: '子任务', maxLeadRunsPerJob: '组长轮数', maxCostUsdPerJob: '成本', maxMinutesPerJob: '分钟', maxConcurrentRuns: '同时 Run' }[k] ?? k} ${v}`).join(' · ') }}
+          </div>
+          <template v-if="editing">
+            <label class="label" for="case-name">名称</label>
+            <input id="case-name" v-model="editing.name" class="field">
+            <label class="label" for="case-text">第一条消息</label>
+            <textarea id="case-text" v-model="editing.text" class="field min-h-28 py-1.5" />
+            <div class="flex justify-end gap-2">
+              <button type="button" class="btn-ghost" @click="editing = null">取消</button>
+              <button type="button" class="btn-primary" :disabled="!editing.name.trim() || !editing.text.trim()" @click="saveEdit">保存</button>
+            </div>
+          </template>
+          <template v-else>
+            <div class="whitespace-pre-wrap p-2 rounded-md bg-code">
+              <span v-for="id in current.mentions" :key="id" class="text-link">@{{ botOf(id)?.name ?? id }} </span>{{ current.text }}
+            </div>
+            <div v-if="current.files.length" class="flex flex-wrap gap-2 hint">
+              <span v-for="f in current.files" :key="f.name" class="inline-flex items-center gap-1"><span class="codicon codicon-file" />{{ f.name }}</span>
+            </div>
+          </template>
+        </div>
+
+        <div class="section-title">重放结果({{ runs.length }})</div>
+        <div v-if="!runs.length" class="hint">还没有重放过。点右上角“重放”开始。</div>
+        <div v-for="r in runs" :key="r.teamId" class="card p-3 flex flex-col gap-1.5">
+          <div class="flex items-center gap-2">
+            <span class="font-500">{{ new Date(r.createdAt).toLocaleString('zh-CN', { hour12: false }) }}</span>
+            <span class="hint">{{ relativeTime(r.createdAt) }}</span>
+            <span :class="runLabel(r).cls">{{ runLabel(r).text }}</span>
+            <span class="flex-1" />
+            <span class="hint">{{ r.runs }} 个 Run · {{ money(r.costUsd) }}<template v-if="r.job"> · 组长 {{ r.job.leadRuns }} 轮 · {{ r.job.taskCount }} 个子任务</template></span>
+            <RouterLink :to="`/bot-playground/${r.teamId}`" class="btn-ghost h-6"><span class="codicon codicon-link-external" />打开</RouterLink>
+          </div>
+          <div v-if="r.job" class="font-500">任务 #{{ r.job.no }} {{ r.job.title }}</div>
+          <div
+            v-if="r.job?.summary ?? r.reply" class="whitespace-pre-wrap cursor-pointer" :class="{ 'line-clamp-4': !expanded.includes(r.teamId) }"
+            title="点击展开或收起" @click="expanded = expanded.includes(r.teamId) ? expanded.filter(x => x !== r.teamId) : [...expanded, r.teamId]"
+          >{{ r.job?.summary ?? r.reply }}</div>
+          <div v-else-if="r.state === 'running'" class="hint">执行中…</div>
+          <div v-if="r.job?.files.length" class="flex flex-wrap gap-2 hint">
+            <span v-for="f in r.job.files" :key="f.fileId" class="inline-flex items-center gap-1"><span class="codicon codicon-file" />{{ f.path }}</span>
+          </div>
+        </div>
+      </div>
+    </template>
+  </SidePane>
+</template>

+ 63 - 11
apps/admin/src/views/BotPlaygroundView.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 // Bot Playground(docs/design/13-bot-teams.md §12.2):以后台身份调用 /bot/v1,建工作组、下命令、查看每个机器人的执行过程;
-// 调试面板有成员(能力卡片、替身)、任务、过程、输入、文件、用量页签,顶栏可以开启逐步执行
+// 调试面板有成员(能力卡片、替身)、任务、过程、输入、文件、用量页签,顶栏可以开启逐步执行。
+// 侧栏切到"全部来源"时可以只读查看业务平台的工作组(只能暂停、关闭),并复制到 Playground 复现问题;任意工作组都可以保存为回归用例
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { AgentChat, BotAvatar, TeamChat, avatarText, basename, fileIcon, formatSize, useChatSession, useTeamSession, type ChatSession, type TeamSession } from '@agent-server/chat-ui'
@@ -27,9 +28,16 @@ const STATUS: Record<TeamStatus, { text: string; cls: string }> = {
 }
 const BOT_STATUS = { idle: '空闲', queued: '排队中', working: '工作中', waiting_input: '等待确认' } as const
 
+// 侧栏:Playground 中的工作组,或全部来源的工作组
+const scope = ref<'admin' | 'all'>('admin')
+const keyNames = ref<Record<string, string>>({})
+const sourceName = (source: string) => (source === 'admin' ? 'Playground' : keyNames.value[source] ?? source)
+// 业务平台的工作组只读:不能发消息、处理审批或改调试设置
+const readonly = computed(() => !!t.value && t.value.source !== 'admin')
+
 async function loadTeams() {
   try {
-    teams.value = await botClient.teams.list({ limit: 200 })
+    teams.value = await botClient.teams.list({ limit: 200, ...(scope.value === 'all' ? { scope: 'all' as const } : {}) })
   } catch (err) {
     toast(errorMessage(err), 'error')
   }
@@ -37,6 +45,11 @@ async function loadTeams() {
 
 // 工作组的状态或成员变化时刷新列表
 watch(() => [t.value?.status, t.value?.bots.length, t.value?.updatedAt], () => void loadTeams())
+watch(scope, () => void loadTeams())
+// 直接打开平台的工作组时切到全部来源,侧栏里才能看到它
+watch(() => t.value?.source, source => {
+  if (source && source !== 'admin') scope.value = 'all'
+})
 
 // ---- 新建工作组 ----
 const form = reactive<{ title: string; lead: string; workers: string[]; human: string; maxTasks: number | null; maxLeadRuns: number | null; saving: boolean }>({
@@ -98,6 +111,29 @@ function addBot(e: Event) {
   if (botId) void act(() => botClient.teams.addBot(teamId.value!, botId))
 }
 
+async function copyToPlayground() {
+  try {
+    const copy = await admin.copyTeam(teamId.value!)
+    scope.value = 'admin'
+    await loadTeams()
+    await router.push(`/bot-playground/${copy.id}`)
+    toast('已按相同的成员组成新建工作组', 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function saveCase() {
+  const name = window.prompt('回归用例名称(保存成员组成和成员发出的第一条消息,以后可以批量重放)', t.value?.title ?? '')?.trim()
+  if (!name) return
+  try {
+    await admin.createTeamCase(name, teamId.value!)
+    toast(`已保存为回归用例「${name}」`, 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
 async function removeTeam(x: TeamDto) {
   if (!confirm(`删除工作组「${x.title}」?消息、共享目录中的文件和各机器人的会话都会一起删除。`)) return
   try {
@@ -246,6 +282,7 @@ onMounted(async () => {
   void loadTeams()
   bots.value = await admin.bots().catch(() => [])
   cards.value = await botClient.bots().catch(() => [])
+  keyNames.value = Object.fromEntries((await admin.botKeys().catch(() => [])).map(k => [k.id, k.name]))
   timer = setInterval(loadTeams, 15000)
 })
 onBeforeUnmount(() => {
@@ -262,7 +299,16 @@ onBeforeUnmount(() => {
       <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 class="flex gap-1 px-4 pb-2">
+        <button
+          v-for="x in ([['admin', 'Playground'], ['all', '全部来源']] as const)" :key="x[0]" type="button"
+          class="flex-1 h-6 rounded-md border-none cursor-pointer text-[12px]" :class="scope === x[0] ? 'bg-active text-fg' : 'bg-transparent text-fg-muted hover:bg-hover'"
+          @click="scope = x[0]"
+        >
+          {{ x[1] }}
+        </button>
+      </div>
+      <div v-if="!teams.length" class="px-5 py-3 hint">{{ scope === 'admin' ? '还没有工作组,点击 + 新建' : '还没有工作组' }}</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"
@@ -273,11 +319,12 @@ onBeforeUnmount(() => {
           <div class="flex items-center gap-1 text-[11px] text-fg-muted truncate">
             <BotAvatar v-for="b in x.bots" :key="b.id" :value="b.avatar" :size="14" />
             <span class="ml-0.5">{{ relativeTime(x.updatedAt) }}</span>
+            <span v-if="scope === 'all'" class="truncate">· {{ sourceName(x.source) }}</span>
           </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)">
+        <button v-if="x.source === 'admin'" type="button" class="icon-btn opacity-0 group-hover:opacity-100" title="删除" @click.stop="removeTeam(x)">
           <span class="codicon codicon-trash" />
         </button>
       </div>
@@ -335,10 +382,15 @@ onBeforeUnmount(() => {
       <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 v-if="readonly" class="hint whitespace-nowrap" title="业务平台的工作组只能查看、暂停和关闭">来自 {{ sourceName(t!.source) }} · 只读</span>
         <span class="flex-1" />
         <template v-if="t">
+          <button v-if="readonly" type="button" class="btn-ghost h-6" title="按相同的成员组成在 Playground 中新建工作组,用来复现问题" @click="copyToPlayground">
+            <span class="codicon codicon-copy" />复制到 Playground
+          </button>
+          <button type="button" class="icon-btn" title="保存为回归用例:成员组成 + 第一条消息" @click="saveCase"><span class="codicon codicon-beaker" /></button>
           <button
-            type="button" class="btn-ghost h-6" :class="{ '!text-warning': debug.stepMode }"
+            v-if="!readonly" type="button" class="btn-ghost h-6" :class="{ '!text-warning': debug.stepMode }"
             title="逐步执行:每次唤醒机器人之前先停下,在时间线中点“继续”才执行" @click="toggleStep"
           >
             <span class="codicon codicon-debug-step-over" />逐步{{ debug.stepMode ? ':开' : '' }}
@@ -354,7 +406,7 @@ onBeforeUnmount(() => {
       </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" />
+          <TeamChat v-if="team" :key="teamId!" :client="botClient" :session="team" :readonly="readonly" @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' || tab === 'input' ? 'w-[520px]' : 'w-[380px]'">
           <div class="h-[35px] flex-none flex items-stretch border-b border-b-solid border-border text-[11px] font-600 uppercase tracking-wide">
@@ -395,18 +447,18 @@ onBeforeUnmount(() => {
                 <button type="button" class="btn-ghost" :disabled="!b.sessionId" @click="pickBot(b.id)"><span class="codicon codicon-list-tree" />过程</button>
                 <button type="button" class="btn-ghost" :disabled="!cardOf(b.id)" @click="cardOpen = cardOpen === b.id ? null : b.id"><span class="codicon codicon-symbol-class" />卡片</button>
                 <label
-                  v-if="b.role === 'worker'" class="flex items-center gap-1 px-1 cursor-pointer" :class="{ 'text-warning': debug.stubBotIds.includes(b.id) }"
+                  v-if="b.role === 'worker' && !readonly" class="flex items-center gap-1 px-1 cursor-pointer" :class="{ 'text-warning': debug.stubBotIds.includes(b.id) }"
                   title="替身:分派给它的子任务不执行,由你在时间线中填写结果"
                 >
                   <input type="checkbox" :checked="debug.stubBotIds.includes(b.id)" @change="toggleStub(b.id)">替身
                 </label>
-                <button v-if="b.status !== 'idle'" type="button" class="btn-ghost" @click="interruptBot(b.id)"><span class="codicon codicon-debug-stop" />停止</button>
+                <button v-if="b.status !== 'idle' && !readonly" 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>
+                <button v-if="!readonly" 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=""
+              v-if="addable.length && t.status !== 'closed' && !readonly" class="field" aria-label="添加机器人" value=""
               @change="addBot"
             >
               <option value="" disabled>+ 添加机器人…</option>
@@ -428,7 +480,7 @@ onBeforeUnmount(() => {
               <div class="hint">组长 {{ j.leadRuns }} 轮 · {{ j.taskCount }} 个子任务 · {{ money(j.costUsd) }} · {{ duration(j.createdAt, j.endedAt) }}</div>
               <div v-if="j.plan" class="hint line-clamp-3 whitespace-pre-wrap" :title="j.plan">{{ j.plan }}</div>
               <div v-if="j.summary" class="line-clamp-3 whitespace-pre-wrap" :title="j.summary">{{ j.summary }}</div>
-              <div v-if="j.status === 'running' || j.status === 'blocked'" class="flex gap-1">
+              <div v-if="(j.status === 'running' || j.status === 'blocked') && !readonly" class="flex gap-1">
                 <button v-if="j.status === 'blocked'" type="button" class="btn-ghost" @click="act(() => team!.continueJob(j.id))"><span class="codicon codicon-debug-continue" />继续</button>
                 <button type="button" class="btn-ghost" @click="act(() => team!.cancelJob(j.id))"><span class="codicon codicon-circle-slash" />取消任务</button>
               </div>

+ 17 - 0
apps/server/drizzle/0008_team_cases.sql

@@ -0,0 +1,17 @@
+CREATE TABLE `team_cases` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`lead_bot_id` text,
+	`worker_bot_ids` text DEFAULT '[]' NOT NULL,
+	`stub_bot_ids` text DEFAULT '[]' NOT NULL,
+	`limits` text,
+	`human_name` text NOT NULL,
+	`text` text NOT NULL,
+	`mentions` text DEFAULT '[]' NOT NULL,
+	`files` text DEFAULT '[]' 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
+ALTER TABLE `teams` ADD `case_id` text;--> statement-breakpoint
+CREATE INDEX `teams_case_idx` ON `teams` (`case_id`);

+ 2329 - 0
apps/server/drizzle/meta/0008_snapshot.json

@@ -0,0 +1,2329 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "f71bd72e-f1e8-45f0-a3a7-66eb3cd051ee",
+  "prevId": "4acfdb59-bfc6-49ae-bc98-51e3b542511f",
+  "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": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_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'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "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": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_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'))"
+        },
+        "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
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "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_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "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": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "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
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_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'))"
+        },
+        "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
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "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

@@ -57,6 +57,13 @@
       "when": 1793073249147,
       "tag": "0007_bot_jobs",
       "breakpoints": true
+    },
+    {
+      "idx": 8,
+      "version": "6",
+      "when": 1793079581381,
+      "tag": "0008_team_cases",
+      "breakpoints": true
     }
   ]
 }

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

@@ -2,7 +2,7 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import type { ProviderId } from '@agent-server/shared'
-import { communitySkillsDir, config, customSkillsDir, dbPath } from './config'
+import { communitySkillsDir, config, customSkillsDir, dbPath, teamCasesDir } from './config'
 import { createDb, migrateDb, type DB } from './db'
 import { ClaudeProvider } from './providers/claude/adapter'
 import { CodexProvider } from './providers/codex/adapter'
@@ -30,6 +30,7 @@ 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 { TeamCaseService } from './teams/cases'
 import { CardService } from './teams/cards'
 import { TeamService } from './teams/service'
 import { createSkillCatalog } from './services/skill-catalog'
@@ -92,8 +93,9 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
       forgetGatewaySession(id)
     },
   })
+  const teamCases = new TeamCaseService({ db, teams, dir: teamCasesDir() })
 
-  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, teamCards, skillCatalog }
+  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, teamCards, teamCases, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

+ 2 - 0
apps/server/src/config.ts

@@ -52,6 +52,8 @@ export const communitySkillsDir = () => path.join(managedSkillsDir(), 'community
 export const customSkillsDir = () => path.join(managedSkillsDir(), 'custom')
 export const customSkillStoreDir = () => path.join(config.dataDir, 'data', 'custom-skill-store')
 export const skillUploadsDir = () => path.join(config.dataDir, 'data', 'skill-uploads')
+/** 回归用例的附件(docs/design/13-bot-teams.md §12.2) */
+export const teamCasesDir = () => path.join(config.dataDir, 'data', 'team-cases')
 
 function parseTrustProxy(v: string | undefined): boolean | string[] {
   if (!v || v === 'false') return false

+ 20 - 1
apps/server/src/db/schema.ts

@@ -288,9 +288,11 @@ export const teams = sqliteTable('teams', {
   /** 调试设置(仅 admin 的工作组) */
   debug: text('debug', { mode: 'json' }).$type<TeamDebug>().notNull().default(sql`'{"stepMode":false,"stubBotIds":[]}'`),
   lastSeq: integer('last_seq').notNull().default(0),
+  /** 由回归用例重放创建时的用例 id */
+  caseId: text('case_id'),
   createdAt: createdAt(),
   updatedAt: updatedAt(),
-}, t => [index('teams_source_idx').on(t.source, t.updatedAt)])
+}, t => [index('teams_source_idx').on(t.source, t.updatedAt), index('teams_case_idx').on(t.caseId)])
 
 export const teamBots = sqliteTable('team_bots', {
   teamId: text('team_id').notNull(),
@@ -355,3 +357,20 @@ export const tasks = sqliteTable('tasks', {
   startedAt: text('started_at'),
   endedAt: text('ended_at'),
 }, t => [index('tasks_team_idx').on(t.teamId, t.no), index('tasks_job_idx').on(t.jobId)])
+
+/** 回归用例(docs/design/13-bot-teams.md §12.2):成员组成 + 第一条命令,批量重放后人工比对结果 */
+export const teamCases = sqliteTable('team_cases', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull(),
+  leadBotId: text('lead_bot_id'),
+  workerBotIds: text('worker_bot_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  stubBotIds: text('stub_bot_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  limits: text('limits', { mode: 'json' }).$type<Partial<TeamLimits>>(),
+  humanName: text('human_name').notNull(),
+  text: text('text').notNull(),
+  mentions: text('mentions', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  /** 第一条命令的附件,复制到 <dataDir>/data/team-cases/<id>/ 下 */
+  files: text('files', { mode: 'json' }).$type<{ name: string; size: number }[]>().notNull().default(sql`'[]'`),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})

+ 30 - 0
apps/server/src/http/routes/admin.ts

@@ -519,6 +519,36 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       reply.code(204)
     })
 
+    // 工作组:复制到 Playground;回归用例(docs/design/13-bot-teams.md §12.1、§12.2)
+    g.post<IdParams>('/teams/:id/copy', async (req, reply) => {
+      const team = await app.teamCases.copy(req.params.id)
+      reply.code(201)
+      return app.teams.toDto(team)
+    })
+    g.get('/team-cases', async () => app.teamCases.list())
+    g.post('/team-cases', async (req, reply) => {
+      const c = await app.teamCases.create(req.body)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `保存回归用例:${c.name}` })
+      reply.code(201)
+      return c
+    })
+    g.patch<IdParams>('/team-cases/:id', async req => app.teamCases.update(req.params.id, req.body))
+    g.delete<IdParams>('/team-cases/:id', async (req, reply) => {
+      await app.teamCases.delete(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除回归用例:${req.params.id}` })
+      reply.code(204)
+    })
+    g.get<IdParams>('/team-cases/:id/runs', async req => app.teamCases.runs(req.params.id))
+    // 重放一个或多个用例:每个用例新建一个后台工作组并发出第一条命令(会真实执行,产生成本)
+    g.post('/team-cases/replay', async (req, reply) => {
+      const { ids } = parse(z.object({ ids: z.array(z.string()).min(1).max(50) }), req.body)
+      const created = []
+      for (const id of ids) created.push(app.teams.toDto(await app.teamCases.replay(id)))
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `重放回归用例 ${ids.length} 个`, detail: { ids } })
+      reply.code(201)
+      return created
+    })
+
     // 审计
     g.get<{ Querystring: { kind?: AuditKind; sessionId?: string; limit?: string } }>('/audit', async req =>
       app.audit.list({ kind: req.query.kind, sessionId: req.query.sessionId, limit: req.query.limit ? Number(req.query.limit) : undefined }))

+ 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' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 162 - 0
apps/server/src/teams/cases.ts

@@ -0,0 +1,162 @@
+// 回归用例与"复制到 Playground"(docs/design/13-bot-teams.md §12.1、§12.2,09-admin-ui.md §3.6),仅后台使用。
+// 用例保存"成员组成 + 第一条命令"(含附件),重放时新建一个后台工作组并发出这条命令;
+// 每次重放的结果(任务总结或回复、轮数、成本)列在一起,由人工比对
+import fs from 'node:fs'
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import { desc, eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type { CreateTeamBody, CreateTeamCaseBody, TeamCaseDto, TeamCaseRunDto, TeamEventOf, TeamMessage } from '@agent-server/shared'
+import type { DB } from '../db'
+import { teamCases, teams } from '../db/schema'
+import { badRequest, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { parse } from '../services/catalog'
+import type { TeamRow, TeamService } from './service'
+
+type CaseRow = typeof teamCases.$inferSelect
+
+const createSchema: z.ZodType<CreateTeamCaseBody> = z.object({ name: z.string().trim().min(1).max(100), teamId: z.string() })
+const patchSchema = z.object({ name: z.string().trim().min(1).max(100), text: z.string().trim().min(1).max(100_000) }).partial()
+
+export class TeamCaseService {
+  constructor(private deps: { db: DB; teams: TeamService; dir: string }) {}
+
+  list(): TeamCaseDto[] {
+    return this.deps.db.select().from(teamCases).orderBy(desc(teamCases.updatedAt)).all().map(r => this.toDto(r))
+  }
+
+  get(id: string): TeamCaseDto {
+    return this.toDto(this.row(id))
+  }
+
+  /** 从工作组保存:当前成员组成、替身、限额,以及成员发出的第一条消息和它的附件 */
+  async create(body: unknown): Promise<TeamCaseDto> {
+    const data = parse(createSchema, body)
+    const team = this.deps.teams.getRow(data.teamId)
+    const first = this.firstHumanMessage(team.id)
+    if (!first) throw badRequest('这个工作组还没有成员发出的消息')
+    const members = this.deps.teams.toDto(team).bots
+    const id = newId('case')
+    const dir = path.join(this.deps.dir, id)
+    await fsp.mkdir(dir, { recursive: true })
+    const files: { name: string; size: number }[] = []
+    for (const ref of first.files) {
+      const name = path.basename(ref.path)
+      if (files.some(f => f.name === name)) continue
+      await fsp.copyFile(path.join(team.workspaceDir, ref.path), path.join(dir, name)).catch(() => undefined)
+      const st = await fsp.stat(path.join(dir, name)).catch(() => null)
+      if (st) files.push({ name, size: st.size })
+    }
+    this.deps.db.insert(teamCases).values({
+      id, name: data.name,
+      leadBotId: members.find(b => b.role === 'lead')?.id ?? null,
+      workerBotIds: members.filter(b => b.role === 'worker').map(b => b.id),
+      stubBotIds: team.source === 'admin' ? team.debug.stubBotIds.filter(x => members.some(b => b.id === x)) : [],
+      limits: team.limits, humanName: team.human.name, text: first.text,
+      mentions: first.mentions.filter(x => members.some(b => b.id === x)), files,
+    }).run()
+    return this.get(id)
+  }
+
+  update(id: string, body: unknown): TeamCaseDto {
+    this.row(id)
+    this.deps.db.update(teamCases).set({ ...parse(patchSchema, body), updatedAt: nowIso() }).where(eq(teamCases.id, id)).run()
+    return this.get(id)
+  }
+
+  /** 删除用例;重放产生的工作组保留 */
+  async delete(id: string) {
+    this.row(id)
+    this.deps.db.delete(teamCases).where(eq(teamCases.id, id)).run()
+    this.deps.db.update(teams).set({ caseId: null }).where(eq(teams.caseId, id)).run()
+    await fsp.rm(path.join(this.deps.dir, id), { recursive: true, force: true })
+  }
+
+  /** 重放:按用例新建后台工作组,上传附件并发出第一条命令 */
+  async replay(id: string): Promise<TeamRow> {
+    const c = this.row(id)
+    const stamp = new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
+    const team = await this.deps.teams.create('admin', {
+      title: `${c.name} · 回放 ${stamp}`,
+      human: { id: 'admin', name: c.humanName },
+      leadBotId: c.leadBotId ?? undefined, workerBotIds: c.workerBotIds,
+      ...(c.limits ? { limits: c.limits } : {}),
+      ...(c.stubBotIds.length ? { debug: { stubBotIds: c.stubBotIds } } : {}),
+    }, { caseId: c.id })
+    const dir = path.join(this.deps.dir, c.id)
+    const fileIds: string[] = []
+    if (c.files.length) {
+      const parts = c.files.filter(f => fs.existsSync(path.join(dir, f.name))).map(f => ({ filename: f.name, file: Object.assign(fs.createReadStream(path.join(dir, f.name)), { truncated: false }) }))
+      if (parts.length) fileIds.push(...(await this.deps.teams.upload(team.id, 'admin', toAsync(parts))).map(f => f.id))
+    }
+    this.deps.teams.postMessage(team.id, 'admin', { text: c.text, mentions: c.mentions, fileIds })
+    return this.deps.teams.getRow(team.id)
+  }
+
+  runs(id: string): TeamCaseRunDto[] {
+    this.row(id)
+    return this.caseTeams(id).map(t => this.runOf(t))
+  }
+
+  /** 复制到 Playground:按相同的成员组成和限额新建一个后台工作组(不复制消息),用来复现问题 */
+  async copy(teamId: string): Promise<TeamRow> {
+    const team = this.deps.teams.getRow(teamId)
+    const members = this.deps.teams.toDto(team).bots
+    const body: CreateTeamBody = {
+      title: `${team.title}(副本)`.slice(0, 100),
+      human: { id: 'admin', name: team.human.name },
+      leadBotId: members.find(b => b.role === 'lead')?.id,
+      workerBotIds: members.filter(b => b.role === 'worker').map(b => b.id),
+      ...(team.limits ? { limits: team.limits } : {}),
+    }
+    return this.deps.teams.create('admin', body)
+  }
+
+  private row(id: string): CaseRow {
+    const row = this.deps.db.select().from(teamCases).where(eq(teamCases.id, id)).get()
+    if (!row) throw notFound('回归用例')
+    return row
+  }
+
+  private caseTeams(id: string): TeamRow[] {
+    return this.deps.db.select().from(teams).where(eq(teams.caseId, id)).orderBy(desc(teams.createdAt), desc(teams.id)).all()
+  }
+
+  private toDto(r: CaseRow): TeamCaseDto {
+    const runs = this.caseTeams(r.id)
+    return {
+      id: r.id, name: r.name, leadBotId: r.leadBotId, workerBotIds: r.workerBotIds, stubBotIds: r.stubBotIds, limits: r.limits,
+      humanName: r.humanName, text: r.text, mentions: r.mentions, files: r.files, createdAt: r.createdAt, updatedAt: r.updatedAt,
+      runCount: runs.length, lastRun: runs[0] ? this.runOf(runs[0]) : null,
+    }
+  }
+
+  private runOf(team: TeamRow): TeamCaseRunDto {
+    const svc = this.deps.teams
+    const detail = svc.detail(team)
+    const busy = detail.bots.some(b => b.status !== 'idle') || detail.pendingInteractions.length > 0
+      || svc.jobs.tasks(team.id, { status: ['stub'] }).length > 0 || detail.pendingSteps.length > 0
+    const last = svc.jobs.jobs(team.id)[0]
+    const job = last ? svc.jobs.jobDto(last) : null
+    const reply = job ? null : this.messages(team.id).filter(m => m.sender.kind === 'bot').at(-1)?.text ?? null
+    const { total } = svc.usage(team.id, 'admin')
+    return {
+      teamId: team.id, createdAt: team.createdAt, state: busy ? 'running' : 'done',
+      job: job && { id: job.id, no: job.no, title: job.title, status: job.status, summary: job.summary, files: job.files, leadRuns: job.leadRuns, taskCount: job.taskCount, endedAt: job.endedAt },
+      reply, runs: total.runs, costUsd: total.costUsd,
+    }
+  }
+
+  private messages(teamId: string): TeamMessage[] {
+    return (this.deps.teams.events.list(teamId, 0, 100_000, ['message.created']) as TeamEventOf<'message.created'>[]).map(e => e.payload)
+  }
+
+  private firstHumanMessage(teamId: string): TeamMessage | undefined {
+    return this.messages(teamId).find(m => m.sender.kind === 'human')
+  }
+}
+
+async function* toAsync<T>(items: T[]) {
+  yield* items
+}

+ 5 - 3
apps/server/src/teams/prompt.ts

@@ -34,10 +34,12 @@ function leadRules(cards: BotCard[]): string {
 - 需要组员配合的复杂工作:先调用 job_start(title, plan) 开始一项任务(同一时间只能有一项),再用 task_create(bot_id, title, instructions, depends_on) 把子任务分派给组员。
   - 说明要写清楚输入文件、期望的产出(文件放在 outputs/ 下的哪里)和验收标准,组员看不到你和成员的对话。
   - 互不依赖的子任务会并行执行;depends_on 填写需要先完成的子任务编号,组员会收到它们的结果。
-- 分派之后结束本轮回复,简短告诉成员你的安排。组员完成或失败时你会被唤醒,收到结果后继续分派、调整计划或重试。
-- 所有子任务都完成后,调用 job_complete(summary, files) 交付:summary 是给成员看的总结,files 是交付文件(outputs/ 下的路径)。无法完成时调用 job_fail(reason)。之后的最终回复简短即可。
+- 分派之后结束本轮回复,简短告诉成员你的安排。每个子任务结束时你都会被唤醒,输入的最后附有当前任务的进度:
+  - 还有子任务在进行、没有需要处理的事情时,简短回复并结束本轮
+  - 子任务失败时,用 task_retry(task_no, instructions) 重试(可以改写说明,依赖它的子任务会继续),或者分派给其他组员、调整计划
+- 所有子任务都结束后,调用 job_complete(summary, files) 交付:summary 是给成员看的总结,files 是交付文件(outputs/ 下的路径)。无法完成时调用 job_fail(reason)。之后的最终回复简短即可。
 - 任务进行中成员发来的消息,是对当前任务的补充说明。
-- 现有组员都不适合时,可以用 team_bot_catalog() 查看可加入的机器人,再用 team_request_bot(bot_id, reason) 申请,由成员审批。
+- 现有组员都不适合(或还没有组员)时,用 team_bot_catalog() 查看可加入的机器人,再用 team_request_bot(bot_id, reason) 申请,由成员审批;不要因为没有组员就自己动手。
 - 超出限额时任务会暂停,等待成员决定,你不需要处理。
 - 不要自己动手完成本该由组员完成的工作。
 

+ 45 - 5
apps/server/src/teams/service.ts

@@ -144,7 +144,7 @@ export class TeamService {
 
   // ---- 工作组 ----
 
-  async create(source: string, body: CreateTeamBody): Promise<TeamRow> {
+  async create(source: string, body: CreateTeamBody, opts: { caseId?: string } = {}): Promise<TeamRow> {
     const data = parse(createSchema, body)
     const workers = [...new Set(data.workerBotIds ?? [])]
     const members: { bot: BotDto; role: BotDto['kind'] }[] = []
@@ -159,7 +159,7 @@ export class TeamService {
     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, limits, ...(debug ? { debug } : {}) }).run()
+      tx.insert(teams).values({ id, source, title, human: data.human, workspaceDir, limits, caseId: opts.caseId ?? null, ...(debug ? { debug } : {}) }).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')
@@ -499,6 +499,8 @@ export class TeamService {
         const job = this.jobs.active(team.id)
         if (!job) throw new Error('当前没有进行中的任务')
         const done = name === 'job_complete'
+        const open = done ? this.jobs.tasks(team.id, { jobId: job.id, status: OPEN_TASK }) : []
+        if (open.length) throw new Error(`还有未结束的子任务:${open.map(t => `#${t.no}(${TASK_STATUS[t.status]})`).join('、')}。请等它们完成,或先用 task_cancel 取消`)
         const files = done ? await this.resolvePaths(team, (args.files as string[] | undefined) ?? []) : []
         this.endJob(job, done ? 'completed' : 'failed', String(done ? args.summary : args.reason), files, ref.botId)
         rt.posted = true
@@ -510,7 +512,10 @@ export class TeamService {
         if (this.roleOf(team.id, botId) !== 'worker') throw new Error('bot_id 不是本工作组的组员,请查看 team_members')
         const dependsOn = [...new Set((args.depends_on as number[] | undefined) ?? [])]
         for (const no of dependsOn) {
-          if (this.jobs.taskByNo(team.id, no)?.jobId !== job.id) throw new Error(`依赖的子任务 #${no} 不属于当前任务`)
+          const dep = this.jobs.taskByNo(team.id, no)
+          if (dep?.jobId !== job.id) throw new Error(`依赖的子任务 #${no} 不属于当前任务`)
+          // 依赖已失败或取消时新子任务永远不会开始
+          if (dep.status === 'failed' || dep.status === 'cancelled') throw new Error(`依赖的子任务 #${no} ${TASK_STATUS[dep.status]},请先用 task_retry 重试它,或者不依赖它`)
         }
         const count = this.jobs.tasks(team.id, { jobId: job.id }).length
         const max = job.limits.maxTasksPerJob
@@ -528,6 +533,23 @@ export class TeamService {
         this.dispatchTasks(team.id)
         return `子任务 #${task.no} 已分派给 ${this.botName(botId)}。`
       }
+      case 'task_retry': {
+        const job = this.requireRunningJob(team.id)
+        const task = this.jobs.taskByNo(team.id, Number(args.task_no))
+        if (!task || task.jobId !== job.id) throw new Error('子任务不存在或不属于当前任务')
+        if (task.status !== 'failed' && task.status !== 'cancelled') throw new Error(`子任务 #${task.no} ${TASK_STATUS[task.status]},只能重试失败或已取消的子任务`)
+        if (this.roleOf(team.id, task.botId) !== 'worker') throw new Error(`${this.botName(task.botId)} 已不在工作组中,请用 task_create 分派给其他组员`)
+        const instructions = typeof args.instructions === 'string' && args.instructions.trim() ? args.instructions : task.instructions
+        const next = this.jobs.updateTask(task.id, { status: 'waiting', instructions, result: null, files: [], runId: null, startedAt: null, endedAt: null })
+        this.emitTask(next)
+        this.botPost(team.id, ref.botId, {
+          text: `#${task.no} → ${this.botName(task.botId)}:${task.title}(重试)${instructions !== task.instructions ? `\n\n${instructions}` : ''}`,
+          card: { kind: 'task', taskId: task.id, no: task.no, event: 'assigned' },
+        })
+        rt.posted = true
+        this.dispatchTasks(team.id)
+        return `子任务 #${task.no} 已重新分派给 ${this.botName(task.botId)},依赖它的子任务会在它完成后继续。`
+      }
       case 'task_cancel': {
         const task = this.jobs.taskByNo(team.id, Number(args.task_no))
         if (!task || !OPEN_TASK.includes(task.status)) throw new Error('子任务不存在或已经结束')
@@ -712,10 +734,11 @@ export class TeamService {
         }
       }
     }
-    const run = this.deps.runs.start(session, teamInput(relevant, botId), attachments)
+    const job = member.role === 'lead' ? this.jobs.active(team.id) : undefined
+    const input = teamInput(relevant, botId) + (job ? `\n\n${this.progressText(job)}` : '')
+    const run = this.deps.runs.start(session, input, attachments)
     this.deps.db.update(teamBotSessions).set({ lastReadSeq: readUpTo })
       .where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).run()
-    const job = member.role === 'lead' ? this.jobs.active(team.id) : undefined
     if (job) {
       this.jobs.addRun(job.id, run.id, true)
       this.emitJob(this.jobs.job(job.id))
@@ -867,6 +890,23 @@ export class TeamService {
     this.changed(job.teamId)
   }
 
+  /** 交给组长的任务进度:每个子任务的状态,以及因依赖失败而无法开始的子任务 */
+  private progressText(job: JobRow): string {
+    const list = this.jobs.tasks(job.teamId, { jobId: job.id })
+    const head = `【当前任务 #${job.no}「${job.title}」${job.status === 'blocked' ? '(已暂停)' : ''}的进度】`
+    if (!list.length) return `${head}\n还没有子任务。`
+    const byNo = new Map(list.map(t => [t.no, t]))
+    const brokenOf = (t: TaskRow) => (t.status === 'waiting' ? t.dependsOn.filter(no => ['failed', 'cancelled'].includes(byNo.get(no)?.status ?? '')) : [])
+    const lines = list.map(t => {
+      const broken = brokenOf(t)
+      const note = broken.length ? ` · 依赖的 ${broken.map(n => `#${n}`).join('、')} 没有完成,无法开始(用 task_retry 重试依赖,或 task_cancel 取消它)` : ''
+      return `#${t.no} ${this.botName(t.botId)} · ${t.title} · ${TASK_STATUS[t.status]}${note}`
+    })
+    // 只剩无法开始的子任务时需要组长处理,不提示等待
+    const pending = list.some(t => OPEN_TASK.includes(t.status) && !brokenOf(t).length)
+    return `${head}\n${lines.join('\n')}${pending ? '\n还有子任务在进行;没有需要你处理的事情时简短回复并结束本轮,它们结束后会再唤醒你。' : ''}`
+  }
+
   private requireRunningJob(teamId: string): JobRow {
     const job = this.jobs.active(teamId)
     if (!job) throw new Error('当前没有进行中的任务,请先调用 job_start')

+ 6 - 1
apps/server/src/teams/tools.ts

@@ -24,6 +24,10 @@ const schemas = {
     instructions: z.string().min(1).max(50_000).describe('完整说明:输入、期望产出、验收标准'),
     depends_on: z.array(z.number().int()).max(20).optional().describe('需要先完成的子任务编号'),
   }),
+  task_retry: z.object({
+    task_no: z.number().int().describe('失败或已取消的子任务编号'),
+    instructions: z.string().max(50_000).optional().describe('新的说明;省略时沿用原来的说明'),
+  }),
   task_cancel: z.object({ task_no: z.number().int().describe('子任务编号') }),
   task_list: z.object({}),
   task_complete: z.object({ result: z.string().min(1).max(50_000).describe('结果:做了什么、结论'), files: filesArg }),
@@ -45,13 +49,14 @@ const DESCRIPTIONS: Record<CollabToolName, string> = {
   job_complete: '完成当前任务并交付给成员。',
   job_fail: '当前任务无法完成,说明原因。',
   task_create: '把一个子任务分派给组员。依赖的子任务完成后它才会开始;互不依赖的子任务并行执行。',
+  task_retry: '重新执行一个失败或已取消的子任务(编号不变),依赖它的子任务会在它完成后继续。',
   task_cancel: '取消一个还没有结束的子任务(正在执行的会被中断)。',
   task_list: '列出当前任务的全部子任务及其状态、结果。',
   task_complete: '汇报当前子任务的结果(只在执行组长分派的子任务时使用)。',
   task_fail: '当前子任务无法完成,说明原因。',
 }
 
-export const LEAD_TOOLS: CollabToolName[] = ['team_members', 'team_bot_catalog', 'team_request_bot', 'team_history', 'job_start', 'job_complete', 'job_fail', 'task_create', 'task_cancel', 'task_list']
+export const LEAD_TOOLS: CollabToolName[] = ['team_members', 'team_bot_catalog', 'team_request_bot', 'team_history', 'job_start', 'job_complete', 'job_fail', 'task_create', 'task_retry', 'task_cancel', 'task_list']
 export const WORKER_TOOLS: CollabToolName[] = ['team_history', 'task_complete', 'task_fail']
 
 export function collabTools(names: CollabToolName[], handle: CollabHandler): BuiltinTool[] {

+ 103 - 1
apps/server/test/team-jobs.test.ts

@@ -218,6 +218,62 @@ describe('任务流程', () => {
   })
 })
 
+describe('子任务的依赖', () => {
+  it('依赖失败时后续子任务无法开始;task_retry 重试后继续;还有未结束的子任务时不能 job_complete', async () => {
+    const team = (await server.inject({
+      method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
+      payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员'], ids['文档员']], debug: { stubBotIds: [ids['数据员'], ids['文档员']] } },
+    })).json() as TeamDetailDto
+    const checks: Record<string, { text: string; isError: boolean }> = {}
+    let progress = ''
+    fake.scripts['组长'] = async (input, call) => {
+      if (input.includes('子任务 #2 已完成')) {
+        await call('job_complete', { summary: '完成' })
+        return '交付'
+      }
+      if (input.includes('子任务 #1 失败')) {
+        progress = input
+        checks.dep = await call('task_create', { bot_id: ids['文档员'], title: 'x', instructions: 'x', depends_on: [1] })
+        checks.complete = await call('job_complete', { summary: '提前交付' })
+        checks.retry = await call('task_retry', { task_no: 1, instructions: '换个数据源' })
+        checks.retryAgain = await call('task_retry', { task_no: 1 })
+        return '重试 #1'
+      }
+      if (input.includes('子任务 #')) return '等待'
+      await call('job_start', { title: '依赖', plan: '' })
+      await call('task_create', { bot_id: ids['数据员'], title: '取数', instructions: '取数据' })
+      await call('task_create', { bot_id: ids['文档员'], title: '写报告', instructions: '写', depends_on: [1] })
+      return '已分派'
+    }
+    const stub = async (no: number, outcome: 'completed' | 'failed', result: string) => {
+      const task = await waitFor(() => app.teams.jobs.tasks(team.id, { status: ['stub'] }).find(t => t.no === no))
+      const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/tasks/${task.id}/stub`, cookies: adminCookie, payload: { outcome, result } })
+      expect(r.statusCode).toBe(200)
+      return task
+    }
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '开始' } })
+    await stub(1, 'failed', '数据缺失')
+    await waitFor(() => checks.retryAgain)
+    // 交给组长的进度中标出无法开始的子任务
+    expect(progress).toContain('#2 文档员 · 写报告 · 等待依赖 · 依赖的 #1 没有完成,无法开始')
+    expect(checks.dep).toMatchObject({ isError: true })
+    expect(checks.dep!.text).toContain('task_retry')
+    expect(checks.complete).toMatchObject({ isError: true })
+    expect(checks.complete!.text).toContain('#2(等待依赖)')
+    expect(checks.retry).toMatchObject({ isError: false })
+    expect(checks.retryAgain).toMatchObject({ isError: true })
+
+    const retried = await stub(1, 'completed', '取到了')
+    expect(retried.instructions).toBe('换个数据源')
+    await stub(2, 'completed', '报告好了')
+    const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
+    expect(job.summary).toBe('完成')
+    expect(app.teams.jobs.tasks(team.id, { jobId: job.id }).map(t => [t.no, t.status])).toEqual([[1, 'completed'], [2, 'completed']])
+    // 编号不变:没有新建子任务
+    expect((await jobsOf(team.id, adminCookie)).jobs[0]!.taskCount).toBe(2)
+  })
+})
+
 describe('限额', () => {
   it('超出子任务数上限时任务暂停,成员选择继续后放宽一倍、组长被唤醒;也可以取消', async () => {
     const team = (await server.inject({
@@ -296,7 +352,7 @@ describe('Bot Playground 的调试功能', () => {
         await call('job_complete', { summary: `汇总:${input.includes('算出 42') ? '42' : '?'}` })
         return '完成'
       }
-      await call('job_start', { title: '替身测试', plan: '' })
+      await call('job_start', { title: '算数测试', plan: '' })
       await call('task_create', { bot_id: ids['数据员'], title: '算数', instructions: '算 6×7' })
       return '已分派'
     }
@@ -340,3 +396,49 @@ describe('Bot Playground 的调试功能', () => {
     await waitFor(() => messages(team.id).filter(m => m.sender.kind === 'bot').length === 2)
   })
 })
+
+describe('复制到 Playground 与回归用例', () => {
+  it('从平台的工作组复制、保存用例(含附件),重放后列出结果', async () => {
+    const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, title: '数据分析', leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
+    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="q3.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n1,2\n\r\n--${boundary}--\r\n`,
+    })
+    const fileId = up.json()[0].id
+    fake.scripts['组长'] = async input => (input.includes('uploads/q3.csv') ? '看到附件了' : '没有附件')
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '分析 Q3 数据', fileIds: [fileId] } })
+    await waitFor(() => idle(team.id) && messages(team.id).some(m => m.sender.kind === 'bot'))
+
+    // 复制到 Playground:相同的成员组成,来源为后台,不复制消息
+    const copied = await server.inject({ method: 'POST', url: `/admin/v1/teams/${team.id}/copy`, cookies: adminCookie })
+    expect(copied.statusCode).toBe(201)
+    expect(copied.json()).toMatchObject({ source: 'admin', title: '数据分析(副本)' })
+    expect(copied.json().bots.map((b: { id: string; role: string }) => [b.id, b.role])).toEqual([[ids['组长'], 'lead'], [ids['数据员'], 'worker']])
+    expect(messages(copied.json().id).every(m => m.sender.kind === 'system')).toBe(true)
+    expect((await server.inject({ method: 'POST', url: `/admin/v1/teams/${team.id}/copy`, headers: bk() })).statusCode).toBe(401)
+
+    const saved = await server.inject({ method: 'POST', url: '/admin/v1/team-cases', cookies: adminCookie, payload: { name: 'Q3 分析', teamId: team.id } })
+    expect(saved.statusCode).toBe(201)
+    const c = saved.json()
+    expect(c).toMatchObject({ name: 'Q3 分析', leadBotId: ids['组长'], workerBotIds: [ids['数据员']], text: '分析 Q3 数据', files: [{ name: 'q3.csv', size: 8 }], runCount: 0 })
+
+    const replayed = await server.inject({ method: 'POST', url: '/admin/v1/team-cases/replay', cookies: adminCookie, payload: { ids: [c.id] } })
+    expect(replayed.statusCode).toBe(201)
+    const run = replayed.json()[0]
+    expect(run.source).toBe('admin')
+    await waitFor(() => idle(run.id) && messages(run.id).some(m => m.sender.kind === 'bot'))
+    const first = messages(run.id).find(m => m.sender.kind === 'human')!
+    expect(first.files.map(f => f.path)).toEqual(['uploads/q3.csv'])
+    const runs = (await server.inject({ method: 'GET', url: `/admin/v1/team-cases/${c.id}/runs`, cookies: adminCookie })).json()
+    expect(runs).toHaveLength(1)
+    expect(runs[0]).toMatchObject({ teamId: run.id, state: 'done', job: null, reply: '看到附件了', runs: 1 })
+    const list = (await server.inject({ method: 'GET', url: '/admin/v1/team-cases', cookies: adminCookie })).json()
+    expect(list.find((x: { id: string }) => x.id === c.id)).toMatchObject({ runCount: 1, lastRun: { teamId: run.id } })
+
+    expect((await server.inject({ method: 'DELETE', url: `/admin/v1/team-cases/${c.id}`, cookies: adminCookie })).statusCode).toBe(204)
+    // 重放产生的工作组保留
+    expect(app.teams.getRow(run.id).caseId).toBeNull()
+  })
+})

+ 2 - 2
docs/README.md

@@ -9,7 +9,7 @@ AgentServer 是一个自托管的 Agent 服务:在服务端接入 **Claude Cod
 ```
 docs/
 ├─ README.md                  本文件:索引、术语、文档约定
-├─ integration.md             业务平台接入指南(SDK 安装、浏览器令牌、<AgentChat> 组件)
+├─ integration.md             业务平台接入指南(SDK 安装、浏览器令牌、<AgentChat>、<TeamChat> 组件)
 ├─ design/                    设计文档(随设计演进持续更新)
 │  ├─ 01-requirements.md      需求与范围
 │  ├─ 02-architecture.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/                  调研材料(选型依据、参考实现分析)
 ```

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

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

+ 32 - 1
docs/design/13-bot-teams.md

@@ -1,6 +1,6 @@
 # 13 Bot 工作组
 
-> 状态:**一期(2026-10-26)、二期(2026-10-27)已实施**,三期待做;实施中的调整见 §15、§16。决策见 ADR-0023(工作组协作模型)、ADR-0024(Bot API 与独立的 Bot Key)。
+> 状态:**一期(2026-10-26)、二期与三期(2026-10-27)已实施**;实施中的调整见 §15、§16、§17。决策见 ADR-0023(工作组协作模型)、ADR-0024(Bot API 与独立的 Bot Key)。
 
 ## 1. 背景与目标
 
@@ -398,3 +398,34 @@ SSE 事件:
 - 任务暂停(超出限额)期间不唤醒组长,组员的结果先记录,成员选择继续后组长一并看到。
 - 同一工作组的文件扫描依次进行(并发扫描会把同一个新文件登记两次)。
 - 已用真实 Claude(haiku)验收:组长调用 `job_start`、`task_create`(含依赖)分派给两个替身组员,管理员填写结果后组长被依次唤醒,核对文件时触发的审批在工作组中处理,最后结束任务。
+
+## 17. 三期实施记录(2026-10-27)
+
+已实现:
+- **SDK 0.3.0**:`BotClient`、`<TeamChat>`、`useTeamSession` 随 SDK 发布,接入方式写入 `docs/integration.md` §8。
+- **platform-demo**:增加"工作组"页面,平台后端用 Bot Key 换令牌(`/api/bot-token`)。
+- **子任务依赖与并行的完善**:
+  - 新增协作工具 `task_retry(task_no, instructions)`:重新执行失败或已取消的子任务,编号不变,依赖它的子任务在它完成后继续。
+  - `task_create` 不能依赖已失败或已取消的子任务。
+  - 还有未结束的子任务时 `job_complete` 被拒绝。
+  - 组长每次被唤醒时,输入末尾附有当前任务的进度,并标出因依赖失败而无法开始的子任务。
+- **后台**:
+  - Bot Playground 的侧栏可以切到"全部来源",只读查看业务平台的工作组(只能暂停、关闭)并"复制到 Playground"。
+  - 任意工作组可以"保存为回归用例"。
+  - 新增"回归用例"页面:批量重放,每次的结果(任务总结或回复、轮数、子任务数、成本)列在一起。
+
+与前文设计相比的调整:
+
+| 设计 | 实施 | 原因 |
+|---|---|---|
+| 单独的"工作组"页面 | 合并到 Bot Playground 的侧栏(Playground / 全部来源),平台的工作组以只读方式打开 | 视图本来就与 Playground 相同,不需要两套页面 |
+| 回归用例覆盖会话(09 §3.6)和工作组 | 先做工作组的用例:成员组成、替身、限额、第一条消息和附件(附件复制到 `data/team-cases/`);重放时新建来源为 `admin` 的工作组,记录 `teams.caseId` | 工作组的行为受提示词和组长的拆分影响最大,最需要回归;会话的用例以后按需再做 |
+| 结果比对 | 人工比对,不做自动判定 | 与 09 §3.6 一致 |
+
+实现要点:
+- 组长没有组员时,提示词要求先申请而不是自己动手(验收时组长曾直接用 Bash 完成了本应分派的工作)。
+- 重放会真实执行(替身组员除外),后台在重放前提示会产生成本。
+- 已在 platform-demo 中用真实 Claude(haiku)验收:
+  - 只有组长的工作组:组长申请加入数据员和文档员(在 `<TeamChat>` 的审批卡片中批准),拆成有依赖的两个子任务,交付两个文件。
+  - 子任务数上限为 1 时任务暂停,选择继续后完成。
+  - 把这个工作组保存为回归用例并重放,结果一致。

+ 84 - 6
docs/integration.md

@@ -1,6 +1,6 @@
 # 业务平台接入指南
 
-把 AgentServer 的对话能力(Claude / Codex、流式输出、审批与提问、文件上传与产出)接入自己的平台(Vue 3 + TS)。可以直接对照完整的示例项目 [`examples/platform-demo`](../examples/platform-demo):它由一个 Vue 前端和一个约 30 行的 Node 后端组成。
+把 AgentServer 的对话能力(Claude / Codex、流式输出、审批与提问、文件上传与产出)接入自己的平台(Vue 3 + TS)。可以直接对照完整的示例项目 [`examples/platform-demo`](../examples/platform-demo):它由一个 Vue 前端和一个约 50 行的 Node 后端组成,同时演示了单个助手的对话和机器人工作组(§8)。
 
 ## 0. 工作方式
 
@@ -47,16 +47,16 @@ app.post('/api/agent-token', requireLogin, async (req, res) => {
 SDK 由 AgentServer 自己提供,三个包需要一起安装:
 
 ```bash
-pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.2.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-client-0.2.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.2.0.tgz
+pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.3.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-client-0.3.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.3.0.tgz
 ```
 
 | 包 | 内容 |
 |---|---|
 | `@agent-server/shared` | API 与事件的类型、常量 |
-| `@agent-server/client` | `AgentClient`:会话、消息、SSE 订阅(断线续传)、交互回复、上传下载 |
-| `@agent-server/chat-ui` | 对话组件 `<AgentChat>` 与状态 `useChatSession`(依赖 `vue@^3.5`) |
+| `@agent-server/client` | `AgentClient`:会话、消息、SSE 订阅(断线续传)、交互回复、上传下载;`BotClient`:机器人工作组(§8) |
+| `@agent-server/chat-ui` | 对话组件 `<AgentChat>` 与状态 `useChatSession`;工作组组件 `<TeamChat>` 与状态 `useTeamSession`(依赖 `vue@^3.5`) |
 
 最新版本号见 `https://agent.zenpower.cc/sdk/index.json`。升级时修改上面 URL 中的版本号即可,旧版本会一直保留。
 
@@ -152,3 +152,81 @@ await client.usage(30)                                    // 本平台最近 30
 | 403 `profile_not_allowed` | 创建会话时指定的 Profile 不在该 Key 的可用列表中 |
 | 流式输出一段一段地出现,或很久才出现 | 平台与 AgentServer 之间有反向代理在缓冲响应:对 `/v1/sessions/*/stream` 关闭缓冲(nginx:`proxy_buffering off`) |
 | 安装时报找不到 `@agent-server/shared` | 三个包要用 URL 一起安装(它们是对等依赖,不在 npm 仓库中) |
+| Bot API 返回 401 | Agent Key 和 Bot Key 互不通用:`/bot/v1` 要用 Bot Key 换的令牌(`/bot/v1/browser-tokens`) |
+
+## 8. 机器人工作组(Bot API)
+
+机器人工作组让多个机器人在一个"群"里协作:成员(平台的用户)发消息,组长理解需求、开任务并把子任务分派给组员,组员完成后组长汇总交付。设计见 [design/13-bot-teams.md](design/13-bot-teams.md),完整接口见后台"API 协议 › Bot API"。
+
+接入方式与单个助手相同,只是换成 Bot Key 和 `/bot/v1`:
+
+1. **后台准备**:在"机器人"页面配置机器人(组长 / 组员),在 **API Keys › Bot Keys** 签发 Bot Key 并勾选这个平台可以使用的机器人。CORS 来源与 §1 相同。
+2. **平台后端**:用 Bot Key 换浏览器令牌,接口是 `POST /bot/v1/browser-tokens`,参数和返回值与 §2 相同。
+3. **前端**:
+
+```vue
+<script setup lang="ts">
+import { onBeforeUnmount, shallowRef } from 'vue'
+import { BotClient, browserTokenProvider } from '@agent-server/client'
+import { TeamChat, useTeamSession, type TeamSession } from '@agent-server/chat-ui'
+
+const bots = new BotClient({
+  baseUrl: 'https://agent.zenpower.cc',
+  getToken: browserTokenProvider(async () => (await fetch('/api/bot-token', { method: 'POST' })).json()),
+})
+
+const team = shallowRef<TeamSession | null>(null)
+async function newTeam(user: { id: string; name: string }) {
+  const cards = await bots.bots()                         // 可用的机器人及其能力卡片
+  const lead = cards.find(b => b.kind === 'lead')
+  const t = await bots.teams.create({
+    title: '周报',
+    human: user,                                          // 成员:平台里的这个用户
+    leadBotId: lead?.id,                                  // 有组长时,组员可以由组长申请加入(需成员批准)
+    workerBotIds: cards.filter(b => b.kind === 'worker').map(b => b.id),
+  })
+  team.value?.dispose()
+  team.value = useTeamSession(bots, t.id)                 // 拉取历史 + 订阅工作组事件
+}
+onBeforeUnmount(() => team.value?.dispose())
+</script>
+
+<template>
+  <div style="height: 100vh">
+    <TeamChat v-if="team" :client="bots" :session="team" placeholder="@ 某个机器人,或直接交给组长…" />
+  </div>
+</template>
+```
+
+`<TeamChat>` 已经处理了:
+- 消息时间线
+- 输入框中的 @ 选择
+- 组长的任务与子任务进度
+- 超出限额时的"继续 / 取消"
+- 审批卡片(含组长申请加入机器人)
+- 文件卡片与上传
+- 正在工作的机器人和"停止"
+- 断线续传
+
+| 属性 / 事件 | 说明 |
+|---|---|
+| `client`、`session` | 必填:`BotClient` 实例和 `useTeamSession()` 的返回值 |
+| `readonly` | 只读:不显示输入框,不能处理审批和限额 |
+| `placeholder` | 输入框提示文字 |
+| `@open-file` | 同 `<AgentChat>` |
+
+常用接口:
+
+```ts
+await bots.teams.list({ status: 'active' })               // 本平台的工作组
+await bots.teams.send(id, { text: '…', mentions: [botId] }) // @ 组员时只交给它,不 @ 时交给组长
+await bots.teams.pause(id) / resume(id) / close(id)
+await bots.teams.addBot(id, botId) / removeBot(id, botId)
+await bots.teams.jobs(id)                                 // 任务与子任务
+await bots.teams.usage(id)                                // 按机器人、任务、子任务统计的用量
+```
+
+说明:
+- 一个工作组只有一个成员(`human`),平台用户之间不共享工作组。
+- 限额(子任务数、组长轮数、时长、成本)以 AgentServer 的系统设置为上限,创建工作组时只能调低。
+- 替身组员、逐步执行是后台 Bot Playground 的调试功能,平台不能设置。

+ 6 - 4
examples/platform-demo/README.md

@@ -2,13 +2,15 @@
 
 演示业务平台的完整接入方式(详见 `docs/integration.md`):
 
-- 前端安装 AgentServer 提供的 SDK 包,使用 `<AgentChat>` 组件
-- 平台后端(`server.mjs`)保存 API Key,为浏览器换取短期令牌
+- 前端安装 AgentServer 提供的 SDK 包
+  - "对话"页面使用 `<AgentChat>`(Agent API,§4)
+  - "工作组"页面使用 `<TeamChat>`(Bot API,§8):选组长和组员建工作组,组长拆分任务、分派给组员、汇总交付
+- 平台后端(`server.mjs`)保存 Key,为浏览器换取短期令牌:Agent Key 用于对话,Bot Key 用于工作组,没有设置的那一个页面不显示
 
 ```bash
 # 1. 安装(把 AGENT_SERVER_URL 换成实际地址,例如 https://agent.zenpower.cc)
 sed -i 's#AGENT_SERVER_URL#https://agent.zenpower.cc#' package.json && pnpm install
-# 2. 在 AgentServer 后台:签发 API Key;设置 › 安全 › CORS 来源中加入本平台的地址
+# 2. 在 AgentServer 后台:签发 Agent Key 和 / 或 Bot Key(Bot Key 勾选可用的机器人);设置 › 安全 › CORS 来源中加入本平台的地址
 # 3. 构建并启动
-pnpm build && AGENT_SERVER_URL=https://agent.zenpower.cc AGENT_API_KEY=ask_xxx pnpm start
+pnpm build && AGENT_SERVER_URL=https://agent.zenpower.cc AGENT_API_KEY=ask_xxx AGENT_BOT_KEY=bk_xxx pnpm start
 ```

+ 3 - 3
examples/platform-demo/package.json

@@ -9,9 +9,9 @@
     "typecheck": "vue-tsc --noEmit"
   },
   "dependencies": {
-    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.2.0.tgz",
-    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.2.0.tgz",
-    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.2.0.tgz",
+    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.3.0.tgz",
+    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.3.0.tgz",
+    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.3.0.tgz",
     "vue": "^3.5.44"
   },
   "devDependencies": {

+ 14 - 7
examples/platform-demo/server.mjs

@@ -1,5 +1,6 @@
-// 业务平台后端示例(无依赖):提供构建好的前端,并用 API Key 为浏览器换取短期令牌。
-// API Key 只存在于后端环境变量中,浏览器拿到的是 1 小时有效的浏览器令牌。
+// 业务平台后端示例(无依赖):提供构建好的前端,并用 Key 为浏览器换取短期令牌。
+// Key 只存在于后端环境变量中,浏览器拿到的是 1 小时有效的浏览器令牌:
+//   AGENT_API_KEY → /v1(单个助手的对话),AGENT_BOT_KEY → /bot/v1(机器人工作组),至少设置一个。
 // 真实平台应先校验自己的登录态(只给已登录用户签发令牌)。
 import fs from 'node:fs'
 import http from 'node:http'
@@ -8,18 +9,24 @@ import { fileURLToPath } from 'node:url'
 
 const AGENT_SERVER_URL = process.env.AGENT_SERVER_URL ?? 'http://127.0.0.1:8787'
 const AGENT_API_KEY = process.env.AGENT_API_KEY
+const AGENT_BOT_KEY = process.env.AGENT_BOT_KEY
 const PORT = Number(process.env.PORT ?? 5180)
 const dist = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist')
-if (!AGENT_API_KEY) throw new Error('请设置环境变量 AGENT_API_KEY')
+if (!AGENT_API_KEY && !AGENT_BOT_KEY) throw new Error('请设置环境变量 AGENT_API_KEY 和 / 或 AGENT_BOT_KEY')
 
 const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' }
+const tokenEndpoints = {
+  '/api/agent-token': [AGENT_API_KEY, '/v1/browser-tokens'],
+  '/api/bot-token': [AGENT_BOT_KEY, '/bot/v1/browser-tokens'],
+}
 
 http.createServer(async (req, res) => {
-  if (req.method === 'POST' && req.url === '/api/agent-token') {
+  const endpoint = req.method === 'POST' && tokenEndpoints[req.url]
+  if (endpoint && endpoint[0]) {
     // 在这里校验平台自己的登录态
-    const r = await fetch(`${AGENT_SERVER_URL}/v1/browser-tokens`, {
+    const r = await fetch(`${AGENT_SERVER_URL}${endpoint[1]}`, {
       method: 'POST',
-      headers: { authorization: `Bearer ${AGENT_API_KEY}`, 'content-type': 'application/json' },
+      headers: { authorization: `Bearer ${endpoint[0]}`, 'content-type': 'application/json' },
       body: JSON.stringify({ ttlSeconds: 3600 }),
     })
     res.writeHead(r.status, { 'content-type': 'application/json' })
@@ -27,7 +34,7 @@ http.createServer(async (req, res) => {
   }
   if (req.url === '/api/config') {
     res.writeHead(200, { 'content-type': 'application/json' })
-    return res.end(JSON.stringify({ agentServerUrl: process.env.AGENT_SERVER_PUBLIC_URL ?? AGENT_SERVER_URL }))
+    return res.end(JSON.stringify({ agentServerUrl: process.env.AGENT_SERVER_PUBLIC_URL ?? AGENT_SERVER_URL, chat: !!AGENT_API_KEY, teams: !!AGENT_BOT_KEY }))
   }
   const file = path.join(dist, path.normalize(decodeURIComponent(req.url.split('?')[0])).replace(/^(\.\.[/\\])+/, ''))
   const target = fs.existsSync(file) && fs.statSync(file).isFile() ? file : path.join(dist, 'index.html')

+ 24 - 58
examples/platform-demo/src/App.vue

@@ -1,75 +1,41 @@
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
-import { AgentClient, browserTokenProvider, type ProfileSummary, type SessionDto } from '@agent-server/client'
-import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
+// 示例平台:顶栏切换"对话"(Agent API)和"工作组"(Bot API),后端没有配置对应 Key 的页面不显示
+import { onMounted, ref } from 'vue'
+import ChatPage from './ChatPage.vue'
+import TeamsPage from './TeamsPage.vue'
 
-const client = shallowRef<AgentClient | null>(null)
-const profiles = ref<ProfileSummary[]>([])
-const sessions = ref<SessionDto[]>([])
-const profileId = ref('')
-const current = ref<string | null>(null)
-const chat = shallowRef<ChatSession | null>(null)
+const config = ref<{ agentServerUrl: string; chat: boolean; teams: boolean } | null>(null)
+const page = ref<'chat' | 'teams'>('chat')
 const error = ref<string | null>(null)
 
-async function refreshSessions() {
-  sessions.value = (await client.value!.sessions.list({ limit: 30 })).items
-}
-
-async function open(id: string) {
-  chat.value?.dispose()
-  current.value = id
-  chat.value = useChatSession(client.value!, id)
-}
-
-async function create() {
-  const s = await client.value!.sessions.create({ profileId: profileId.value || undefined })
-  await refreshSessions()
-  await open(s.id)
-}
-
 onMounted(async () => {
   try {
-    const { agentServerUrl } = await (await fetch('/api/config')).json()
-    // 浏览器令牌:由平台后端用 API Key 换取,过期前自动刷新
-    const getToken = browserTokenProvider(async () => {
-      const r = await fetch('/api/agent-token', { method: 'POST' })
-      if (!r.ok) throw new Error(`获取令牌失败(HTTP ${r.status})`)
-      return r.json()
-    })
-    client.value = new AgentClient({ baseUrl: agentServerUrl, getToken })
-    profiles.value = await client.value.profiles()
-    profileId.value = profiles.value.find(p => p.isDefault)?.id ?? profiles.value[0]?.id ?? ''
-    await refreshSessions()
+    config.value = await (await fetch('/api/config')).json()
+    if (!config.value!.chat) page.value = 'teams'
   } catch (err) {
     error.value = (err as Error).message
   }
 })
-onBeforeUnmount(() => chat.value?.dispose())
 </script>
 
 <template>
-  <div class="h-screen flex font-sans text-[14px] text-gray-800 bg-gray-50">
-    <aside class="w-64 flex-none border-r border-gray-200 bg-white flex flex-col">
-      <div class="p-3 border-b border-gray-200 font-600">示例平台</div>
-      <div class="p-3 flex flex-col gap-2">
-        <select v-model="profileId" class="h-8 px-2 rounded border border-gray-300" aria-label="助手">
-          <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
-        </select>
-        <button type="button" class="h-8 rounded bg-blue-600 text-white border-none cursor-pointer" :disabled="!client" @click="create">新建对话</button>
-      </div>
-      <div class="flex-1 overflow-y-auto">
+  <div class="h-screen flex flex-col font-sans text-[14px] text-gray-800 bg-gray-50">
+    <header class="h-11 flex-none flex items-center gap-4 px-4 border-b border-gray-200 bg-white">
+      <span class="font-600">示例平台</span>
+      <nav v-if="config" class="flex gap-1">
         <button
-          v-for="s in sessions" :key="s.id" type="button"
-          class="block w-full text-left px-3 py-2 border-none cursor-pointer truncate"
-          :class="s.id === current ? 'bg-blue-50 text-blue-700' : 'bg-transparent hover:bg-gray-100'"
-          @click="open(s.id)"
-        >{{ s.title || '新会话' }}</button>
-      </div>
-    </aside>
-    <main class="flex-1 min-w-0">
+          v-for="p in ([['chat', '对话', config.chat], ['teams', '工作组', config.teams]] as const).filter(x => x[2])" :key="p[0]" type="button"
+          class="h-7 px-3 rounded border-none cursor-pointer" :class="page === p[0] ? 'bg-blue-50 text-blue-700' : 'bg-transparent hover:bg-gray-100'"
+          @click="page = p[0]"
+        >{{ p[1] }}</button>
+      </nav>
+    </header>
+    <div class="flex-1 min-h-0">
       <div v-if="error" class="p-6 text-red-600">{{ error }}</div>
-      <AgentChat v-else-if="chat && client" :key="current!" :client="client" :session="chat" placeholder="问点什么,或上传文件让助手分析…" />
-      <div v-else class="h-full flex items-center justify-center text-gray-400">选择或新建一个对话</div>
-    </main>
+      <template v-else-if="config">
+        <ChatPage v-if="page === 'chat'" :agent-server-url="config.agentServerUrl" />
+        <TeamsPage v-else :agent-server-url="config.agentServerUrl" />
+      </template>
+    </div>
   </div>
 </template>

+ 75 - 0
examples/platform-demo/src/ChatPage.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+// 单个助手的对话:AgentClient + <AgentChat>,令牌由平台后端用 Agent Key 换取
+import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
+import { AgentClient, browserTokenProvider, type ProfileSummary, type SessionDto } from '@agent-server/client'
+import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
+
+const props = defineProps<{ agentServerUrl: string }>()
+const client = shallowRef<AgentClient | null>(null)
+const profiles = ref<ProfileSummary[]>([])
+const sessions = ref<SessionDto[]>([])
+const profileId = ref('')
+const current = ref<string | null>(null)
+const chat = shallowRef<ChatSession | null>(null)
+const error = ref<string | null>(null)
+
+async function refreshSessions() {
+  sessions.value = (await client.value!.sessions.list({ limit: 30 })).items
+}
+
+async function open(id: string) {
+  chat.value?.dispose()
+  current.value = id
+  chat.value = useChatSession(client.value!, id)
+}
+
+async function create() {
+  const s = await client.value!.sessions.create({ profileId: profileId.value || undefined })
+  await refreshSessions()
+  await open(s.id)
+}
+
+onMounted(async () => {
+  try {
+    // 浏览器令牌:由平台后端用 API Key 换取,过期前自动刷新
+    const getToken = browserTokenProvider(async () => {
+      const r = await fetch('/api/agent-token', { method: 'POST' })
+      if (!r.ok) throw new Error(`获取令牌失败(HTTP ${r.status})`)
+      return r.json()
+    })
+    client.value = new AgentClient({ baseUrl: props.agentServerUrl, getToken })
+    profiles.value = await client.value.profiles()
+    profileId.value = profiles.value.find(p => p.isDefault)?.id ?? profiles.value[0]?.id ?? ''
+    await refreshSessions()
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+})
+onBeforeUnmount(() => chat.value?.dispose())
+</script>
+
+<template>
+  <div class="h-full flex">
+    <aside class="w-64 flex-none border-r border-gray-200 bg-white flex flex-col">
+      <div class="p-3 flex flex-col gap-2">
+        <select v-model="profileId" class="h-8 px-2 rounded border border-gray-300" aria-label="助手">
+          <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
+        </select>
+        <button type="button" class="h-8 rounded bg-blue-600 text-white border-none cursor-pointer" :disabled="!client" @click="create">新建对话</button>
+      </div>
+      <div class="flex-1 overflow-y-auto">
+        <button
+          v-for="s in sessions" :key="s.id" type="button"
+          class="block w-full text-left px-3 py-2 border-none cursor-pointer truncate"
+          :class="s.id === current ? 'bg-blue-50 text-blue-700' : 'bg-transparent hover:bg-gray-100'"
+          @click="open(s.id)"
+        >{{ s.title || '新会话' }}</button>
+      </div>
+    </aside>
+    <main class="flex-1 min-w-0">
+      <div v-if="error" class="p-6 text-red-600">{{ error }}</div>
+      <AgentChat v-else-if="chat && client" :key="current!" :client="client" :session="chat" placeholder="问点什么,或上传文件让助手分析…" />
+      <div v-else class="h-full flex items-center justify-center text-gray-400">选择或新建一个对话</div>
+    </main>
+  </div>
+</template>

+ 107 - 0
examples/platform-demo/src/TeamsPage.vue

@@ -0,0 +1,107 @@
+<script setup lang="ts">
+// 机器人工作组:BotClient + <TeamChat>,令牌由平台后端用 Bot Key 换取(docs/integration.md §8)
+import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
+import { BotClient, browserTokenProvider, type BotCard, type TeamDto } from '@agent-server/client'
+import { BotAvatar, TeamChat, useTeamSession, type TeamSession } from '@agent-server/chat-ui'
+
+const props = defineProps<{ agentServerUrl: string }>()
+// 演示平台没有登录,所有工作组的成员都是这个用户;真实平台用自己的用户 id 和名称
+const user = { id: 'demo-user', name: '演示用户' }
+
+const client = shallowRef<BotClient | null>(null)
+const cards = ref<BotCard[]>([])
+const teams = ref<TeamDto[]>([])
+const current = ref<string | null>(null)
+const team = shallowRef<TeamSession | null>(null)
+const error = ref<string | null>(null)
+
+// 新建工作组:选组长和组员
+const creating = ref(false)
+const form = ref<{ title: string; lead: string; workers: string[] }>({ title: '', lead: '', workers: [] })
+const leads = computed(() => cards.value.filter(b => b.kind === 'lead'))
+const workers = computed(() => cards.value.filter(b => b.kind === 'worker'))
+
+async function refreshTeams() {
+  teams.value = await client.value!.teams.list({ limit: 50 })
+}
+
+function open(id: string) {
+  team.value?.dispose()
+  creating.value = false
+  current.value = id
+  team.value = useTeamSession(client.value!, id)
+}
+
+async function create() {
+  try {
+    const t = await client.value!.teams.create({
+      title: form.value.title.trim() || undefined, human: user,
+      leadBotId: form.value.lead || undefined, workerBotIds: form.value.workers,
+    })
+    form.value = { title: '', lead: '', workers: [] }
+    await refreshTeams()
+    open(t.id)
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+}
+
+onMounted(async () => {
+  try {
+    const getToken = browserTokenProvider(async () => {
+      const r = await fetch('/api/bot-token', { method: 'POST' })
+      if (!r.ok) throw new Error(`获取令牌失败(HTTP ${r.status})`)
+      return r.json()
+    })
+    client.value = new BotClient({ baseUrl: props.agentServerUrl, getToken })
+    cards.value = await client.value.bots()
+    await refreshTeams()
+    creating.value = !teams.value.length
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+})
+onBeforeUnmount(() => team.value?.dispose())
+</script>
+
+<template>
+  <div class="h-full flex">
+    <aside class="w-64 flex-none border-r border-gray-200 bg-white flex flex-col">
+      <div class="p-3">
+        <button type="button" class="w-full h-8 rounded bg-blue-600 text-white border-none cursor-pointer" :disabled="!client" @click="creating = true; current = null">新建工作组</button>
+      </div>
+      <div class="flex-1 overflow-y-auto">
+        <button
+          v-for="t in teams" :key="t.id" type="button"
+          class="flex items-center gap-2 w-full text-left px-3 py-2 border-none cursor-pointer"
+          :class="t.id === current ? 'bg-blue-50 text-blue-700' : 'bg-transparent hover:bg-gray-100'"
+          @click="open(t.id)"
+        >
+          <span class="flex-1 min-w-0 truncate">{{ t.title }}</span>
+          <BotAvatar v-for="b in t.bots" :key="b.id" :value="b.avatar" :size="16" />
+        </button>
+      </div>
+    </aside>
+    <main class="flex-1 min-w-0">
+      <div v-if="error" class="p-6 text-red-600">{{ error }}</div>
+      <form v-else-if="creating" class="max-w-120 mx-auto mt-10 p-5 rounded-lg bg-white border border-gray-200 flex flex-col gap-3" @submit.prevent="create">
+        <div class="font-600 text-[16px]">新建工作组</div>
+        <input v-model="form.title" class="h-8 px-2 rounded border border-gray-300" placeholder="标题(可选)">
+        <label class="text-gray-500 text-[13px]" for="lead">组长:理解需求、拆分任务、分派给组员并汇总</label>
+        <select id="lead" v-model="form.lead" class="h-8 px-2 rounded border border-gray-300">
+          <option value="">不要组长(直接 @ 组员)</option>
+          <option v-for="b in leads" :key="b.id" :value="b.id">{{ b.name }}</option>
+        </select>
+        <div class="text-gray-500 text-[13px]">组员(有组长时也可以不选,由组长申请加入)</div>
+        <label v-for="b in workers" :key="b.id" class="flex items-start gap-2 cursor-pointer">
+          <input v-model="form.workers" type="checkbox" :value="b.id" class="mt-1">
+          <BotAvatar :value="b.avatar" :size="20" />
+          <span class="flex-1 min-w-0"><span>{{ b.name }}</span><span class="block text-gray-500 text-[12px] truncate">{{ b.description }}</span></span>
+        </label>
+        <button type="submit" class="h-8 rounded bg-blue-600 text-white border-none cursor-pointer" :disabled="!form.lead && !form.workers.length">创建</button>
+      </form>
+      <TeamChat v-else-if="team && client" :key="current!" :client="client" :session="team" placeholder="@ 某个机器人,或直接交给组长…" />
+      <div v-else class="h-full flex items-center justify-center text-gray-400">选择或新建一个工作组</div>
+    </main>
+  </div>
+</template>

+ 1 - 1
packages/chat-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/chat-ui",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "private": true,
   "type": "module",
   "exports": {

+ 1 - 1
packages/client/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/client",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "private": true,
   "type": "module",
   "exports": {

+ 1 - 1
packages/shared/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/shared",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "private": true,
   "type": "module",
   "exports": {

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

@@ -298,3 +298,44 @@ 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', 'step.updated'])
+
+// ---- 回归用例(仅后台,§12.2) ----
+
+/** 回归用例:成员组成 + 第一条命令。重放时按它新建一个后台工作组并发出命令,结果由人工比对 */
+export interface TeamCaseDto {
+  id: string
+  name: string
+  leadBotId: string | null
+  workerBotIds: string[]
+  /** 重放时设为替身的组员 */
+  stubBotIds: string[]
+  limits: Partial<TeamLimits> | null
+  humanName: string
+  text: string
+  mentions: string[]
+  files: { name: string; size: number }[]
+  createdAt: string
+  updatedAt: string
+  runCount: number
+  lastRun: TeamCaseRunDto | null
+}
+
+/** 一次重放:对应一个工作组 */
+export interface TeamCaseRunDto {
+  teamId: string
+  createdAt: string
+  /** running:还有机器人在工作或在等审批 / 替身结果;done:都空闲了 */
+  state: 'running' | 'done'
+  /** 最后一项任务(组长开了任务时) */
+  job: Pick<JobDto, 'id' | 'no' | 'title' | 'status' | 'summary' | 'files' | 'leadRuns' | 'taskCount' | 'endedAt'> | null
+  /** 没有任务时:机器人的最后一条回复 */
+  reply: string | null
+  runs: number
+  costUsd: number
+}
+
+export interface CreateTeamCaseBody {
+  name: string
+  /** 从这个工作组保存:取它的成员组成、替身设置、限额和成员发出的第一条消息 */
+  teamId: string
+}