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