|
|
@@ -0,0 +1,227 @@
|
|
|
+<script setup lang="ts">
|
|
|
+// 工作组详情:状态、成员、任务、文件、用量、限额、删除
|
|
|
+import { computed, onMounted, ref } from 'vue'
|
|
|
+import { useRouter } from 'vue-router'
|
|
|
+import type { BotCard, JobDto, TaskStatus, TeamDetailDto, TeamJobsDto, TeamLimits, TeamUsageDto } from '@agent-server/client'
|
|
|
+import { BotAvatar } from '@agent-server/chat-ui'
|
|
|
+import FileList from '../components/FileList.vue'
|
|
|
+import TopBar from '../components/TopBar.vue'
|
|
|
+import { botClient } from '../lib/config'
|
|
|
+import { runTeamAction, TEAM_STATUS, teamStatusActions } from '../lib/sessions'
|
|
|
+import { attempt, formatCost, formatTokens, openSheet, relativeTime } from '../lib/ui'
|
|
|
+
|
|
|
+const props = defineProps<{ id: string }>()
|
|
|
+const router = useRouter()
|
|
|
+const client = botClient.value!
|
|
|
+const team = ref<TeamDetailDto | null>(null)
|
|
|
+const jobs = ref<TeamJobsDto | null>(null)
|
|
|
+const usage = ref<TeamUsageDto | null>(null)
|
|
|
+const tab = ref<'members' | 'jobs' | 'files' | 'usage' | 'limits'>('members')
|
|
|
+
|
|
|
+async function refresh() {
|
|
|
+ const [t, j, u] = await Promise.all([
|
|
|
+ attempt(() => client.teams.get(props.id)),
|
|
|
+ attempt(() => client.teams.jobs(props.id)),
|
|
|
+ attempt(() => client.teams.usage(props.id)),
|
|
|
+ ])
|
|
|
+ if (t) {
|
|
|
+ team.value = t
|
|
|
+ limits.value = { ...t.limits }
|
|
|
+ }
|
|
|
+ jobs.value = j ?? null
|
|
|
+ usage.value = u ?? null
|
|
|
+}
|
|
|
+onMounted(refresh)
|
|
|
+
|
|
|
+const BOT_STATUS = { idle: '空闲', queued: '排队中', working: '工作中', waiting_input: '等待确认' } as const
|
|
|
+const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '进行中', stub: '等待替身结果', background: '后台执行中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
|
|
+const JOB_STATUS: Record<JobDto['status'], string> = { running: '进行中', blocked: '超出限额,已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
|
|
+const botName = (id: string) => team.value?.bots.find(b => b.id === id)?.name ?? id
|
|
|
+const tasksOf = (jobId: string) => jobs.value?.tasks.filter(t => t.jobId === jobId) ?? []
|
|
|
+const sortedJobs = computed(() => [...(jobs.value?.jobs ?? [])].sort((a, b) => b.no - a.no))
|
|
|
+
|
|
|
+async function rename() {
|
|
|
+ const title = window.prompt('工作组名称', team.value?.title)?.trim()
|
|
|
+ if (title && title !== team.value?.title) {
|
|
|
+ const t = await attempt(() => client.teams.update(props.id, { title }))
|
|
|
+ if (t) team.value = t
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function changeStatus() {
|
|
|
+ if (!team.value) return
|
|
|
+ const action = await openSheet([...teamStatusActions(team.value)], `当前状态:${TEAM_STATUS[team.value.status]}`)
|
|
|
+ if (!action) return
|
|
|
+ if (action === 'close' && !window.confirm('关闭工作组?进行中的任务会被取消。')) return
|
|
|
+ const t = await runTeamAction(client, props.id, action)
|
|
|
+ if (t) team.value = t
|
|
|
+}
|
|
|
+
|
|
|
+async function remove() {
|
|
|
+ if (!window.confirm(`删除工作组「${team.value?.title}」?消息、任务和文件都会删除,不能恢复。`)) return
|
|
|
+ if ((await attempt(() => client.teams.remove(props.id), '已删除')) !== undefined) await router.replace('/teams')
|
|
|
+}
|
|
|
+
|
|
|
+async function botMenu(botId: string) {
|
|
|
+ const bot = team.value?.bots.find(b => b.id === botId)
|
|
|
+ if (!bot) return
|
|
|
+ const action = await openSheet([
|
|
|
+ ...(bot.status !== 'idle' ? [{ value: 'interrupt', label: '停止本轮工作', icon: 'debug-stop' }] : []),
|
|
|
+ { value: 'reset', label: '开始新的会话', icon: 'refresh', hint: '清空它的上下文,下一轮重新开始' },
|
|
|
+ { value: 'remove', label: '移出工作组', icon: 'person-remove', danger: true },
|
|
|
+ ], bot.name)
|
|
|
+ if (action === 'interrupt') await attempt(() => client.teams.interruptBot(props.id, botId), '已停止')
|
|
|
+ else if (action === 'reset') await attempt(() => client.teams.resetSession(props.id, botId), '下一轮将开始新的会话')
|
|
|
+ else if (action === 'remove' && window.confirm(`把「${bot.name}」移出工作组?`)) {
|
|
|
+ const t = await attempt(() => client.teams.removeBot(props.id, botId))
|
|
|
+ if (t) team.value = t
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function addBot() {
|
|
|
+ const cards: BotCard[] = (await attempt(() => client.bots())) ?? []
|
|
|
+ const joined = new Set(team.value?.bots.map(b => b.id))
|
|
|
+ const available = cards.filter(b => !joined.has(b.id))
|
|
|
+ if (!available.length) return void window.alert('没有可以加入的机器人了')
|
|
|
+ const botId = await openSheet(available.map(b => ({ value: b.id, label: b.name + (b.kind === 'lead' ? '(组长)' : ''), hint: b.description, icon: 'person-add' })), '添加成员')
|
|
|
+ if (!botId) return
|
|
|
+ const t = await attempt(() => client.teams.addBot(props.id, botId), '已加入')
|
|
|
+ if (t) team.value = t
|
|
|
+}
|
|
|
+
|
|
|
+async function jobAction(job: JobDto, action: 'cancel' | 'continue') {
|
|
|
+ if (action === 'cancel' && !window.confirm(`取消任务 #${job.no}?还没有结束的子任务一并取消。`)) return
|
|
|
+ const ok = await attempt(() => (action === 'cancel' ? client.teams.cancelJob(props.id, job.id) : client.teams.continueJob(props.id, job.id)), action === 'cancel' ? '已取消' : '已继续')
|
|
|
+ if (ok) await refresh()
|
|
|
+}
|
|
|
+
|
|
|
+// 限额:不能超过系统设置(服务端校验)
|
|
|
+const limits = ref<TeamLimits | null>(null)
|
|
|
+const LIMIT_FIELDS: { key: keyof TeamLimits; label: string; unit: string; nullable?: boolean }[] = [
|
|
|
+ { key: 'maxTasksPerJob', label: '每个任务的子任务数', unit: '个' },
|
|
|
+ { key: 'maxLeadRunsPerJob', label: '每个任务中组长的执行轮数', unit: '轮' },
|
|
|
+ { key: 'maxCostUsdPerJob', label: '每个任务的成本上限', unit: '美元', nullable: true },
|
|
|
+ { key: 'maxMinutesPerJob', label: '每个任务的时长上限', unit: '分钟' },
|
|
|
+ { key: 'maxConcurrentRuns', label: '同时执行的机器人数', unit: '个' },
|
|
|
+ { key: 'sessionMaxContextTokens', label: '上下文超过后开始新会话', unit: 'tokens', nullable: true },
|
|
|
+ { key: 'sessionIdleHours', label: '闲置超过后开始新会话', unit: '小时', nullable: true },
|
|
|
+]
|
|
|
+function setLimit(key: keyof TeamLimits, raw: string) {
|
|
|
+ if (!limits.value) return
|
|
|
+ const v = raw.trim() === '' ? null : Number(raw)
|
|
|
+ ;(limits.value as Record<keyof TeamLimits, number | null>)[key] = v === null || Number.isNaN(v) ? null : v
|
|
|
+}
|
|
|
+async function saveLimits() {
|
|
|
+ if (!limits.value || !team.value) return
|
|
|
+ // 只提交改动过的项
|
|
|
+ const changed = Object.fromEntries(Object.entries(limits.value).filter(([k, v]) => team.value!.limits[k as keyof TeamLimits] !== v))
|
|
|
+ if (!Object.keys(changed).length) return
|
|
|
+ const t = await attempt(() => client.teams.update(props.id, { limits: changed }), '限额已保存')
|
|
|
+ if (t) {
|
|
|
+ team.value = t
|
|
|
+ limits.value = { ...t.limits }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const TABS = [['members', '成员'], ['jobs', '任务'], ['files', '文件'], ['usage', '用量'], ['limits', '限额']] as const
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <TopBar back :title="team?.title ?? '工作组'" :subtitle="team ? `${TEAM_STATUS[team.status]} · 创建于 ${relativeTime(team.createdAt)}` : ''">
|
|
|
+ <button type="button" class="m-icon" aria-label="刷新" @click="refresh"><span class="codicon codicon-refresh text-[18px]" /></button>
|
|
|
+ </TopBar>
|
|
|
+ <div v-if="team" class="m-page flex-1 min-h-0 overflow-y-auto pb-[calc(24px+var(--safe-bottom))]">
|
|
|
+ <div class="m-group m-list mt-3">
|
|
|
+ <button type="button" class="m-row" @click="rename">
|
|
|
+ <span class="codicon codicon-edit text-[18px]" /><span class="flex-1 min-w-0 truncate">{{ team.title }}</span><span class="text-[12px] text-fg-muted">重命名</span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="m-row" @click="changeStatus">
|
|
|
+ <span class="codicon codicon-pulse text-[18px]" /><span class="flex-1">状态</span><span class="text-fg-muted">{{ TEAM_STATUS[team.status] }}</span><span class="codicon codicon-chevron-right text-fg-muted" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex gap-1 px-3 pt-4 overflow-x-auto">
|
|
|
+ <button
|
|
|
+ v-for="[v, label] in TABS" :key="v" type="button"
|
|
|
+ class="flex-none h-8 px-3 rounded-full border-none cursor-pointer text-[13px]" :class="tab === v ? 'bg-active text-fg' : 'bg-transparent text-fg-muted'"
|
|
|
+ @click="tab = v"
|
|
|
+ >{{ label }}</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 成员 -->
|
|
|
+ <div v-if="tab === 'members'" class="m-group m-list mt-2">
|
|
|
+ <button v-for="b in team.bots" :key="b.id" type="button" class="m-row" @click="botMenu(b.id)">
|
|
|
+ <BotAvatar :value="b.avatar" :size="28" />
|
|
|
+ <span class="flex-1 min-w-0">
|
|
|
+ <span class="block truncate">{{ b.name }}<span v-if="b.role === 'lead'" class="text-[12px] text-accent ml-1">组长</span></span>
|
|
|
+ <span class="block text-[12px] text-fg-muted truncate">{{ BOT_STATUS[b.status] }} · {{ b.description }}</span>
|
|
|
+ </span>
|
|
|
+ <span class="codicon codicon-ellipsis text-fg-muted" />
|
|
|
+ </button>
|
|
|
+ <button v-if="team.status !== 'closed'" type="button" class="m-row text-link" @click="addBot">
|
|
|
+ <span class="codicon codicon-person-add text-[18px]" />添加成员
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 任务 -->
|
|
|
+ <div v-else-if="tab === 'jobs'" class="flex flex-col gap-2 mt-2">
|
|
|
+ <div v-if="!sortedJobs.length" class="m-group px-4 py-3 text-fg-muted">还没有任务。有组长时,交给组长的需求会成为任务。</div>
|
|
|
+ <div v-for="job in sortedJobs" :key="job.id" class="m-group p-3">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="font-600 flex-1 min-w-0 truncate">#{{ job.no }} {{ job.title }}</span>
|
|
|
+ <span class="text-[12px] flex-none" :class="job.status === 'blocked' ? 'text-warning' : job.status === 'failed' ? 'text-error' : 'text-fg-muted'">{{ JOB_STATUS[job.status] }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="text-[12px] text-fg-muted mt-0.5">{{ job.taskCount }} 个子任务 · 组长 {{ job.leadRuns }} 轮 · {{ formatCost(job.costUsd) }} · {{ relativeTime(job.createdAt) }}</div>
|
|
|
+ <div v-if="job.summary" class="text-[13px] mt-1.5 whitespace-pre-wrap line-clamp-4">{{ job.summary }}</div>
|
|
|
+ <div v-for="t in tasksOf(job.id)" :key="t.id" class="text-[12px] mt-1 flex gap-1.5">
|
|
|
+ <span class="text-fg-muted flex-none">#{{ t.no }}</span>
|
|
|
+ <span class="flex-1 min-w-0 truncate">{{ botName(t.botId) }}:{{ t.title }}</span>
|
|
|
+ <span class="text-fg-muted flex-none">{{ TASK_STATUS[t.status] }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="job.status === 'running' || job.status === 'blocked'" class="flex gap-2 mt-2">
|
|
|
+ <button v-if="job.status === 'blocked'" type="button" class="m-btn-primary flex-1 h-9" @click="jobAction(job, 'continue')">继续(放宽限额)</button>
|
|
|
+ <button type="button" class="m-btn-danger flex-1 h-9" @click="jobAction(job, 'cancel')">取消任务</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 文件 -->
|
|
|
+ <div v-else-if="tab === 'files'" class="m-group mt-2">
|
|
|
+ <FileList :client="client" :load="() => client.teams.files(id)" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 用量 -->
|
|
|
+ <div v-else-if="tab === 'usage'" class="m-group m-list mt-2">
|
|
|
+ <div v-if="!usage" class="px-4 py-3 text-fg-muted">加载中…</div>
|
|
|
+ <template v-else>
|
|
|
+ <div class="m-row cursor-default">
|
|
|
+ <span class="flex-1 font-600">合计</span>
|
|
|
+ <span class="text-[13px] text-fg-muted">{{ usage.total.runs }} 轮 · {{ formatTokens(usage.total.inputTokens + usage.total.outputTokens) }} tokens · {{ formatCost(usage.total.costUsd) }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-for="r in usage.bots" :key="r.botId" class="m-row cursor-default">
|
|
|
+ <span class="flex-1 min-w-0 truncate">{{ botName(r.botId) }}</span>
|
|
|
+ <span class="text-[13px] text-fg-muted">{{ r.runs }} 轮 · {{ formatTokens(r.inputTokens + r.outputTokens) }} · {{ formatCost(r.costUsd) }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 限额 -->
|
|
|
+ <div v-else-if="tab === 'limits' && limits" class="m-group mt-2 p-3 flex flex-col gap-3">
|
|
|
+ <label v-for="f in LIMIT_FIELDS" :key="f.key" class="flex items-center gap-2">
|
|
|
+ <span class="flex-1 text-[14px]">{{ f.label }}</span>
|
|
|
+ <input
|
|
|
+ class="m-field w-24 text-right" type="number" inputmode="decimal" :value="limits[f.key] ?? ''" :placeholder="f.nullable ? '不限' : ''"
|
|
|
+ @change="setLimit(f.key, ($event.target as HTMLInputElement).value)"
|
|
|
+ >
|
|
|
+ <span class="w-10 text-[12px] text-fg-muted">{{ f.unit }}</span>
|
|
|
+ </label>
|
|
|
+ <div class="text-[12px] text-fg-muted">超出任务级限额时任务暂停,可以在对话或「任务」中选择继续或取消。限额不能超过系统设置。</div>
|
|
|
+ <button type="button" class="m-btn-primary" @click="saveLimits">保存限额</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="px-3 pt-6">
|
|
|
+ <button type="button" class="m-btn-danger w-full" @click="remove"><span class="codicon codicon-trash" />删除工作组</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="m-page flex-1 p-6 text-fg-muted">加载中…</div>
|
|
|
+</template>
|