| | |
| | | <script lang="ts" setup> |
| | | import type { FileType } from 'ant-design-vue/es/upload/interface'; |
| | | import type { MenuRecordRaw } from '@vben/types'; |
| | | |
| | | import type { AgentConversationApi } from '#/api/agent/conversation'; |
| | | import type { AgentTaskApi } from '#/api/agent/conversation'; |
| | | |
| | | import { computed, onMounted, ref, watch } from 'vue'; |
| | | import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { useAccessStore } from '@vben/stores'; |
| | | import { downloadFileFromBlobPart } from '@vben/utils'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | import { |
| | | agentImportErrorMessage, |
| | | createDocumentTask, |
| | | createAgentConversation, |
| | | deleteAgentConversation, |
| | | downloadAgentConversationAttachment, |
| | | downloadAgentReport, |
| | | getAgentCapabilities, |
| | | getAgentModels, |
| | | getAuthorizedAgentCapabilities, |
| | | getAgentConversation, |
| | | listAgentConversations, |
| | | sendAgentAction, |
| | | sendAgentMessage, |
| | | streamAgentConversationEvents, |
| | | uploadAgentAttachment, |
| | | } from '#/api/agent/conversation'; |
| | | |
| | | import TaskCenterDrawer from './components/TaskCenterDrawer.vue'; |
| | | |
| | | defineOptions({ name: 'AgentDataEntry' }); |
| | | |
| | | const accessStore = useAccessStore(); |
| | | |
| | | const MAX_BYTES = 8 * 1024 * 1024; |
| | | const ACCEPT = '.csv,.xlsx,.docx,.pdf'; |
| | | |
| | | const modules = ref<AgentConversationApi.ModuleSpec[]>([]); |
| | | const authorizationScope = ref(''); |
| | | const models = ref<AgentConversationApi.BusinessModelInfo[]>([]); |
| | | const selectedModelId = ref<number>(); |
| | | const conversations = ref<AgentConversationApi.ConversationSummary[]>([]); |
| | | const active = ref<AgentConversationApi.Conversation>(); |
| | | const loading = ref(false); |
| | | const busy = ref(false); |
| | | const creating = ref(false); |
| | | const newOpen = ref(false); |
| | | const newModule = ref(''); |
| | | const newScenarios = ref<string[]>([]); |
| | | const newText = ref(''); |
| | | const newFile = ref<File>(); |
| | | const newFileName = ref(''); |
| | | const newTimeRange = ref<string[]>([]); |
| | | const newRecordLimit = ref<number>(); |
| | | const newIndustry = ref(''); |
| | | const newSkipRestDays = ref(false); |
| | | const newRestDayDescription = ref(''); |
| | | const newRegion = ref(''); |
| | | type ConstraintKey = |
| | | | 'industry' |
| | | | 'instruction' |
| | | | 'record_limit' |
| | | | 'region' |
| | | | 'rest_day_description' |
| | | | 'scenario_paths' |
| | | | 'skip_rest_days' |
| | | | 'time_range'; |
| | | const newPriorities = ref<Record<ConstraintKey, boolean>>({ |
| | | industry: false, |
| | | instruction: false, |
| | | record_limit: false, |
| | | region: false, |
| | | rest_day_description: false, |
| | | scenario_paths: false, |
| | | skip_rest_days: false, |
| | | time_range: false, |
| | | }); |
| | | const newFiles = ref<File[]>([]); |
| | | const newAttachmentIds = ref<number[]>([]); |
| | | const durableTasksEnabled = ref(false); |
| | | const taskCenter = ref<{ open: (taskId?: string) => Promise<void> }>(); |
| | | const interruptFile = ref<File>(); |
| | | const mappingDraft = ref<Record<string, string>>({}); |
| | | const reviseText = ref(''); |
| | | const syncMes = ref(true); |
| | | const eventState = ref<'connected' | 'connecting' | 'idle' | 'retrying'>('idle'); |
| | | interface ExecutionProgress { |
| | | candidateCount: number; |
| | | issueCount: number; |
| | | label: string; |
| | | modelId?: number; |
| | | percent: number; |
| | | stage: string; |
| | | } |
| | | const executionProgress = ref<ExecutionProgress>(); |
| | | let eventAbort: AbortController | undefined; |
| | | let eventGeneration = 0; |
| | | let eventRefreshTimer: ReturnType<typeof setTimeout> | undefined; |
| | | |
| | | const attachmentPreview = ref<{ |
| | | filename: string; |
| | | kind: 'image' | 'pdf'; |
| | | open: boolean; |
| | | url: string; |
| | | }>({ filename: '', kind: 'image', open: false, url: '' }); |
| | | |
| | | const modelOptions = computed(() => |
| | | models.value.map((model) => ({ |
| | | label: `${model.name || model.model} · ${model.platform}`, |
| | | value: model.id, |
| | | })), |
| | | ); |
| | | const interrupt = computed(() => active.value?.interrupts?.at(-1)); |
| | | const card = computed(() => interrupt.value?.card); |
| | | const actions = computed(() => card.value?.actions ?? []); |
| | | const actions = computed(() => { |
| | | const list = [...(card.value?.actions ?? [])]; |
| | | // å¡çå¯è½å¨æäº¤å¼å
³å
³éæ¶çæï¼ä»¥æå¡ç«¯å½åå¼å
³ä¸ºåè¡¥å确认å¨ä½ |
| | | if ( |
| | | card.value?.kind === 'preview' && |
| | | active.value?.commit_enabled && |
| | | !list.includes('confirm') |
| | | ) { |
| | | list.push('confirm'); |
| | | } |
| | | return list; |
| | | }); |
| | | const isGatewayModule = computed(() => active.value?.module_id === 'mdm.item'); |
| | | |
| | | const moduleOptions = computed(() => [ |
| | | { label: 'èªå¨è¯å«', value: '' }, |
| | | ...modules.value.map((item) => ({ label: item.title, value: item.module_id })), |
| | | const previewValueLabels: Record<string, Record<string, string>> = { |
| | | status: { '0': 'å¯ç¨', '1': 'åç¨' }, |
| | | types: { |
| | | '1': 'IQCï¼æ¥ææ£éªï¼', |
| | | '2': 'IPQCï¼è¿ç¨æ£éªï¼', |
| | | '3': 'OQCï¼åºè´§æ£éªï¼', |
| | | '4': 'RQCï¼éææ£éªï¼', |
| | | }, |
| | | }; |
| | | const previewColumns = computed(() => [ |
| | | { title: 'è¡å·', dataIndex: 'row_index', key: 'row_index', width: 60 }, |
| | | ...(active.value?.row_fields ?? []).map((field) => ({ |
| | | title: field.label, |
| | | dataIndex: field.name, |
| | | key: field.name, |
| | | width: 140, |
| | | customRender: ({ text }: { text: unknown }) => |
| | | previewValueLabels[field.name]?.[String(text ?? '')] ?? text, |
| | | })), |
| | | ]); |
| | | const previewRows = computed(() => |
| | | (active.value?.rows ?? []).map((row, index) => ({ |
| | | candidate_id: row.candidate_id, |
| | | row_index: index + 1, |
| | | ...row.values, |
| | | })), |
| | | ); |
| | | const eventStateText = computed(() => { |
| | | if (active.value?.finished) return '已忥'; |
| | | return { |
| | | connected: '宿¶æ´æ°', |
| | | connecting: 'æ£å¨è¿æ¥', |
| | | idle: 'æªè¿æ¥', |
| | | retrying: 'æçº¿éè¿', |
| | | }[eventState.value]; |
| | | }); |
| | | const eventStateColor = computed(() => { |
| | | if (active.value?.finished || eventState.value === 'connected') return 'green'; |
| | | if (eventState.value === 'retrying') return 'orange'; |
| | | return 'blue'; |
| | | }); |
| | | |
| | | interface ScenarioOption { |
| | | description: string; |
| | | label: string; |
| | | name: string; |
| | | value: string; |
| | | } |
| | | |
| | | function flattenMenuScenarios( |
| | | menus: MenuRecordRaw[], |
| | | parentNames: string[] = [], |
| | | ): ScenarioOption[] { |
| | | return menus.flatMap((menu) => { |
| | | const names = [...parentNames, menu.name].filter(Boolean); |
| | | const current = menu.show === false |
| | | ? [] |
| | | : [ |
| | | { |
| | | description: menu.path, |
| | | label: names.join(' / '), |
| | | name: menu.name, |
| | | value: menu.path, |
| | | }, |
| | | ]; |
| | | return [ |
| | | ...current, |
| | | ...flattenMenuScenarios(menu.children ?? [], names), |
| | | ]; |
| | | }); |
| | | } |
| | | |
| | | const scenarioOptions = computed(() => { |
| | | const seen = new Set<string>(); |
| | | return flattenMenuScenarios(accessStore.accessMenus).filter((item) => { |
| | | if (!item.value || seen.has(item.value)) return false; |
| | | seen.add(item.value); |
| | | return true; |
| | | }); |
| | | }); |
| | | |
| | | function togglePriority(key: ConstraintKey) { |
| | | newPriorities.value[key] = !newPriorities.value[key]; |
| | | } |
| | | |
| | | function buildConstraints(): AgentConversationApi.ConversationConstraints { |
| | | const scenarioByPath = new Map( |
| | | scenarioOptions.value.map((item) => [item.value, item.label]), |
| | | ); |
| | | return { |
| | | industry: newIndustry.value.trim() || undefined, |
| | | instruction: newText.value.trim() || undefined, |
| | | priority_fields: Object.entries(newPriorities.value) |
| | | .filter(([, enabled]) => enabled) |
| | | .map(([key]) => key), |
| | | record_limit: newRecordLimit.value, |
| | | region: newRegion.value.trim() || undefined, |
| | | rest_day_description: newRestDayDescription.value.trim() || undefined, |
| | | scenario_paths: newScenarios.value.map((path) => |
| | | `${scenarioByPath.get(path) ?? path} (${path})`, |
| | | ), |
| | | skip_rest_days: |
| | | newSkipRestDays.value || newPriorities.value.skip_rest_days |
| | | ? newSkipRestDays.value |
| | | : undefined, |
| | | time_end: newTimeRange.value[1], |
| | | time_start: newTimeRange.value[0], |
| | | }; |
| | | } |
| | | |
| | | function hasNewConditions() { |
| | | return Boolean( |
| | | newScenarios.value.length || |
| | | newTimeRange.value.length || |
| | | newRecordLimit.value || |
| | | newIndustry.value.trim() || |
| | | newSkipRestDays.value || |
| | | newRestDayDescription.value.trim() || |
| | | newRegion.value.trim(), |
| | | ); |
| | | } |
| | | |
| | | function resetNewConditions() { |
| | | newScenarios.value = []; |
| | | newText.value = ''; |
| | | newTimeRange.value = []; |
| | | newRecordLimit.value = undefined; |
| | | newIndustry.value = ''; |
| | | newSkipRestDays.value = false; |
| | | newRestDayDescription.value = ''; |
| | | newRegion.value = ''; |
| | | for (const key of Object.keys(newPriorities.value) as ConstraintKey[]) { |
| | | newPriorities.value[key] = false; |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => interrupt.value?.interrupt_id, |
| | |
| | | commit_disabled: 'æªæäº¤', |
| | | confirmed: '已确认', |
| | | created: 'å¾
ä¸ä¼ æä»¶', |
| | | queued: 'æéå¤çä¸', |
| | | error: 'åºé', |
| | | failed: '失败', |
| | | mapping_applied: 'æ å°å·²æ´æ°', |
| | |
| | | function beforeNewUpload(fileInfo: FileType) { |
| | | const raw = fileInfo as unknown as File; |
| | | if (!checkFile(raw)) return false; |
| | | newFile.value = raw; |
| | | newFileName.value = raw.name; |
| | | const duplicate = newFiles.value.some( |
| | | (item) => |
| | | item.name === raw.name && item.size === raw.size && item.lastModified === raw.lastModified, |
| | | ); |
| | | if (!duplicate) newFiles.value = [...newFiles.value, raw].slice(0, 20); |
| | | newAttachmentIds.value = []; |
| | | return false; |
| | | } |
| | | |
| | | function clearNewFiles() { |
| | | newFiles.value = []; |
| | | newAttachmentIds.value = []; |
| | | } |
| | | |
| | | function beforeInterruptUpload(fileInfo: FileType) { |
| | |
| | | async function boot() { |
| | | loading.value = true; |
| | | try { |
| | | const [capabilities, list] = await Promise.all([ |
| | | const [capabilities, authorization, list, modelList] = await Promise.all([ |
| | | getAgentCapabilities(), |
| | | getAuthorizedAgentCapabilities(), |
| | | listAgentConversations({ page_size: 50 }), |
| | | getAgentModels().catch(() => []), |
| | | ]); |
| | | modules.value = capabilities.modules ?? []; |
| | | authorizationScope.value = authorization.scope_summary; |
| | | models.value = modelList; |
| | | if (!selectedModelId.value) { |
| | | selectedModelId.value = active.value?.model_id ?? modelList[0]?.id; |
| | | } |
| | | const visibleScenarioPaths = new Set(scenarioOptions.value.map((item) => item.value)); |
| | | newScenarios.value = newScenarios.value.filter((path) => |
| | | visibleScenarioPaths.has(path), |
| | | ); |
| | | durableTasksEnabled.value = capabilities.durable_tasks?.enabled ?? false; |
| | | conversations.value = list.items ?? []; |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function ensureNewAttachments() { |
| | | if (newAttachmentIds.value.length === newFiles.value.length) { |
| | | return newAttachmentIds.value; |
| | | } |
| | | newAttachmentIds.value = []; |
| | | for (const file of newFiles.value) { |
| | | newAttachmentIds.value.push(await uploadAgentAttachment(file)); |
| | | } |
| | | return newAttachmentIds.value; |
| | | } |
| | | |
| | | async function reloadConversations() { |
| | |
| | | } |
| | | } |
| | | |
| | | function abortEventStream() { |
| | | eventGeneration += 1; |
| | | eventAbort?.abort(); |
| | | eventAbort = undefined; |
| | | if (eventRefreshTimer) clearTimeout(eventRefreshTimer); |
| | | eventRefreshTimer = undefined; |
| | | eventState.value = 'idle'; |
| | | } |
| | | |
| | | async function refreshActive(conversationId: string) { |
| | | const snapshot = await getAgentConversation(conversationId); |
| | | if (active.value?.conversation_id !== conversationId) return; |
| | | active.value = snapshot; |
| | | await reloadConversations(); |
| | | if (snapshot.finished) abortEventStream(); |
| | | } |
| | | |
| | | function queueEventRefresh(conversationId: string) { |
| | | if (eventRefreshTimer) clearTimeout(eventRefreshTimer); |
| | | eventRefreshTimer = setTimeout(() => { |
| | | eventRefreshTimer = undefined; |
| | | void refreshActive(conversationId).catch(() => { |
| | | // SSE ä¼ç»§ç»éè¿ï¼å¿«ç
§è¯»å失败ä¸éå¤å¼¹çªææ°æä½ã |
| | | }); |
| | | }, 120); |
| | | } |
| | | |
| | | function handleConversationEvent( |
| | | conversationId: string, |
| | | event: AgentConversationApi.ConversationEvent, |
| | | ) { |
| | | if (active.value?.conversation_id !== conversationId) return; |
| | | if (event.type === 'progress') { |
| | | const payload = event.payload; |
| | | executionProgress.value = { |
| | | candidateCount: Number(payload.candidate_count ?? 0), |
| | | issueCount: Number(payload.issue_count ?? 0), |
| | | label: String(payload.label ?? 'æ£å¨å¤ç'), |
| | | modelId: |
| | | typeof payload.model_id === 'number' ? payload.model_id : undefined, |
| | | percent: Math.min(100, Math.max(0, Number(payload.percent ?? 0))), |
| | | stage: String(payload.stage ?? 'processing'), |
| | | }; |
| | | return; |
| | | } |
| | | queueEventRefresh(conversationId); |
| | | } |
| | | |
| | | function waitForRetry(delay: number, signal: AbortSignal) { |
| | | return new Promise<void>((resolve) => { |
| | | const timer = setTimeout(resolve, delay); |
| | | signal.addEventListener( |
| | | 'abort', |
| | | () => { |
| | | clearTimeout(timer); |
| | | resolve(); |
| | | }, |
| | | { once: true }, |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | function startEventStream(conversationId: string) { |
| | | abortEventStream(); |
| | | const generation = eventGeneration; |
| | | const controller = new AbortController(); |
| | | eventAbort = controller; |
| | | eventState.value = 'connecting'; |
| | | void (async () => { |
| | | let cursor = 0; |
| | | let retryDelay = 1000; |
| | | while (!controller.signal.aborted && generation === eventGeneration) { |
| | | try { |
| | | cursor = await streamAgentConversationEvents( |
| | | conversationId, |
| | | cursor, |
| | | controller.signal, |
| | | (event) => handleConversationEvent(conversationId, event), |
| | | () => { |
| | | eventState.value = 'connected'; |
| | | retryDelay = 1000; |
| | | }, |
| | | ); |
| | | if (!controller.signal.aborted) eventState.value = 'connecting'; |
| | | } catch { |
| | | if (controller.signal.aborted) return; |
| | | eventState.value = 'retrying'; |
| | | try { |
| | | await refreshActive(conversationId); |
| | | } catch { |
| | | // 轮询å
åºå¤±è´¥åç»§ç»ææ°éé¿ã |
| | | } |
| | | await waitForRetry(retryDelay, controller.signal); |
| | | retryDelay = Math.min(retryDelay * 2, 30_000); |
| | | } |
| | | } |
| | | })(); |
| | | } |
| | | |
| | | async function openConversation(conversationId: string) { |
| | | if (busy.value) return; |
| | | busy.value = true; |
| | | try { |
| | | active.value = await getAgentConversation(conversationId); |
| | | const conversation = await getAgentConversation(conversationId); |
| | | active.value = conversation; |
| | | selectedModelId.value = conversation.model_id ?? models.value[0]?.id; |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } finally { |
| | |
| | | } |
| | | } |
| | | |
| | | function changeModel(value: number) { |
| | | selectedModelId.value = value; |
| | | } |
| | | |
| | | async function createConversation() { |
| | | if (!newFile.value && !newText.value.trim()) { |
| | | message.warning('请ä¸ä¼ æä»¶æå¡«åè¡¥å½è¯´æ'); |
| | | if (!newFiles.value.length && !newText.value.trim() && !hasNewConditions()) { |
| | | message.warning('请ä¸ä¼ æä»¶ãå¡«åè¡¥å½è¯´ææè®¾ç½®è¡¥å½æ¡ä»¶'); |
| | | return; |
| | | } |
| | | creating.value = true; |
| | | try { |
| | | let attachmentId: number | undefined; |
| | | if (newFile.value) { |
| | | attachmentId = await uploadAgentAttachment(newFile.value); |
| | | if (newFiles.value.length > 1) { |
| | | message.warning('å¤æä»¶è¯·ä½¿ç¨åå°åæï¼å½å¹¶æ£æ¥å®æååè¿å
¥è¡¥å½'); |
| | | return; |
| | | } |
| | | const [attachmentId] = await ensureNewAttachments(); |
| | | const description = newText.value.trim(); |
| | | const baseTitle = |
| | | newFiles.value[0]?.name || description.slice(0, 60) || 'æ°å»ºè¡¥å½'; |
| | | active.value = await createAgentConversation({ |
| | | attachment_id: attachmentId ? String(attachmentId) : undefined, |
| | | module_id: newModule.value || undefined, |
| | | text: newText.value.trim() || undefined, |
| | | title: newFile.value?.name || newText.value.trim().slice(0, 60) || undefined, |
| | | constraints: buildConstraints(), |
| | | model_id: selectedModelId.value, |
| | | text: description || undefined, |
| | | title: baseTitle, |
| | | }); |
| | | newOpen.value = false; |
| | | newFile.value = undefined; |
| | | newFileName.value = ''; |
| | | newText.value = ''; |
| | | clearNewFiles(); |
| | | resetNewConditions(); |
| | | await reloadConversations(); |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | |
| | | } |
| | | } |
| | | |
| | | async function startBackgroundAnalysis() { |
| | | if (!durableTasksEnabled.value) { |
| | | message.info('æä¹
任塿å¡å°æªå¯ç¨'); |
| | | return; |
| | | } |
| | | if (!newFiles.value.length) { |
| | | message.warning('åå°åæéè¦å
éæ©æ°æ®æä»¶'); |
| | | return; |
| | | } |
| | | creating.value = true; |
| | | try { |
| | | const attachmentIds = await ensureNewAttachments(); |
| | | if (!attachmentIds.length) return; |
| | | const task = await createDocumentTask({ |
| | | attachment_ids: attachmentIds, |
| | | constraints: buildConstraints(), |
| | | }); |
| | | newOpen.value = false; |
| | | resetNewConditions(); |
| | | message.success('åå°åæä»»å¡å·²å建ï¼å¯å
³é页é¢åç»§ç»æ§è¡'); |
| | | await taskCenter.value?.open(task.task_id); |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } finally { |
| | | creating.value = false; |
| | | } |
| | | } |
| | | |
| | | async function continueFromTask(task: AgentTaskApi.Task) { |
| | | if (task.result?.continuation_supported === false) { |
| | | message.info('å¤æä»¶ç»æéå
å¨è¯æ®æç»ä¸å¤çå²çªï¼å½åæä¸è¿å
¥åæä»¶æäº¤'); |
| | | return; |
| | | } |
| | | const attachmentId = task.result?.attachment_id; |
| | | if (!attachmentId) { |
| | | message.error('ä»»å¡ç»æç¼ºå°éä»¶å¼ç¨ï¼æ æ³è¿å
¥è¡¥å½'); |
| | | return; |
| | | } |
| | | busy.value = true; |
| | | try { |
| | | active.value = await createAgentConversation({ |
| | | attachment_id: String(attachmentId), |
| | | model_id: selectedModelId.value, |
| | | module_id: task.result?.module_id || undefined, |
| | | text: 'åå°ææ¡£åæå·²å®æï¼è¯·ç»§ç»è¿è¡åæ®µæ ¸éªåè¡¥å½ç¡®è®¤ã', |
| | | title: task.result?.filename || 'åå°åæè¡¥å½', |
| | | }); |
| | | await reloadConversations(); |
| | | message.success('å·²è¿å
¥è¡¥å½ç¡®è®¤æµç¨'); |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } finally { |
| | | busy.value = false; |
| | | } |
| | | } |
| | | |
| | | async function apply(result: AgentConversationApi.Conversation, tip?: string) { |
| | | active.value = result; |
| | | selectedModelId.value = result.model_id ?? selectedModelId.value; |
| | | if (tip) message.success(tip); |
| | | await reloadConversations(); |
| | | } |
| | |
| | | const result = await sendAgentAction(current.conversation_id, { |
| | | action, |
| | | interrupt_id: pending.interrupt_id, |
| | | model_id: selectedModelId.value, |
| | | payload, |
| | | revision: pending.revision, |
| | | }); |
| | |
| | | const attachmentId = await uploadAgentAttachment(interruptFile.value); |
| | | const result = await sendAgentMessage(current.conversation_id, { |
| | | attachment_id: String(attachmentId), |
| | | model_id: selectedModelId.value, |
| | | text: 'å·²ä¸ä¼ è¦è¡¥å½çæä»¶ã', |
| | | }); |
| | | interruptFile.value = undefined; |
| | |
| | | } |
| | | } |
| | | |
| | | function decodeAttachmentFilename( |
| | | headers: Record<string, string> | undefined, |
| | | fallback: string, |
| | | ) { |
| | | const encoded = headers?.['x-agent-filename-b64']; |
| | | if (!encoded) return fallback; |
| | | try { |
| | | const binary = atob(encoded); |
| | | const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); |
| | | return new TextDecoder().decode(bytes); |
| | | } catch { |
| | | return fallback; |
| | | } |
| | | } |
| | | |
| | | function canPreview(filename?: string) { |
| | | return /\.(pdf|png|jpe?g|gif|webp|bmp)$/i.test(filename || ''); |
| | | } |
| | | |
| | | async function previewAttachment( |
| | | attachment: AgentConversationApi.ConversationAttachment, |
| | | ) { |
| | | const current = active.value; |
| | | if (!current) return; |
| | | try { |
| | | const response = await downloadAgentConversationAttachment( |
| | | current.conversation_id, |
| | | attachment.attachment_id, |
| | | 'inline', |
| | | ); |
| | | const filename = decodeAttachmentFilename( |
| | | response.headers, |
| | | attachment.filename || `éä»¶-${attachment.attachment_id}`, |
| | | ); |
| | | closeAttachmentPreview(); |
| | | attachmentPreview.value = { |
| | | filename, |
| | | kind: /\.pdf$/i.test(filename) ? 'pdf' : 'image', |
| | | open: true, |
| | | url: URL.createObjectURL(response.data), |
| | | }; |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } |
| | | } |
| | | |
| | | function closeAttachmentPreview() { |
| | | if (attachmentPreview.value.url) URL.revokeObjectURL(attachmentPreview.value.url); |
| | | attachmentPreview.value = { filename: '', kind: 'image', open: false, url: '' }; |
| | | } |
| | | |
| | | async function downloadAttachment( |
| | | attachment: AgentConversationApi.ConversationAttachment, |
| | | ) { |
| | | const current = active.value; |
| | | if (!current) return; |
| | | try { |
| | | const response = await downloadAgentConversationAttachment( |
| | | current.conversation_id, |
| | | attachment.attachment_id, |
| | | 'download', |
| | | ); |
| | | downloadFileFromBlobPart({ |
| | | fileName: decodeAttachmentFilename( |
| | | response.headers, |
| | | attachment.filename || `éä»¶-${attachment.attachment_id}`, |
| | | ), |
| | | source: response.data, |
| | | }); |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } |
| | | } |
| | | |
| | | async function removeConversation( |
| | | item: AgentConversationApi.ConversationSummary, |
| | | ) { |
| | | try { |
| | | await deleteAgentConversation(item.conversation_id); |
| | | if (active.value?.conversation_id === item.conversation_id) { |
| | | abortEventStream(); |
| | | active.value = undefined; |
| | | } |
| | | await reloadConversations(); |
| | | message.success('ä¼è¯åéä»¶å·²å é¤'); |
| | | } catch (error) { |
| | | message.error(agentImportErrorMessage(error)); |
| | | } |
| | | } |
| | | |
| | | onMounted(boot); |
| | | onBeforeUnmount(() => { |
| | | abortEventStream(); |
| | | closeAttachmentPreview(); |
| | | }); |
| | | |
| | | watch( |
| | | () => active.value?.conversation_id, |
| | | (conversationId) => { |
| | | executionProgress.value = undefined; |
| | | if (conversationId && !active.value?.finished) startEventStream(conversationId); |
| | | else abortEventStream(); |
| | | }, |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | v-if="!conversations.length" |
| | | description="ææ è¡¥å½ä¼è¯" |
| | | /> |
| | | <button |
| | | <div |
| | | v-for="item in conversations" |
| | | :key="item.conversation_id" |
| | | class="mb-1 w-full rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800" |
| | | class="mb-1 flex w-full cursor-pointer items-start gap-1 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800" |
| | | :class="{ |
| | | 'bg-blue-50 dark:bg-blue-900/30': |
| | | item.conversation_id === active?.conversation_id, |
| | | }" |
| | | type="button" |
| | | role="button" |
| | | tabindex="0" |
| | | @click="openConversation(item.conversation_id)" |
| | | @keydown.enter="openConversation(item.conversation_id)" |
| | | > |
| | | <div class="truncate text-sm font-medium"> |
| | | {{ item.title || 'æªå½åè¡¥å½' }} |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="truncate text-sm font-medium"> |
| | | {{ item.title || 'æªå½åè¡¥å½' }} |
| | | </div> |
| | | <div class="mt-1 flex items-center gap-2 text-xs text-gray-500"> |
| | | <a-tag :color="statusColor(item.status)" class="!mr-0"> |
| | | {{ statusText(item.status) }} |
| | | </a-tag> |
| | | <span class="truncate"> |
| | | {{ item.module_title || 'å¾
è¯å«' }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | <div class="mt-1 flex items-center gap-2 text-xs text-gray-500"> |
| | | <a-tag :color="statusColor(item.status)" class="!mr-0"> |
| | | {{ statusText(item.status) }} |
| | | </a-tag> |
| | | <span class="truncate"> |
| | | {{ item.module_title || 'å¾
è¯å«' }} |
| | | </span> |
| | | </div> |
| | | </button> |
| | | <a-popconfirm |
| | | cancel-text="åæ¶" |
| | | ok-text="å é¤" |
| | | title="å é¤ä¼è¯åå·²ä¸ä¼ éä»¶ï¼" |
| | | @confirm="removeConversation(item)" |
| | | > |
| | | <a-button |
| | | class="shrink-0" |
| | | danger |
| | | size="small" |
| | | type="text" |
| | | @click.stop |
| | | > |
| | | å é¤ |
| | | </a-button> |
| | | </a-popconfirm> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </aside> |
| | |
| | | </a-tag> |
| | | <span v-if="active?.module_title">{{ active.module_title }}</span> |
| | | <span v-if="active?.total">å
± {{ active.total }} è¡</span> |
| | | <span v-if="active?.data_scope_summary || authorizationScope"> |
| | | æ°æ®èå´ï¼{{ active?.data_scope_summary || authorizationScope }} |
| | | </span> |
| | | <span |
| | | v-if="modelOptions.length" |
| | | class="flex items-center gap-1" |
| | | title="忢åä»ä¸ä¸æ¬¡åæå¼å§çæ" |
| | | > |
| | | 模åï¼ |
| | | <a-select |
| | | :disabled="busy" |
| | | :options="modelOptions" |
| | | :value="selectedModelId" |
| | | class="w-56" |
| | | size="small" |
| | | @change="changeModel" |
| | | /> |
| | | </span> |
| | | <a-tag |
| | | v-if="active" |
| | | :color="eventStateColor" |
| | | class="!mr-0" |
| | | aria-live="polite" |
| | | > |
| | | {{ eventStateText }} |
| | | </a-tag> |
| | | </div> |
| | | <div |
| | | v-if="active?.attachments?.length" |
| | | class="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-500" |
| | | > |
| | | <span>éä»¶ï¼</span> |
| | | <span |
| | | v-for="attachment in active.attachments" |
| | | :key="attachment.attachment_id" |
| | | class="flex items-center gap-1 rounded border border-border px-2 py-0.5" |
| | | > |
| | | <span class="max-w-60 truncate"> |
| | | {{ attachment.filename || `éä»¶ ${attachment.attachment_id}` }} |
| | | </span> |
| | | <a-button |
| | | v-if="canPreview(attachment.filename)" |
| | | class="!h-5 !px-1" |
| | | size="small" |
| | | type="link" |
| | | @click="previewAttachment(attachment)" |
| | | > |
| | | é¢è§ |
| | | </a-button> |
| | | <a-button |
| | | class="!h-5 !px-1" |
| | | size="small" |
| | | type="link" |
| | | @click="downloadAttachment(attachment)" |
| | | > |
| | | ä¸è½½ |
| | | </a-button> |
| | | </span> |
| | | </div> |
| | | </div> |
| | | <a-button |
| | | v-if="active?.receipt?.command_id" |
| | | :disabled="busy" |
| | | @click="downloadReport" |
| | | > |
| | | ä¸è½½åæ§ |
| | | </a-button> |
| | | <div class="flex shrink-0 items-center gap-2"> |
| | | <a-tooltip |
| | | :title=" |
| | | durableTasksEnabled |
| | | ? 'æ¥çè·¨é¡µé¢æ§è¡çææ¡£ä»»å¡' |
| | | : 'æä¹
任塿å¡å°æªå¯ç¨' |
| | | " |
| | | > |
| | | <a-button @click="taskCenter?.open()">åå°ä»»å¡</a-button> |
| | | </a-tooltip> |
| | | <a-button |
| | | v-if="active?.receipt?.command_id" |
| | | :disabled="busy" |
| | | @click="downloadReport" |
| | | > |
| | | ä¸è½½åæ§ |
| | | </a-button> |
| | | </div> |
| | | </header> |
| | | |
| | | <div |
| | | v-if="executionProgress && active" |
| | | class="border-b border-border bg-blue-50/70 px-4 py-2 dark:bg-blue-950/20" |
| | | aria-live="polite" |
| | | > |
| | | <div class="mb-1 flex items-center justify-between gap-4 text-xs"> |
| | | <span class="font-medium text-blue-700 dark:text-blue-300"> |
| | | {{ executionProgress.label }} |
| | | </span> |
| | | <span class="text-gray-500"> |
| | | å·²åç° {{ executionProgress.candidateCount }} æ¡åé |
| | | <template v-if="executionProgress.issueCount"> |
| | | · {{ executionProgress.issueCount }} 项å¾
æ ¸å¯¹ |
| | | </template> |
| | | </span> |
| | | </div> |
| | | <a-progress |
| | | :percent="executionProgress.percent" |
| | | :status="executionProgress.stage === 'error' ? 'exception' : 'active'" |
| | | size="small" |
| | | /> |
| | | </div> |
| | | |
| | | <div class="flex-1 overflow-y-auto p-4"> |
| | | <a-empty |
| | |
| | | </ul> |
| | | </a-alert> |
| | | |
| | | <a-alert |
| | | v-if="card?.repair_guidance?.length" |
| | | class="mb-2" |
| | | type="info" |
| | | show-icon |
| | | > |
| | | <template #message>ä¸å¡å¤§æ¨¡åæ ¸å¯¹å»ºè®®</template> |
| | | <ul class="mt-1 list-disc pl-4 text-xs"> |
| | | <li v-for="(item, index) in card.repair_guidance" :key="index"> |
| | | <span v-if="item.field" class="font-medium">{{ item.field }}ï¼</span> |
| | | {{ item.guidance }} |
| | | </li> |
| | | </ul> |
| | | </a-alert> |
| | | |
| | | <div |
| | | v-if="card?.summary" |
| | | class="mb-2 flex flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300" |
| | |
| | | å
³éåä»
å建 ERP ç©æ |
| | | </span> |
| | | </div> |
| | | |
| | | <a-table |
| | | v-if="previewRows.length" |
| | | class="mb-2" |
| | | :columns="previewColumns" |
| | | :data-source="previewRows" |
| | | :pagination="false" |
| | | :scroll="{ x: 'max-content', y: 320 }" |
| | | row-key="candidate_id" |
| | | size="small" |
| | | bordered |
| | | /> |
| | | |
| | | <div class="flex gap-2"> |
| | | <a-input |
| | |
| | | </footer> |
| | | </section> |
| | | |
| | | <TaskCenterDrawer |
| | | ref="taskCenter" |
| | | :enabled="durableTasksEnabled" |
| | | @continue="continueFromTask" |
| | | /> |
| | | |
| | | <a-modal |
| | | v-model:open="attachmentPreview.open" |
| | | :footer="null" |
| | | :title="attachmentPreview.filename" |
| | | width="80%" |
| | | @cancel="closeAttachmentPreview" |
| | | > |
| | | <iframe |
| | | v-if="attachmentPreview.kind === 'pdf'" |
| | | :src="attachmentPreview.url" |
| | | class="h-[70vh] w-full border-0" |
| | | title="éä»¶é¢è§" |
| | | /> |
| | | <img |
| | | v-else |
| | | :src="attachmentPreview.url" |
| | | alt="éä»¶é¢è§" |
| | | class="max-h-[70vh] w-full object-contain" |
| | | /> |
| | | </a-modal> |
| | | |
| | | <a-modal |
| | | v-model:open="newOpen" |
| | | title="æ°å»ºè¡¥å½" |
| | | :confirm-loading="creating" |
| | | ok-text="å¼å§è¡¥å½" |
| | | cancel-text="åæ¶" |
| | | :width="560" |
| | | @ok="createConversation" |
| | | :width="760" |
| | | > |
| | | <div class="space-y-3"> |
| | | <div> |
| | | <div class="mb-1 text-sm">ä¸å¡åºæ¯</div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>ä¸å¡åºæ¯ï¼å¯å¤éï¼</span> |
| | | <a-button |
| | | size="small" |
| | | :type="newPriorities.scenario_paths ? 'primary' : 'default'" |
| | | @click="togglePriority('scenario_paths')" |
| | | >ä¼å
</a-button> |
| | | </div> |
| | | <a-select |
| | | v-model:value="newModule" |
| | | v-model:value="newScenarios" |
| | | class="w-full" |
| | | :options="moduleOptions" |
| | | /> |
| | | mode="multiple" |
| | | :max-tag-count="3" |
| | | :options="scenarioOptions" |
| | | option-filter-prop="label" |
| | | placeholder="è¯·éæ©å½åè¡¥å½æ¶åçè忍¡å" |
| | | show-search |
| | | > |
| | | <template #option="option"> |
| | | <div class="py-1"> |
| | | <div class="font-medium">{{ option.label }}</div> |
| | | <div v-if="option.description" class="mt-0.5 text-xs text-gray-500"> |
| | | {{ option.description }} |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </a-select> |
| | | <div class="mt-1 text-xs text-gray-500"> |
| | | ä¸ç¡®å®æ¶å¯ä¿æèªå¨è¯å«ï¼ç³»ç»ä¼æ ¹æ®è¡¨å¤´åè¯´ææ¨èåºæ¯ã |
| | | å¼å¯âä¼å
âåæç¡¬çº¦æå¤çï¼æç»æ¨¡åä»å¿
é¡»éè¿è½å䏿°æ®æéæ ¡éªã |
| | | </div> |
| | | </div> |
| | | <div class="grid grid-cols-1 gap-3 md:grid-cols-2"> |
| | | <div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>æ¶é´èå´</span> |
| | | <a-button size="small" :type="newPriorities.time_range ? 'primary' : 'default'" @click="togglePriority('time_range')">ä¼å
</a-button> |
| | | </div> |
| | | <a-range-picker v-model:value="newTimeRange" class="w-full" value-format="YYYY-MM-DD" /> |
| | | </div> |
| | | <div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>æ°æ®æ¡æ°</span> |
| | | <a-button size="small" :type="newPriorities.record_limit ? 'primary' : 'default'" @click="togglePriority('record_limit')">ä¼å
</a-button> |
| | | </div> |
| | | <a-input-number v-model:value="newRecordLimit" class="w-full" :min="1" :max="100000" placeholder="ä¾å¦ï¼500" /> |
| | | </div> |
| | | <div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>è¡ä¸</span> |
| | | <a-button size="small" :type="newPriorities.industry ? 'primary' : 'default'" @click="togglePriority('industry')">ä¼å
</a-button> |
| | | </div> |
| | | <a-input v-model:value="newIndustry" maxlength="80" placeholder="ä¾å¦ï¼æ±½è½¦é¶é¨ä»¶" /> |
| | | </div> |
| | | <div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>åºå</span> |
| | | <a-button size="small" :type="newPriorities.region ? 'primary' : 'default'" @click="togglePriority('region')">ä¼å
</a-button> |
| | | </div> |
| | | <a-input v-model:value="newRegion" maxlength="120" placeholder="ä¾å¦ï¼åä¸ / æ±èåé" /> |
| | | </div> |
| | | <div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>è·³è¿ä¼æ¯æ¥</span> |
| | | <a-button size="small" :type="newPriorities.skip_rest_days ? 'primary' : 'default'" @click="togglePriority('skip_rest_days')">ä¼å
</a-button> |
| | | </div> |
| | | <div class="flex h-8 items-center gap-2"> |
| | | <a-switch v-model:checked="newSkipRestDays" /> |
| | | <span class="text-xs text-gray-500">{{ newSkipRestDays ? 'è·³è¿' : 'ä¸è·³è¿' }}</span> |
| | | </div> |
| | | </div> |
| | | <div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>伿¯æ¥æè¿°</span> |
| | | <a-button size="small" :type="newPriorities.rest_day_description ? 'primary' : 'default'" @click="togglePriority('rest_day_description')">ä¼å
</a-button> |
| | | </div> |
| | | <a-input v-model:value="newRestDayDescription" maxlength="300" placeholder="ä¾å¦ï¼å¨æ¥åæ³å®è忥" /> |
| | | </div> |
| | | </div> |
| | | <div> |
| | | <div class="mb-1 text-sm">è¡¥å½è¯´æï¼å¯éï¼</div> |
| | | <div class="mb-1 flex items-center justify-between text-sm"> |
| | | <span>è¡¥å½è¯´æï¼å¯éï¼</span> |
| | | <a-button size="small" :type="newPriorities.instruction ? 'primary' : 'default'" @click="togglePriority('instruction')">ä¼å
</a-button> |
| | | </div> |
| | | <a-textarea |
| | | v-model:value="newText" |
| | | :rows="2" |
| | |
| | | <a-upload |
| | | accept=".csv,.xlsx,.docx,.pdf" |
| | | :before-upload="beforeNewUpload" |
| | | :max-count="1" |
| | | :max-count="20" |
| | | multiple |
| | | :show-upload-list="false" |
| | | > |
| | | <a-button>éæ©æä»¶</a-button> |
| | | </a-upload> |
| | | <span v-if="newFileName" class="ml-2 text-xs text-gray-500"> |
| | | {{ newFileName }} |
| | | <span v-if="newFiles.length" class="ml-2 text-xs text-gray-500"> |
| | | 已鿩 {{ newFiles.length }} 个æä»¶ |
| | | </span> |
| | | <a-button v-if="newFiles.length" class="ml-2" size="small" type="link" @click="clearNewFiles"> |
| | | æ¸
空 |
| | | </a-button> |
| | | <div v-if="newFiles.length" class="mt-2 max-h-20 overflow-auto text-xs text-gray-500"> |
| | | <div v-for="file in newFiles" :key="`${file.name}:${file.size}:${file.lastModified}`"> |
| | | {{ file.name }} |
| | | </div> |
| | | </div> |
| | | <div class="mt-1 text-xs text-gray-500"> |
| | | æ¯æ CSVãXLSXãDOCX ä¸å¯å¤å¶ææ¬ç PDFï¼å个æä»¶ä¸è¶
è¿ 8 |
| | | MiBãæ«æä»¶æä¸æ¯æã |
| | | æ¯æä¸æ¬¡éæ©æå¤ 20 个 CSVãXLSXãDOCX ä¸å¯å¤å¶ææ¬ç PDFï¼å个æä»¶ä¸è¶
è¿ 8 |
| | | MiBã夿件ä¼å¨åå°åè·¨æä»¶éå¤åå²çªæ£æ¥ï¼æ«æä»¶æä¸æ¯æã |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <template #footer> |
| | | <div class="flex flex-wrap justify-end gap-2"> |
| | | <a-button :disabled="creating" @click="newOpen = false"> |
| | | åæ¶ |
| | | </a-button> |
| | | <a-tooltip |
| | | :title=" |
| | | !durableTasksEnabled |
| | | ? 'éå
å¯ç¨ PostgreSQL ä¸ Redis æä¹
任塿å¡' |
| | | : 'ä»»å¡ä¼æ ¹æ®æä»¶å
容èªå¨è¯å«ï¼å
³é页é¢åä»å¯ä»ä»»å¡ä¸å¿æ¢å¤' |
| | | " |
| | | > |
| | | <a-button |
| | | :disabled="!durableTasksEnabled || !newFiles.length" |
| | | :loading="creating" |
| | | @click="startBackgroundAnalysis" |
| | | > |
| | | åå°åæ |
| | | </a-button> |
| | | </a-tooltip> |
| | | <a-button |
| | | type="primary" |
| | | :loading="creating" |
| | | @click="createConversation" |
| | | > |
| | | ç´æ¥å¼å§è¡¥å½ |
| | | </a-button> |
| | | </div> |
| | | </template> |
| | | </a-modal> |
| | | </div> |
| | | </Page> |