diff --git a/src/api/settings/modelConfigV2/index.ts b/src/api/settings/modelConfigV2/index.ts index 420cf17..df597af 100644 --- a/src/api/settings/modelConfigV2/index.ts +++ b/src/api/settings/modelConfigV2/index.ts @@ -219,3 +219,14 @@ export function updateModelManage(data: UpdateModelManageParams) { data, }); } + +/** + * 获取当前会话模型(普通对话执行时使用的模型 id) + * 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId) + */ +export function getChatModel() { + return request({ + url: '/model-gateway/model/manage/getChatModel', + method: 'get', + }); +} diff --git a/src/api/settings/session/index.ts b/src/api/settings/session/index.ts new file mode 100644 index 0000000..bdd100d --- /dev/null +++ b/src/api/settings/session/index.ts @@ -0,0 +1,13 @@ +import request from '/@/utils/request'; + +/** + * 查询会话问答消息记录(普通对话历史)。 + * 依赖后端接口,URL/返回结构待后端提供;接口未就绪前由调用方以 localStorage 兜底。 + */ +export function getSessionMessages(sessionId: string) { + return request({ + url: '/ai-agent/session/getMessages', + method: 'get', + params: { sessionId }, + }); +} diff --git a/src/views/home/components/ChatList.vue b/src/views/home/components/ChatList.vue index c3986fb..5664d93 100644 --- a/src/views/home/components/ChatList.vue +++ b/src/views/home/components/ChatList.vue @@ -1,8 +1,18 @@ diff --git a/src/views/home/index.vue b/src/views/home/index.vue index ff91cb3..e24ae82 100644 --- a/src/views/home/index.vue +++ b/src/views/home/index.vue @@ -20,6 +20,7 @@ :active-menu="activeMenu" :workflow-detail="selectedWorkflowDetail" :active-history-id="activeHistoryId" + :messages="currentMessages" /> @@ -57,6 +58,9 @@ import MainContent from './components/MainContent.vue'; import InputBar from './components/InputBar.vue'; import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue'; import { applyHomeFormValues } from './utils/flowDsl'; +import { getChatModel } from '/@/api/settings/modelConfigV2'; +import { openWsExecute } from './utils/wsExecute'; +import { getSessionMessages } from '/@/api/settings/session'; import type { ExecutionTreeItem } from '/@/api/settings/creation'; import { getExecutionList, @@ -66,7 +70,6 @@ import { deleteExecutionSession, getSessionList, downloadToFile, - executeFlow, } from '/@/api/settings/creation'; interface HistoryItem { @@ -82,6 +85,7 @@ interface ChatMessage { content: string; time: string; isUser: boolean; + loading?: boolean; } interface TreeNode { @@ -283,6 +287,13 @@ const isSendDisabled = computed(() => { return session?.status === 'executing'; }); +// 当前会话消息流(对话页渲染) +const currentMessages = computed(() => { + const id = activeHistoryId.value; + if (!id) return []; + return sessionMessages.value.get(id) || []; +}); + const getSessionId = () => { return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; }; @@ -326,12 +337,39 @@ const handleMenuChange = (menu: string) => { const formatTime = (d: Date) => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); +// ===== 消息缓存(普通对话历史;后端接口就绪前 localStorage 兜底)===== +const CHAT_CACHE_KEY = 'home_chat_msgs_'; +const chatCacheKey = (sid: string) => CHAT_CACHE_KEY + sid; +const loadChatCache = (sid: string): ChatMessage[] => { + try { + const raw = localStorage.getItem(chatCacheKey(sid)); + return raw ? (JSON.parse(raw) as ChatMessage[]) : []; + } catch { + return []; + } +}; +const saveChatCache = (sid: string, msgs: ChatMessage[]) => { + try { + localStorage.setItem(chatCacheKey(sid), JSON.stringify(msgs)); + } catch { + /* 存储不可用时忽略 */ + } +}; +const clearChatCache = (sid: string) => { + try { + localStorage.removeItem(chatCacheKey(sid)); + } catch { + /* ignore */ + } +}; + const addMessage = (msg: ChatMessage) => { const id = activeHistoryId.value; if (!id) return; const list = sessionMessages.value.get(id); if (list) list.push(msg); else sessionMessages.value.set(id, [msg]); + saveChatCache(id, sessionMessages.value.get(id) || []); }; const handleSend = async (message: string) => { @@ -350,18 +388,14 @@ const handleSend = async (message: string) => { const sid = activeHistoryId.value; if (sendingSessions[sid]) return; sendingSessions[sid] = true; - if (!selectedWorkflowDetail.value) { - ElMessage.warning('请先选择一个工作流'); - delete sendingSessions[sid]; - return; - } const mc = mainContentRef.value; if (!mc) { delete sendingSessions[sid]; return; } - if (!mc.validateFormFields()) { + // 工作流模式:发送前校验必填表单字段;普通对话无需表单 + if (selectedWorkflowDetail.value && !mc.validateFormFields()) { delete sendingSessions[sid]; return; } @@ -376,7 +410,7 @@ const handleSend = async (message: string) => { // 添加用户消息 addMessage({ id: 'msg-' + Date.now() + '-user', - content: message || '执行工作流', + content: message || (selectedWorkflowDetail.value ? '执行工作流' : ''), time: formatTime(new Date()), isUser: true, }); @@ -385,6 +419,75 @@ const handleSend = async (message: string) => { const sessionId = curSession.sessionId || getSessionId(); + // 分支:有工作流 → 执行工作流;无工作流 → 普通对话 + if (selectedWorkflowDetail.value) { + await runWorkflow(sid, sessionId, message, mc); + } else { + await runChat(sid, sessionId, message); + } +}; + +// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 ===== +const runWorkflow = async (sid: string, sessionId: string, message: string, mc: any) => { + const curSession = historyList.value.find((h) => h.id === sid); + if (!curSession) { + delete sendingSessions[sid]; + return; + } + + let finished = false; + let wsFailed = false; + const finishExec = (success: boolean, errorMsg?: string) => { + if (finished) return; + finished = true; + curSession.status = success ? 'completed' : 'failed'; + addMessage({ + id: 'msg-' + Date.now() + (success ? '-done' : '-fail'), + content: success ? '✅ 执行完成,可前往工作空间查看产出' : `❌ ${errorMsg || '执行失败,请重试或联系管理员'}`, + time: formatTime(new Date()), + isUser: false, + }); + if (success) { + ElMessage.success('✅ 执行完成,可前往工作空间查看'); + // 刷新工作空间树(查询所有结果) + getExecutionList().then((res) => { + imgAddressPrefix.value = res.data?.imgAddressPrefix || ''; + treeNodes.value = buildTreeNodes(res.data?.tree || []); + }); + } else { + ElMessage.error(errorMsg || '执行失败,请重试'); + } + // 虚拟会话执行后刷新会话列表替换真实条目 + if (curSession.id.startsWith('virtual_')) { + getSessionList().then((sessionRes) => { + const freshList = getSessionData(sessionRes).map((s: any) => ({ + id: s.id, + sessionId: s.sessionId, + title: s.flowName || '未命名会话', + time: s.createDate?.substring(0, 10) || '', + })); + const match = freshList.find((s: any) => s.sessionId === curSession.sessionId); + if (match) { + const idx = historyList.value.findIndex((h) => h.id === curSession.id); + if (idx >= 0) { + const msgs = sessionMessages.value.get(curSession.id); + sessionMessages.value.delete(curSession.id); + sessionMessages.value.set(match.id, msgs || []); + historyList.value[idx] = { ...match, status: curSession.status }; + if (activeHistoryId.value === curSession.id) { + activeHistoryId.value = match.id; + } + } + } + }); + } + delete sendingSessions[sid]; + // 执行完成后切回对话页:清空工作流选择,主区域展示消息流 + if (success) { + inputBarRef.value?.resetAll?.(); + } + }; + try { // 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回对应字段(model → modelRequestParams;form → outputConfig) const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || [])); @@ -396,71 +499,77 @@ const handleSend = async (message: string) => { nodes: nodeInputParams, }; + // 3. 获取当前会话模型(普通对话用用户设置的会话模型 id) + let chatModelId: string | number | undefined; + try { + const chatRes: any = await getChatModel(); + chatModelId = chatRes?.data?.modelManage?.id || chatRes?.data?.id || chatRes?.data?.modelId; + } catch { + chatModelId = undefined; + } - // 3. 构建请求参数(外层 fileUrl 留给输入框附件,当前已禁用故保持空数组) - const params = { + // 4. 打开 WebSocket 执行(消息格式待定:收到完成标志或连接关闭视为完成) + const ws = openWsExecute({ + sessionId, flowId: selectedWorkflowDetail.value.id, + modelId: chatModelId, + question: message || '执行工作流', + systemPrompt: '', flowContent: updatedFlowContent, - nodeInputParams: nodeInputParams, - sessionId: sessionId, - desc: message, - flowName: selectedWorkflowDetail.value.flowName || '', - fileUrl: [], - resultUrl: selectedWorkflowDetail.value.resultUrl || '', - templates: mc.templates || [], - }; - - // 4. 执行 - await executeFlow(params); - // 标记完成(直接用 curSession 引用,避免切换会话后状态错乱) - curSession.status = 'completed'; - addMessage({ - id: 'msg-' + Date.now() + '-done', - content: '✅ 执行完成,可前往工作空间查看产出', - time: formatTime(new Date()), - isUser: false, - }); - ElMessage.success('✅ 执行完成,可前往工作空间查看'); - // 刷新工作空间树(查询所有结果) - getExecutionList().then((res) => { - imgAddressPrefix.value = res.data?.imgAddressPrefix || ''; - treeNodes.value = buildTreeNodes(res.data?.tree || []); - }); - // 虚拟会话执行成功后,刷新会话列表替换真实条目 - if (curSession.id.startsWith('virtual_')) { - getSessionList().then((sessionRes) => { - const freshList = getSessionData(sessionRes).map((s: any) => ({ - id: s.id, - sessionId: s.sessionId, - title: s.flowName || '未命名会话', - time: s.createDate?.substring(0, 10) || '', - })); - const match = freshList.find((s: any) => s.sessionId === curSession.sessionId); - if (match) { - const idx = historyList.value.findIndex((h) => h.id === curSession.id); - if (idx >= 0) { - const msgs = sessionMessages.value.get(curSession.id); - sessionMessages.value.delete(curSession.id); - sessionMessages.value.set(match.id, msgs || []); - historyList.value[idx] = { ...match, status: curSession.status }; - if (activeHistoryId.value === curSession.id) { - activeHistoryId.value = match.id; - } - } + onMessage: (raw) => { + let data: any = raw; + if (typeof raw === 'string') { + try { data = JSON.parse(raw); } catch { data = raw; } } - }); + const str = typeof data === 'string' ? data : JSON.stringify(data || ''); + if (/done|finish|complete|end|success/i.test(str)) { + finishExec(true); + } + }, + onError: () => { + wsFailed = true; + finishExec(false, '执行连接失败,请重试'); + }, + onClose: () => { + // 连接关闭兜底视为完成;若已触发 onError 则按失败处理 + if (!finished) finishExec(!wsFailed); + }, + }); + if (!ws) { + finishExec(false, 'WebSocket 初始化失败,请检查服务地址'); } } catch (e: any) { - ElMessage.error(e?.message || "执行失败,请重试"); - curSession.status = 'failed'; - addMessage({ - id: 'msg-' + Date.now() + '-fail', - content: '❌ 执行失败,请重试或联系管理员', - time: formatTime(new Date()), - isUser: false, - }); - // 虚拟会话执行失败也刷新列表(后端会创建 status=3 的记录) - if (curSession.id.startsWith('virtual_')) { + finishExec(false, e?.message || '执行失败,请重试'); + } +}; + +// ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 ===== +const runChat = async (sid: string, sessionId: string, message: string) => { + // AI loading 占位气泡 + const aiMsgId = 'msg-' + Date.now() + '-ai'; + addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true }); + + let done = false; + let failed = false; + const finishChat = (success: boolean, errorMsg?: string) => { + if (done) return; + done = true; + const list = sessionMessages.value.get(sid); + const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; + if (!success) { + const content = `❌ ${errorMsg || '对话失败,请重试'}`; + if (list && aiIdx >= 0) { + list[aiIdx].loading = false; + list[aiIdx].content = content; + } else { + addMessage({ id: 'msg-' + Date.now() + '-fail', content, time: formatTime(new Date()), isUser: false }); + } + } + // 更新会话状态 + const session = historyList.value.find((h) => h.id === sid); + if (session) session.status = success ? 'completed' : 'failed'; + // 虚拟会话对话后刷新会话列表替换真实条目 + if (session && session.id.startsWith('virtual_')) { getSessionList().then((sessionRes) => { const freshList = getSessionData(sessionRes).map((s: any) => ({ id: s.id, @@ -468,37 +577,91 @@ const handleSend = async (message: string) => { title: s.flowName || '未命名会话', time: s.createDate?.substring(0, 10) || '', })); - const match = freshList.find((s: any) => s.sessionId === curSession.sessionId); + const match = freshList.find((s: any) => s.sessionId === session.sessionId); if (match) { - const idx = historyList.value.findIndex((h) => h.id === curSession.id); + const idx = historyList.value.findIndex((h) => h.id === session.id); if (idx >= 0) { - const msgs = sessionMessages.value.get(curSession.id); - sessionMessages.value.delete(curSession.id); + const msgs = sessionMessages.value.get(session.id); + sessionMessages.value.delete(session.id); sessionMessages.value.set(match.id, msgs || []); - historyList.value[idx] = { ...match, status: curSession.status }; - if (activeHistoryId.value === curSession.id) { + historyList.value[idx] = { ...match, status: session.status }; + saveChatCache(match.id, msgs || []); + if (activeHistoryId.value === session.id) { activeHistoryId.value = match.id; } } } }); } - } finally { + saveChatCache(sid, sessionMessages.value.get(sid) || []); delete sendingSessions[sid]; + }; + + // 获取会话模型 + let chatModelId: string | number | undefined; + try { + const chatRes: any = await getChatModel(); + chatModelId = chatRes?.data?.modelManage?.id || chatRes?.data?.id || chatRes?.data?.modelId; + } catch { + chatModelId = undefined; + } + + const ws = openWsExecute({ + sessionId, + modelId: chatModelId, + question: message || '', + systemPrompt: '', + onMessage: (raw) => { + let data: any = raw; + if (typeof raw === 'string') { + try { data = JSON.parse(raw); } catch { data = raw; } + } + // 提取回复文本(兼容常见字段) + let text = ''; + if (typeof data === 'string') text = data; + else if (data && typeof data === 'object') { + text = data.content ?? data.message ?? data.reply ?? data.text ?? data.answer ?? ''; + } + const str = text ? text : typeof data === 'string' ? data : JSON.stringify(data || ''); + const list = sessionMessages.value.get(sid); + const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1; + if (aiIdx >= 0 && str) { + const ai = list[aiIdx]; + if (ai.loading) ai.loading = false; + ai.content = ai.content ? ai.content + (str.startsWith(' ') ? str : '\n' + str) : str; + saveChatCache(sid, list || []); + } + if (/done|finish|complete|end|success/i.test(str)) { + finishChat(true); + } + }, + onError: () => { + failed = true; + finishChat(false, '对话连接失败,请重试'); + }, + onClose: () => { + // 连接关闭兜底完成 + if (!done) finishChat(!failed); + }, + }); + if (!ws) { + finishChat(false, 'WebSocket 初始化失败,请检查服务地址'); } }; const handleSelectHistory = async (id: string) => { activeHistoryId.value = id; activeMenu.value = 'chat'; - // 加载该会话的工作流详情,渲染可编辑表单 const session = historyList.value.find((h) => h.id === id); + let asForm = false; if (session && !session.id.startsWith('virtual_')) { try { const res = await getExecutionDetail(session.id); if (res.data) { + // 工作流会话:回显表单页(可改参数重跑),并锁定工作流选择 selectedWorkflowDetail.value = res.data; isHistoryWorkflow.value = true; + asForm = true; // 同步回显 InputBar 的工作流选择 const ib = inputBarRef.value as any; if (ib?.commonWorkflows && res.data.flowName) { @@ -507,10 +670,15 @@ const handleSelectHistory = async (id: string) => { } } } catch { - // 无关联执行数据,仅展示消息 + // 无执行详情 → 按普通对话会话处理 } - } else { + } + if (!asForm) { + // 普通对话会话 / 虚拟会话:切回对话页,加载消息记录 selectedWorkflowDetail.value = null; + isHistoryWorkflow.value = false; + inputBarRef.value?.clearWorkflow?.(); + await loadSessionMessages(id); } // 清除可重新执行的标记 if (session?.status === 'completed' || session?.status === 'failed' || (session?.status === 'executing' && !sendingSessions[session.id])) { @@ -518,6 +686,35 @@ const handleSelectHistory = async (id: string) => { } }; +// 加载会话消息:优先后端接口,接口未就绪时读本地缓存兜底 +const loadSessionMessages = async (sid: string) => { + const existing = sessionMessages.value.get(sid); + if (existing && existing.length > 0) return; // 内存已有消息直接保留 + try { + const res: any = await getSessionMessages(sid); + const list = Array.isArray(res?.data) ? res.data : res?.data?.list || res?.data?.messages; + if (Array.isArray(list) && list.length > 0) { + const msgs: ChatMessage[] = list.map((m: any, i: number) => ({ + id: m.id || 'msg-' + Date.now() + '-' + i, + content: m.content ?? m.message ?? m.reply ?? '', + time: m.time || m.createDate || '', + isUser: m.isUser === true || m.role === 'user', + })); + sessionMessages.value.set(sid, msgs); + saveChatCache(sid, msgs); + return; + } + } catch { + // 接口不可用 → 兜底本地缓存 + } + const cached = loadChatCache(sid); + if (cached.length > 0) { + sessionMessages.value.set(sid, cached); + } else if (!sessionMessages.value.has(sid)) { + sessionMessages.value.set(sid, []); + } +}; + const createNewSession = () => { const sessionId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; historyList.value.unshift({ @@ -561,6 +758,7 @@ const handleDeleteHistory = async (id: string) => { if (idx < 0) return; historyList.value.splice(idx, 1); sessionMessages.value.delete(id); + clearChatCache(id); if (activeHistoryId.value === id) { if (historyList.value.length > 0) { activeHistoryId.value = historyList.value[0].id; diff --git a/src/views/home/utils/flowDsl.ts b/src/views/home/utils/flowDsl.ts index 3755e33..351c511 100644 --- a/src/views/home/utils/flowDsl.ts +++ b/src/views/home/utils/flowDsl.ts @@ -200,6 +200,61 @@ export function collectHomeFormFields(node: any): HomeFormField[] { return collectLegacyFormConfig(node); } +// 上传类型字段判定(与 MainContent isFileField 一致) +const isUploadType = (f: HomeFormField): boolean => + f.type === 'upload' || f.type === 'uploadMultiple' || f.type === 'fileUpload'; + +// 上传类型空值:未选文件(空字符串 / 空数组 / null) +const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0); + +// 按路径定位父级后删除末段字段(modelRequestParams 字段 / outputConfig 条目 / http body 子字段) +function deleteResolvedPath(params: any, path: string): boolean { + if (!path) return false; + const segments = path.split('.'); + const last = segments[segments.length - 1]; + let cur = params; + for (const seg of segments.slice(0, -1)) { + if (cur === undefined || cur === null || typeof cur !== 'object') return false; + if (seg === 'attrs') { + cur = cur.attrs; + if (!cur || typeof cur !== 'object') return false; + } else { + const m = seg.match(/^value\[(\d+)\]$/); + if (m) { + if (!Array.isArray(cur.value)) return false; + cur = cur.value[Number(m[1])]; + } else { + cur = cur[seg]; + } + } + } + if (!cur || typeof cur !== 'object') return false; + const lm = last.match(/^value\[(\d+)\]$/); + if (lm) { + if (!Array.isArray(cur.value)) return false; + cur.value.splice(Number(lm[1]), 1); + } else { + delete cur[last]; + } + return true; +} + +// 从 DSL 移除空上传字段(对应 applyHomeFormValues 中的写回分支) +function removeUploadField(node: any, code: string, f: HomeFormField): void { + if (code === 'model') { + deleteResolvedPath(node?.modelConfig?.modelRequestParams, f.path); + } else if (code === 'form') { + const list = Array.isArray(node?.outputConfig) ? node.outputConfig : []; + const idx = list.findIndex((o: any) => o && o.field === f.path); + if (idx >= 0) list.splice(idx, 1); + } else if (f.__isHttpBodyChild && f.bodyKey && Array.isArray(node?.formConfig)) { + const bodyField = node.formConfig.find((x: any) => x && x.field === 'body'); + if (bodyField?.value && typeof bodyField.value === 'object' && !Array.isArray(bodyField.value)) { + delete bodyField.value[f.bodyKey]; + } + } +} + // 首页表单值写回 DSL 对应字段(model → modelRequestParams.path.value;form → outputConfig[].value) export function applyHomeFormValues(nodes: any[], formValues: Record): void { if (!Array.isArray(nodes)) return; @@ -210,6 +265,11 @@ export function applyHomeFormValues(nodes: any[], formValues: Record { + const raw = import.meta.env.VITE_API_URL || ''; + return raw.replace(/^http/, 'ws'); +}; + +export interface WsExecuteOptions { + sessionId: string; + flowId?: string | number; + modelId?: string | number; + question?: string; + systemPrompt?: string; + flowContent?: any; + onOpen?: (ws: WebSocket) => void; + onMessage?: (raw: any) => void; + onClose?: (ev: CloseEvent) => void; + onError?: (ev: Event) => void; +} + +/** + * 打开 WebSocket 执行工作流。 + * 返回 WebSocket 实例(若 URL 构造失败返回 null)。 + * 连接成功后自动发送 flowContent(握手后 send JSON)。 + */ +export function openWsExecute(opts: WsExecuteOptions): WebSocket | null { + const base = getWsBase(); + const params: Record = { sessionId: opts.sessionId }; + if (opts.flowId) params.flowId = String(opts.flowId); + if (opts.modelId) params.modelId = String(opts.modelId); + if (opts.question) params.question = opts.question; + if (opts.systemPrompt) params.systemPrompt = opts.systemPrompt; + const token = Session.get('token'); + if (token) params.token = String(token); + + const query = Object.keys(params) + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) + .join('&'); + const url = `${base}/ai-agent/session/wsExecute?${query}`; + + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch (e) { + console.error('[wsExecute] WebSocket 创建失败', e); + return null; + } + + ws.onopen = () => { + if (opts.flowContent) { + ws.send(JSON.stringify({ flowContent: opts.flowContent, systemPrompt: opts.systemPrompt || '' })); + } + opts.onOpen?.(ws); + }; + ws.onmessage = (ev) => opts.onMessage?.(ev.data); + ws.onclose = (ev) => opts.onClose?.(ev); + ws.onerror = (ev) => opts.onError?.(ev); + + return ws; +}