253 lines
9.0 KiB
TypeScript
253 lines
9.0 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref, computed, onScopeDispose } from 'vue'
|
|
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
|
import * as agentService from '@/services/agentService'
|
|
import type { SseClient } from '@/services/sseClient'
|
|
import { t } from '@/i18n'
|
|
|
|
export const useAgentStore = defineStore('agent', () => {
|
|
const runs = ref<AgentRun[]>([])
|
|
const activeRunId = ref<string | null>(null)
|
|
const events = ref<AgentEvent[]>([])
|
|
const tools = ref<ToolDefinition[]>([])
|
|
const isCreating = ref(false)
|
|
const isRunning = ref(false)
|
|
const permissionRequest = ref<PermissionRequest | null>(null)
|
|
const toolCalls = ref<ToolCall[]>([])
|
|
const error = ref<string | null>(null)
|
|
let eventStream: SseClient | null = null
|
|
let selectionVersion = 0
|
|
let streamVersion = 0
|
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
let retryCount = 0
|
|
const seenSequences = new Set<number>()
|
|
let lastSequence = -1
|
|
const connectionState = ref<'idle' | 'connected' | 'reconnecting' | 'disconnected'>('idle')
|
|
const terminal = (status?: string) => ['completed', 'failed', 'cancelled'].includes(status || '')
|
|
|
|
function stopStream() {
|
|
streamVersion++
|
|
if (retryTimer) clearTimeout(retryTimer)
|
|
retryTimer = null
|
|
eventStream?.cancel()
|
|
eventStream = null
|
|
}
|
|
function resetEvents() {
|
|
events.value = []
|
|
toolCalls.value = []
|
|
seenSequences.clear()
|
|
lastSequence = -1
|
|
retryCount = 0
|
|
}
|
|
onScopeDispose(stopStream)
|
|
|
|
const activeRun = computed(() =>
|
|
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
|
)
|
|
|
|
const sortedRuns = computed(() =>
|
|
[...runs.value].sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
|
|
)
|
|
|
|
const currentStep = computed(() => {
|
|
const tc = events.value.filter((e) => e.event === 'ToolCall').length
|
|
return tc
|
|
})
|
|
|
|
async function loadTools() {
|
|
tools.value = await agentService.listTools()
|
|
}
|
|
|
|
let listVersion = 0
|
|
async function loadRuns() {
|
|
const version = ++listVersion
|
|
const items: AgentRun[] = []
|
|
let offset = 0
|
|
do {
|
|
const resp = await agentService.listAgentRuns({ limit: 100, offset })
|
|
if (version !== listVersion) return
|
|
items.push(...resp.items)
|
|
offset += resp.items.length
|
|
if (!resp.items.length || offset >= resp.total) break
|
|
} while (true)
|
|
const active = runs.value.find(run => run.run_id === activeRunId.value)
|
|
const merged = new Map(items.map(item => [item.run_id, item]))
|
|
if (active && !merged.has(active.run_id)) merged.set(active.run_id, active)
|
|
runs.value = [...merged.values()]
|
|
}
|
|
|
|
async function loadRun(runId: string) {
|
|
const version = ++selectionVersion
|
|
stopStream()
|
|
activeRunId.value = runId
|
|
resetEvents()
|
|
permissionRequest.value = null
|
|
isRunning.value = false
|
|
const run = await agentService.getAgentRun(runId)
|
|
if (version !== selectionVersion) return
|
|
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
|
|
if (existingIndex >= 0) runs.value[existingIndex] = run
|
|
else runs.value.unshift(run)
|
|
events.value = []
|
|
toolCalls.value = []
|
|
permissionRequest.value = null
|
|
subscribe(runId)
|
|
}
|
|
|
|
function processEvent(event: AgentEvent) {
|
|
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
|
|
if (seenSequences.has(event.sequence)) return
|
|
seenSequences.add(event.sequence)
|
|
if (event.sequence > lastSequence) events.value.push(event)
|
|
else {
|
|
const index = events.value.findIndex(item => item.sequence > event.sequence)
|
|
events.value.splice(index < 0 ? events.value.length : index, 0, event)
|
|
}
|
|
lastSequence = Math.max(lastSequence, event.sequence)
|
|
const data = event.data
|
|
const run = runs.value.find((item) => item.run_id === event.run_id)
|
|
if (event.event === 'RunStarted' && run) run.status = 'running'
|
|
if (event.event === 'ToolCall') {
|
|
toolCalls.value.push({
|
|
tool_call_id: String(data.tool_call_id ?? ''),
|
|
name: String(data.name ?? 'unknown'),
|
|
parameters: (data.arguments ?? {}) as Record<string, unknown>,
|
|
status: 'running',
|
|
started_at: event.timestamp,
|
|
})
|
|
} else if (event.event === 'ToolResult') {
|
|
const toolCall = toolCalls.value.find((item) => item.tool_call_id === data.tool_call_id)
|
|
if (toolCall) {
|
|
toolCall.status = data.success ? 'completed' : 'error'
|
|
toolCall.result = data.output == null ? undefined : JSON.stringify(data.output)
|
|
toolCall.error_code = data.error_code == null ? undefined : String(data.error_code)
|
|
toolCall.error_message = data.error_message == null ? undefined : String(data.error_message)
|
|
toolCall.completed_at = event.timestamp
|
|
}
|
|
permissionRequest.value = null
|
|
if (run?.status === 'waiting_permission') run.status = 'running'
|
|
} else if (event.event === 'PermissionRequired') {
|
|
const call = (data.tool_call ?? {}) as Record<string, unknown>
|
|
permissionRequest.value = {
|
|
request_id: String(data.request_id ?? ''),
|
|
run_id: event.run_id,
|
|
tool_name: String(call.name ?? 'unknown'),
|
|
permission: String(data.permission ?? ''),
|
|
parameters: (call.arguments ?? {}) as Record<string, unknown>,
|
|
impact: t('该工具需要获得权限后才能继续执行。', 'This tool requires permission before it can continue.'),
|
|
}
|
|
if (run) run.status = 'waiting_permission'
|
|
} else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) {
|
|
isRunning.value = false
|
|
permissionRequest.value = null
|
|
if (run) {
|
|
run.status = event.event === 'RunCompleted' ? 'completed' : event.event === 'RunFailed' ? 'failed' : 'cancelled'
|
|
run.completed_at = event.timestamp
|
|
}
|
|
}
|
|
}
|
|
|
|
function subscribe(runId: string) {
|
|
stopStream()
|
|
const version = streamVersion
|
|
const current = () => activeRunId.value === runId && version === streamVersion
|
|
isRunning.value = !terminal(activeRun.value?.status)
|
|
const interrupted = (cause?: Error) => {
|
|
if (!current() || retryTimer) return
|
|
eventStream?.cancel()
|
|
eventStream = null
|
|
if (terminal(activeRun.value?.status)) {
|
|
isRunning.value = false
|
|
connectionState.value = 'idle'
|
|
return
|
|
}
|
|
error.value = cause?.message || t('事件连接中断', 'Event connection interrupted')
|
|
connectionState.value = retryCount >= 5 ? 'disconnected' : 'reconnecting'
|
|
if (retryCount >= 5) return
|
|
const delay = Math.min(1000 * 2 ** retryCount++, 16000)
|
|
retryTimer = setTimeout(async () => {
|
|
retryTimer = null
|
|
try {
|
|
const run = await agentService.getAgentRun(runId)
|
|
if (!current()) return
|
|
const index = runs.value.findIndex(item => item.run_id === runId)
|
|
if (index >= 0) runs.value[index] = run
|
|
// 即使已结束仍续读一次缺失的尾部事件,保留完整 Trace。
|
|
subscribe(runId)
|
|
} catch (cause) {
|
|
if (current()) interrupted(cause instanceof Error ? cause : new Error(String(cause)))
|
|
}
|
|
}, delay)
|
|
}
|
|
eventStream = agentService.streamAgentEvents(runId, {
|
|
onOpen() { if (current()) { connectionState.value = 'connected'; error.value = null } },
|
|
onEvent(event) { if (current()) processEvent(event) },
|
|
onError: interrupted,
|
|
onDone() { interrupted() },
|
|
}, lastSequence)
|
|
}
|
|
|
|
function reconnect() {
|
|
if (!activeRunId.value) return
|
|
retryCount = 0
|
|
subscribe(activeRunId.value)
|
|
}
|
|
|
|
async function createRun(request: agentService.CreateAgentRunRequest) {
|
|
isCreating.value = true
|
|
try {
|
|
const run = await agentService.createAgentRun(request)
|
|
selectionVersion++
|
|
runs.value.unshift(run)
|
|
activeRunId.value = run.run_id
|
|
resetEvents()
|
|
subscribe(run.run_id)
|
|
return run
|
|
} finally {
|
|
isCreating.value = false
|
|
}
|
|
}
|
|
|
|
async function cancelRun(runId: string) {
|
|
await agentService.cancelAgentRun(runId)
|
|
const run = runs.value.find((r) => r.run_id === runId)
|
|
if (run) run.status = 'cancelled'
|
|
if (activeRunId.value === runId) {
|
|
isRunning.value = false
|
|
permissionRequest.value = null
|
|
stopStream()
|
|
connectionState.value = 'idle'
|
|
}
|
|
}
|
|
|
|
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') {
|
|
if (!activeRunId.value || !permissionRequest.value) return
|
|
const apiDecision = decision === 'deny' ? 'deny' : scope === 'session' ? 'allow_session' : 'allow_once'
|
|
await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, apiDecision)
|
|
permissionRequest.value = null
|
|
}
|
|
|
|
return {
|
|
runs,
|
|
activeRunId,
|
|
activeRun,
|
|
sortedRuns,
|
|
events,
|
|
tools,
|
|
isCreating,
|
|
isRunning,
|
|
permissionRequest,
|
|
toolCalls,
|
|
error,
|
|
connectionState,
|
|
reconnect,
|
|
currentStep,
|
|
loadTools,
|
|
loadRuns,
|
|
loadRun,
|
|
createRun,
|
|
cancelRun,
|
|
respondPermission,
|
|
}
|
|
})
|