diff --git a/ui/src/api/admin/workspace/conversation.ts b/ui/src/api/admin/workspace/conversation.ts index 9372c462e8b..f55047ec514 100644 --- a/ui/src/api/admin/workspace/conversation.ts +++ b/ui/src/api/admin/workspace/conversation.ts @@ -29,6 +29,10 @@ const getConversationPage = (page: number, size: number, applicationId?: string) return get(`/workspace/${wsId}/historical_conversation/${page}/${size}`) } + +const getConversationRecordDetail = (chatId: string, chatRecordId: string, applicationId?: string) => + get(`/workspace/${getWorkspaceId()}/application/${applicationId}/chat/${chatId}/chat_record/${chatRecordId}`) + /** 获取会话记录分页。 */ const getConversationRecordPage = (chatId: string, page: number, size: number, applicationId?: string) => { const wsId = getWorkspaceId() @@ -66,6 +70,7 @@ export default { postCancelConversationMessage, postResumeConversationMessage, getConversationPage, + getConversationRecordDetail, getConversationRecordPage, deleteConversation, putConversation, diff --git a/ui/src/api/chat/conversation.ts b/ui/src/api/chat/conversation.ts index c02f4dd5668..48895ce788d 100644 --- a/ui/src/api/chat/conversation.ts +++ b/ui/src/api/chat/conversation.ts @@ -17,6 +17,10 @@ const postResumeConversationMessage = (chatId: string, chatRecordId: string) => /** 获取历史会话分页。 */ const getConversationPage = (page: number, size: number) => get(`/historical_conversation/${page}/${size}`) +/** 获取单条会话记录详情(含执行详情 execution_details、知识来源、tokens、耗时)。 */ +const getConversationRecordDetail = (chatId: string, chatRecordId: string) => + get(`/historical_conversation/${chatId}/record/${chatRecordId}`) + /** 获取会话记录分页。 */ const getConversationRecordPage = (chatId: string, page: number, size: number) => get(`/historical_conversation_record/${chatId}/${page}/${size}`) @@ -35,6 +39,7 @@ export default { postCancelConversationMessage, postResumeConversationMessage, getConversationPage, + getConversationRecordDetail, getConversationRecordPage, deleteConversation, putConversation, diff --git a/ui/src/conversation-panel/chat-panel/index.vue b/ui/src/conversation-panel/chat-panel/index.vue deleted file mode 100644 index 072249a823c..00000000000 --- a/ui/src/conversation-panel/chat-panel/index.vue +++ /dev/null @@ -1,897 +0,0 @@ - - - - - - - - - - - - {{ appInfo.name }} - {{ currentConversation?.abstract || '新对话' }} - - - - - - - - {{ appInfo?.name || 'AI 助手' }} - 有什么可以帮你的? - - - - - - - - - - - - - - - - - - - - diff --git a/ui/src/conversation-panel/common/get-api.ts b/ui/src/conversation-panel/common/get-api.ts deleted file mode 100644 index 9ba110a0ca5..00000000000 --- a/ui/src/conversation-panel/common/get-api.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CHAT_TYPE } from '@/conversation-panel/common/enums' -import AdminConversationApi from '@/api/admin/workspace/conversation' -import ConversationApi from '@/api/chat/conversation' -import type { ChatType } from './types' - -/** 根据面板模式选择对话接口。 */ -export function getApi(type: ChatType) { - return type === CHAT_TYPE.DEBUG ? AdminConversationApi : ConversationApi -} diff --git a/ui/src/conversation-panel/common/types.ts b/ui/src/conversation-panel/common/types.ts deleted file mode 100644 index 7350a8b5426..00000000000 --- a/ui/src/conversation-panel/common/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { CHAT_TYPE } from './enums' - -export interface Conversation { - id: string - abstract: string - application_id?: string - create_time?: string - update_time?: string -} - -export interface ChatMessage { - role: 'USER' | 'ASSISTANT' - content: any[] - id: string - write_ed?: boolean -} - -export interface StreamChunk { - content?: any[] - chat_id?: string - chat_record_id?: string - [key: string]: any -} - -export type ChatType = (typeof CHAT_TYPE)[keyof typeof CHAT_TYPE] diff --git a/ui/src/conversation-panel/common/use-chat-store/chat/index.ts b/ui/src/conversation-panel/common/use-chat-store/chat/index.ts deleted file mode 100644 index 94495d86cda..00000000000 --- a/ui/src/conversation-panel/common/use-chat-store/chat/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -import FileApi from '@/api/chat/file' -import { FILE_SOURCE_TYPE } from '@/api/enums' -import { ref, computed } from 'vue' -import { useRoute } from 'vue-router' -import ConversationApi from '@/api/chat/conversation' -import { useStreamManager } from '../shared/use-stream-manager' -import { useMessagePagination } from '../shared/use-message-pagination' -import { useConversationCrud } from '../shared/use-conversation-crud' -import { aggregators } from '../../../index' -import type { ChatMessage } from '../../types' -import { v4 as uuidv4 } from 'uuid' - -// ── 共享状态(单例) ───────────────────────────────────── -const appInfo = ref<{ name: string; icon: string } | null>(null) -const currentChatId = ref('') -// composer 重置信号:新建/切换会话时自增,chat-panel 监听后清空暂存文件与输入, -// 避免上传文件(source_id 绑旧 chat_id)在切上下文后与新 chat_id 对不上。 -const composerResetSignal = ref(0) - -// ── 会话 CRUD ───────────────────────────────────────── -const { conversations, loadConversations, loadMore } = useConversationCrud({ - pageConversationAPI: (query: any) => ConversationApi.getConversationPage(query.currentPage, query.pageSize), -}) - -// ── 消息分页 ───────────────────────────────────────── -const { messages, loading, hasMore, loadMessages, loadMoreMessages, pushMessage, resetMsgState } = useMessagePagination({ - pageConversationMessage: (cid: string, query: any) => ConversationApi.getConversationRecordPage(cid, query.currentPage, query.pageSize), -}) - -// ── 流式管理 ───────────────────────────────────────── -const streamManager = useStreamManager() - -// ── 计算属性 ───────────────────────────────────────── -const currentConversation = computed(() => conversations.value.find((c) => c.id === currentChatId.value) || null) - -export function useChatStore() { - const route = useRoute() - const applicationId = computed(() => (route.params.id as string) || (route.params.applicationId as string) || '') - - const fetchAppInfo = async (applicationId?: string) => { - try { - // 对话模式下,应用信息通常由外部传入或从路由获取 - } catch (e) { - // 静默处理 - } - } - - // ── 流式聚合(内部) ───────────────────────────────── - const appendChunk = (message: ChatMessage, chunk: any) => { - if (!chunk) return - const contentArray = Array.isArray(chunk.content) ? chunk.content : chunk.type ? [chunk] : null - if (!contentArray) return - - contentArray.forEach((item: any) => { - if (!item?.type) return - const aggregator = aggregators[item.type] - if (!aggregator) return - const index = message.content.findIndex((c: any) => c.id === item.id && c.type === item.type) - if (index >= 0) { - message.content[index] = aggregator(message.content[index], item) - } else { - message.content.push(aggregator({}, item)) - } - }) - } - - const createAnswerMessage = (): ChatMessage => ({ - role: 'ASSISTANT', - content: [], - id: '', - }) - - // ── 会话操作 ───────────────────────────────────────── - // 惰性获取当前 chat_id:已有则原样返回;为空才前端生成一个并赋值。 - // 幂等——反复调用返回同一个 id,从而上传文件的 source_id 与后续发送的 chat_id 始终一致。 - // 不请求后端 open,不往左侧列表 push(直到首次发消息才入列表,见 chat-panel send)。 - const getChatId = (): string => { - if (currentChatId.value) return currentChatId.value - currentChatId.value = uuidv4() - return currentChatId.value - } - - const openChat = async (appId?: string) => { - return await ConversationApi.getConversationOpen() - } - - const deleteChat = async (id: string) => { - await ConversationApi.deleteConversation(id) - const idx = conversations.value.findIndex((c) => c.id === id) - if (idx >= 0) conversations.value.splice(idx, 1) - } - - const renameChat = async (id: string, name: string) => { - await ConversationApi.putConversation(id, { abstract: name }) - const c = conversations.value.find((x) => x.id === id) - if (c) c.abstract = name - } - - const chat = (chatId: string, data: any) => ConversationApi.postConversationMessage(chatId, data) - - // ── 文件上传 ───────────────────────────────────────── - const uploadFile = async (file: File): Promise<{ url: string; name: string }> => { - const res = await FileApi.postUploadFile(file, '', FILE_SOURCE_TYPE.CHAT).request - return { url: res, name: file.name } - } - - // ── 切换对话 ───────────────────────────────────────── - const switchConversation = async (cid: string) => { - await streamManager.switchConversation({ - cid, - loadMessages, - resumeStream: (chatRecordId: string) => ConversationApi.postResumeConversationMessage(cid, chatRecordId), - getLastMessage: () => messages.value[messages.value.length - 1] ?? null, - onStream: (chunk) => { - const lastMsg = messages.value[messages.value.length - 1] - if (lastMsg) appendChunk(lastMsg, chunk) - }, - onFinish: () => { - const lastMsg = messages.value[messages.value.length - 1] - if (lastMsg) lastMsg.write_ed = true - }, - onFailure: () => { - const lastMsg = messages.value[messages.value.length - 1] - if (lastMsg) lastMsg.write_ed = true - }, - }) - } - - return { - // 状态 - appInfo, - currentChatId, - currentConversation, - conversations, - messages, - loading, - // 路由 - applicationId, - // 会话 - loadConversations, - loadMore, - getChatId, - openChat, - deleteChat, - renameChat, - fetchAppInfo, - // 消息 - loadMessages, - loadMoreMessages, - pushMessage, - resetMsgState, - createAnswerMessage, - appendChunk, - // 流式 - chat, - startStream: streamManager.startStream, - switchConversation, - closeStream: streamManager.closeStream, - stopWorkflow: (cid: string) => streamManager.stopWorkflow(cid, 'chat'), - cancelWorkflow: (cid: string) => streamManager.cancelWorkflow(cid, 'chat'), - // 文件 - uploadFile, - // composer 重置 - composerResetSignal, - resetComposer: () => { - composerResetSignal.value++ - }, - } -} diff --git a/ui/src/conversation-panel/common/use-chat-store/debug/index.ts b/ui/src/conversation-panel/common/use-chat-store/debug/index.ts deleted file mode 100644 index b9c65cf91f0..00000000000 --- a/ui/src/conversation-panel/common/use-chat-store/debug/index.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { CHAT_TYPE } from '@/conversation-panel/common/enums' -import FileApi from '@/api/admin/file' -import { FILE_SOURCE_TYPE } from '@/api/enums' -import { ref, computed, watch } from 'vue' -import { useRoute } from 'vue-router' -import ConversationApi from '@/api/admin/workspace/conversation' -import { useStreamManager } from '../shared/use-stream-manager' -import { useMessagePagination } from '../shared/use-message-pagination' -import { useConversationCrud } from '../shared/use-conversation-crud' -import { aggregators, Scroll } from '../../../index' -import type { ChatMessage } from '../../types' -import { v4 as uuidv4 } from 'uuid' - -// ── 共享状态(单例) ───────────────────────────────────── -const appInfo = ref<{ name: string; icon: string } | null>(null) -const currentChatId = ref('') -// composer 重置信号:新建/切换会话时自增,chat-panel 监听后清空暂存文件与输入, -// 避免上传文件(source_id 绑旧 chat_id)在切上下文后与新 chat_id 对不上。 -const composerResetSignal = ref(0) -let currentApplicationId = '' -const loading = ref(false) - -// ── 会话 CRUD ───────────────────────────────────────── -const { conversations, loadConversations, loadMore } = useConversationCrud({ - pageConversationAPI: (query: any) => ConversationApi.getConversationPage(query.currentPage, query.pageSize, currentApplicationId), -}) - -// ── 消息分页 ───────────────────────────────────────── -const { messages, hasMore, loadMessages, loadMoreMessages, pushMessage, resetMsgState } = useMessagePagination( - { - pageConversationMessage: (cid: string, query: any) => - ConversationApi.getConversationRecordPage(cid, query.currentPage, query.pageSize, currentApplicationId), - }, - loading, -) - -// ── 流式管理 ───────────────────────────────────────── -const streamManager = useStreamManager() - -// ── 计算属性 ───────────────────────────────────────── -const currentConversation = computed(() => conversations.value.find((c) => c.id === currentChatId.value) || null) - -export function useDebugStore() { - const route = useRoute() - const applicationId = computed(() => (route.params.id as string) || (route.params.applicationId as string) || '') - - // 更新当前 applicationId - watch( - applicationId, - (newId) => { - currentApplicationId = newId - }, - { immediate: true }, - ) - - const fetchAppInfo = async (appId?: string) => { - try { - const { getApi } = await import('../../get-api') - const api = getApi(CHAT_TYPE.DEBUG) - } catch (e) { - // 静默处理 - } - } - - // ── 流式聚合(内部) ───────────────────────────────── - const appendChunk = (message: ChatMessage, chunk: any) => { - if (!chunk) return - const contentArray = Array.isArray(chunk.content) ? chunk.content : chunk.type ? [chunk] : null - if (!contentArray) return - - contentArray.forEach((item: any) => { - if (!item?.type) return - const aggregator = aggregators[item.type] - if (!aggregator) return - const index = message.content.findIndex((c: any) => c.id === item.id && c.type === item.type) - if (index >= 0) { - message.content[index] = aggregator(message.content[index], item) - } else { - message.content.push(aggregator({}, item)) - } - }) - } - - const createAnswerMessage = (): ChatMessage => ({ - role: 'ASSISTANT', - content: [], - id: '', - }) - - // ── 会话操作 ───────────────────────────────────────── - // 惰性获取当前 chat_id:已有则原样返回;为空才前端生成一个并赋值。 - // 幂等——反复调用返回同一个 id,从而上传文件的 source_id 与后续发送的 chat_id 始终一致。 - // 不请求后端 open,不往左侧列表 push(直到首次发消息才入列表,见 chat-panel send)。 - const getChatId = (): string => { - if (currentChatId.value) return currentChatId.value - currentChatId.value = uuidv4() - return currentChatId.value - } - - const openChat = async (appId?: string) => { - return await ConversationApi.getConversationOpen(appId || applicationId.value) - } - - const deleteChat = async (id: string) => { - await ConversationApi.deleteConversation(id, applicationId.value) - const idx = conversations.value.findIndex((c) => c.id === id) - if (idx >= 0) conversations.value.splice(idx, 1) - } - - const renameChat = async (id: string, name: string) => { - await ConversationApi.putConversation(id, { abstract: name }, applicationId.value) - const c = conversations.value.find((x) => x.id === id) - if (c) c.abstract = name - } - - const chat = (chatId: string, data: any) => ConversationApi.postConversationMessage(chatId, data, currentApplicationId) - - // ── 文件上传 ───────────────────────────────────────── - const uploadFile = async (file: File, chatId: string): Promise<{ url: string; name: string }> => { - loading.value = true - // 无会话时前端生成草稿 chat_id(不调后端 open),与发送保持同一个 id。 - const cid = chatId || getChatId() - const res = await FileApi.postUploadFile(file, cid, FILE_SOURCE_TYPE.CHAT).request - loading.value = false - return { url: res, name: file.name } - } - - // ── 切换对话 ───────────────────────────────────────── - const switchConversation = async (cid: string) => { - loading.value = true - await streamManager.switchConversation({ - cid, - loadMessages, - resumeStream: (chatRecordId: string) => ConversationApi.postResumeConversationMessage(cid, chatRecordId, currentApplicationId), - getLastMessage: () => messages.value[messages.value.length - 1] ?? null, - onStream: (chunk) => { - const lastMsg = messages.value[messages.value.length - 1] - if (lastMsg) appendChunk(lastMsg, chunk) - }, - onFinish: () => { - loading.value = false - }, - onFailure: () => { - loading.value = false - }, - }) - } - - return { - // 状态 - appInfo, - currentChatId, - currentConversation, - conversations, - messages, - loading, - // 路由 - applicationId, - // 会话 - loadConversations, - loadMore, - getChatId, - openChat, - deleteChat, - renameChat, - fetchAppInfo, - // 消息 - loadMessages, - loadMoreMessages, - pushMessage, - resetMsgState, - createAnswerMessage, - appendChunk, - // 流式 - chat, - startStream: streamManager.startStream, - switchConversation, - closeStream: streamManager.closeStream, - stopWorkflow: (cid: string) => streamManager.stopWorkflow(cid, 'debug', currentApplicationId), - cancelWorkflow: (cid: string) => streamManager.cancelWorkflow(cid, 'debug', currentApplicationId), - // 文件 - uploadFile, - // composer 重置 - composerResetSignal, - resetComposer: () => { - composerResetSignal.value++ - }, - } -} diff --git a/ui/src/conversation-panel/common/use-chat-store/index.ts b/ui/src/conversation-panel/common/use-chat-store/index.ts deleted file mode 100644 index 3fc00ca82e6..00000000000 --- a/ui/src/conversation-panel/common/use-chat-store/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { CHAT_TYPE } from '@/conversation-panel/common/enums' -import { useChatStore } from './chat' -import { useDebugStore } from './debug' -import type { ChatType } from '../types' - -export function useChatStoreByType(type: ChatType) { - return type === CHAT_TYPE.DEBUG ? useDebugStore() : useChatStore() -} diff --git a/ui/src/conversation-panel/common/use-chat-store/shared/use-conversation-crud.ts b/ui/src/conversation-panel/common/use-chat-store/shared/use-conversation-crud.ts deleted file mode 100644 index d840e1340df..00000000000 --- a/ui/src/conversation-panel/common/use-chat-store/shared/use-conversation-crud.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ref, type Ref } from 'vue' -import type { Conversation } from '../../types' - -export function useConversationCrud( - opts: { - pageConversationAPI: (query: any) => Promise - }, - loading: Ref = ref(false), -) { - const conversations = ref([]) - const currentPage = ref(1) - const pageSize = 50 - const hasMore = ref(true) - - const loadConversations = async (page = 1) => { - loading.value = true - try { - const res = await opts.pageConversationAPI({ currentPage: page, pageSize }) - const records = res?.records || [] - if (page === 1) { - conversations.value = records - } else { - conversations.value = [...conversations.value, ...records] - } - currentPage.value = page - hasMore.value = records.length >= pageSize - } catch (e) { - // debug 模式下历史接口可能不存在,静默处理 - } finally { - loading.value = false - } - } - - const loadMore = async () => { - if (!hasMore.value || loading.value) return - await loadConversations(currentPage.value + 1) - } - - return { - conversations, - loading, - hasMore, - loadConversations, - loadMore, - } -} diff --git a/ui/src/conversation-panel/common/use-chat-store/shared/use-message-pagination.ts b/ui/src/conversation-panel/common/use-chat-store/shared/use-message-pagination.ts deleted file mode 100644 index fc74e8fd837..00000000000 --- a/ui/src/conversation-panel/common/use-chat-store/shared/use-message-pagination.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ref, type Ref } from 'vue' -import type { ChatMessage } from '../../types' - -export function useMessagePagination( - opts: { - pageConversationMessage: (cid: string, query: any) => Promise - }, - loading: Ref = ref(false), -) { - const messages = ref([]) - - const currentPage = ref(1) - const pageSize = 20 - const hasMore = ref(true) - - const loadMessages = async (cid: string, page = 1) => { - loading.value = true - try { - const res = await opts.pageConversationMessage(cid, { currentPage: page, pageSize }) - const records = (res?.records || []).reverse() - const result = records.flatMap((record: any) => { - return [ - { - role: 'USER', - content: [record.question], - id: record.id + '_USER', - }, - { - role: 'ASSISTANT', - content: record.messages, - id: record.id + '_ASSISTANT', - }, - ] - }) - if (page === 1) { - messages.value = result - } else { - messages.value = [...result, ...messages.value] - } - currentPage.value = page - hasMore.value = records.length >= pageSize - } catch (e) { - // 静默处理 - } finally { - loading.value = false - } - } - - const loadMoreMessages = async (cid: string) => { - if (!hasMore.value || loading.value) return - await loadMessages(cid, currentPage.value + 1) - } - - const pushMessage = (msg: ChatMessage) => { - messages.value.push(msg) - } - - const resetMsgState = () => { - messages.value = [] - currentPage.value = 1 - hasMore.value = true - } - - return { - messages, - loading, - hasMore, - loadMessages, - loadMoreMessages, - pushMessage, - resetMsgState, - } -} diff --git a/ui/src/conversation-panel/common/use-chat-store/shared/use-stream-manager.ts b/ui/src/conversation-panel/common/use-chat-store/shared/use-stream-manager.ts deleted file mode 100644 index c2ea2299f9b..00000000000 --- a/ui/src/conversation-panel/common/use-chat-store/shared/use-stream-manager.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { CHAT_TYPE } from '@/conversation-panel/common/enums' -import type { ChatMessage } from '../../types' -import { ConversationStream } from '../../../stream' -import { getApi } from '../../get-api' - -export interface StartStreamOptions { - cid: string - request: () => Promise - onStream: (chunk: any) => void - onFinish?: () => void - onFailure?: (e: any) => void -} - -export interface SwitchOptions { - cid: string - loadMessages: (cid: string) => Promise - resumeStream: (chatRecordId: string) => Promise - getLastMessage: () => ChatMessage | null - onStream: (chunk: any) => void - onFinish?: () => void - onFailure?: () => void - skipLoadMessages?: boolean -} - -export function useStreamManager() { - let currentStream: ConversationStream | null = null - - // 返回值表示本次是否真的关闭了一个进行中的流,供调用方判断是否需要通知后端取消 - const closeStream = () => { - if (currentStream) { - currentStream.cancel() - currentStream = null - return true - } - return false - // 不在这里增加 streamToken,由调用方决定 - } - - const startStream = ({ cid, request, onStream, onFinish, onFailure }: StartStreamOptions) => { - closeStream() - - request() - .then((response: any) => { - currentStream = new ConversationStream(response, onStream, (e) => { - currentStream = null - if (e) onFailure?.(e) - else onFinish?.() - }) - currentStream.start() - }) - .catch((e: any) => { - onFailure?.(e) - }) - } - - const switchConversation = async (opts: SwitchOptions) => { - const { cid, loadMessages, resumeStream, getLastMessage, onStream, onFinish, onFailure, skipLoadMessages } = opts - - // 先关闭旧流 - closeStream() - if (!skipLoadMessages) { - await loadMessages(cid) - } - - const lastMsg = getLastMessage() - // 没有需要续传的流(无消息或最后一条消息已完成)时,同样要结束 loading 契约。 - if (!lastMsg || (lastMsg.content && lastMsg.content.length > 0)) { - onFinish?.() - return - } - try { - const response = await resumeStream(lastMsg.id.replace('_USER', '').replace('_ASSISTANT', '')) - if (!response.ok) { - onFailure?.() - return - } - currentStream = new ConversationStream(response, onStream, (e) => { - currentStream = null - if (e) onFailure?.() - else onFinish?.() - }) - currentStream.start() - } catch (e) { - console.error('resume stream failed', e) - onFailure?.() - } - } - - // 优雅停止:仅通知后端取消,本地 SSE 不断开,继续读取。 - // 后端收到取消后会触发 on_complete 往队列放入 "done" 并 yield [DONE], - // 前端读到 [DONE] 由 ConversationStream 自行 finish(loading 交给 onFinish 复位)。 - // 若此刻就 abort 本地流,服务端 generator 不再被消费,[DONE] 那段永远跑不到。 - const stopWorkflow = (cid: string, apiType: 'chat' | 'debug' = 'chat', applicationId?: string) => { - if (!currentStream || !cid) return - const api = getApi(apiType === 'debug' ? CHAT_TYPE.DEBUG : CHAT_TYPE.CHAT) - api.postCancelConversationMessage(cid, applicationId).catch(() => {}) - } - - const cancelWorkflow = (cid: string, apiType: 'chat' | 'debug' = 'chat', applicationId?: string) => { - // 硬取消:用于卸载/离开页面,必须断开本地流避免后台泄漏,同时通知后端。 - const wasStreaming = closeStream() - // 仅当确实中断了一个进行中的流、且已有会话 id 时才通知后端取消。 - // debug 的 chat_id 是前端本地草稿,首发消息前服务端并不存在该会话, - // 此时调用 cancel_chat_message 只会命中不存在的 chat_id,必须跳过。 - if (!wasStreaming || !cid) return - const api = getApi(apiType === 'debug' ? CHAT_TYPE.DEBUG : CHAT_TYPE.CHAT) - api.postCancelConversationMessage(cid, applicationId).catch(() => {}) - } - - return { - closeStream, - startStream, - switchConversation, - stopWorkflow, - cancelWorkflow, - } -} diff --git a/ui/src/conversation-panel/content-list/index.vue b/ui/src/conversation-panel/components/content-list/index.vue similarity index 92% rename from ui/src/conversation-panel/content-list/index.vue rename to ui/src/conversation-panel/components/content-list/index.vue index eb947de3c07..ee3739bea22 100644 --- a/ui/src/conversation-panel/content-list/index.vue +++ b/ui/src/conversation-panel/components/content-list/index.vue @@ -8,7 +8,7 @@ + + diff --git a/ui/src/conversation-panel/components/header/debug/index.ts b/ui/src/conversation-panel/components/header/debug/index.ts new file mode 100644 index 00000000000..aeff2fcd316 --- /dev/null +++ b/ui/src/conversation-panel/components/header/debug/index.ts @@ -0,0 +1,29 @@ +import { ref, inject, type InjectionKey } from 'vue' + +/** + * debug 面板状态:显隐 + 放大。debug header 深嵌在会话主面板内,用注入而非 props 获取。 + * 由 view/debug 创建并 provide,同时把 open/close 暴露给外层(工作流页)通过 ref 调用。 + */ +export function createDebugHeaderStore() { + const visible = ref(false) + const expanded = ref(false) + + const open = () => (visible.value = true) + const close = () => { + visible.value = false + expanded.value = false + } + const toggleExpand = () => (expanded.value = !expanded.value) + + return { visible, expanded, open, close, toggleExpand } +} + +export type DebugHeaderStore = ReturnType + +export const DEBUG_HEADER_KEY: InjectionKey = Symbol('debug-header') + +export function useDebugHeaderStore(): DebugHeaderStore { + const store = inject(DEBUG_HEADER_KEY) + if (!store) throw new Error('useDebugHeaderStore 必须在 debug view 内使用') + return store +} diff --git a/ui/src/conversation-panel/components/header/debug/index.vue b/ui/src/conversation-panel/components/header/debug/index.vue new file mode 100644 index 00000000000..82a116db8aa --- /dev/null +++ b/ui/src/conversation-panel/components/header/debug/index.vue @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + {{ list.currentConversation.value?.abstract || '新建对话' }} + + + + + + + + + + + + + + + + diff --git a/ui/src/conversation-panel/components/message-footer/index.vue b/ui/src/conversation-panel/components/message-footer/index.vue new file mode 100644 index 00000000000..62115bcc96e --- /dev/null +++ b/ui/src/conversation-panel/components/message-footer/index.vue @@ -0,0 +1,65 @@ + + + + + + + + diff --git a/ui/src/conversation-panel/components/message-input/index.ts b/ui/src/conversation-panel/components/message-input/index.ts new file mode 100644 index 00000000000..d84640ca4bb --- /dev/null +++ b/ui/src/conversation-panel/components/message-input/index.ts @@ -0,0 +1,224 @@ +import { ref, computed, reactive, watch, inject, type Ref, type InjectionKey } from 'vue' +import type { ChatMessage, Conversation, SendMessageOptions } from '../../core/types' + +const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp'] +const documentExts = ['pdf', 'docx', 'txt', 'xls', 'xlsx', 'md', 'html', 'csv'] +const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'flv', 'wmv'] +const audioExts = ['mp3', 'wav', 'ogg', 'aac', 'm4a'] +const getExt = (name: string) => name.split('.').pop()?.toLowerCase() || '' +const isImage = (name: string) => imageExts.includes(getExt(name)) +const isDocument = (name: string) => documentExts.includes(getExt(name)) +const isAudio = (name: string) => audioExts.includes(getExt(name)) +const isVideo = (name: string) => videoExts.includes(getExt(name)) + +export interface FileItem { + uid: number + name: string + size: number + raw: File + url?: string + file_id?: string + previewUrl?: string + uploading?: boolean +} + +// 只声明本组件真正用到的依赖(接口隔离),不接收整个别人的 store +export interface MessageInputDeps { + // 来自 conversation-list + conversations: Ref + getChatId: () => string + renameChat: (id: string, name: string) => Promise + composerResetSignal: Ref + // 来自 message-list + messages: Ref + loading: Ref + sendMessage: (opts: SendMessageOptions) => void + uploadFile: (file: File, chatId: string) => Promise + stop: () => void +} + +/** + * message-input 组件 store:本组件独有——输入内容 + 暂存文件 + 发送。 + */ +export function createMessageInputStore(deps: MessageInputDeps) { + const { conversations, getChatId, renameChat, composerResetSignal, messages, loading, sendMessage, uploadFile, stop } = + deps + + const question = ref('') + const fileList = ref([]) + const uploadPromises = ref[]>([]) + const showDelete = ref('') + const sending = ref(false) + const maxFiles = 10 + const maxSizeMB = 50 + const acceptList = [...imageExts, ...documentExts, ...videoExts, ...audioExts].map((e) => `.${e}`).join(',') + + const placeholder = computed(() => (loading.value ? '正在回复中...' : '输入消息...')) + const canSend = computed( + () => (question.value.trim() || fileList.value.length > 0) && !loading.value && !sending.value, + ) + + const imageFiles = computed(() => fileList.value.filter((f) => isImage(f.name))) + const documentFiles = computed(() => fileList.value.filter((f) => isDocument(f.name))) + const audioFiles = computed(() => fileList.value.filter((f) => isAudio(f.name))) + const videoFiles = computed(() => fileList.value.filter((f) => isVideo(f.name))) + + const getFileIcon = (name: string) => { + const ext = getExt(name) + const doc = 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/svg/document.svg' + const audio = 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.3.1/dist/svg/headset.svg' + const iconMap: Record = { + pdf: doc, doc, docx: doc, xls: doc, xlsx: doc, txt: doc, md: doc, html: doc, csv: doc, + mp3: audio, wav: audio, ogg: audio, aac: audio, m4a: audio, + } + return iconMap[ext] || doc + } + + const validateFile = (file: File) => + fileList.value.length < maxFiles && file.size > 0 && file.size <= maxSizeMB * 1024 * 1024 + + const addFile = (file: File) => { + if (!validateFile(file)) return + const item: FileItem = reactive({ + uid: Date.now() + Math.random(), + name: file.name, + size: file.size, + raw: file, + uploading: true, + }) + if (isImage(file.name)) item.previewUrl = URL.createObjectURL(file) + fileList.value.push(item) + + const uploadPromise = (async () => { + try { + const cid = getChatId() + const url = await uploadFile(file, cid) + item.url = url + const parts = url.split('/') + item.file_id = parts[parts.length - 1] + } catch (e) { + console.error('upload failed:', e) + } finally { + item.uploading = false + } + })() + uploadPromises.value.push(uploadPromise) + uploadPromise.finally(() => { + uploadPromises.value = uploadPromises.value.filter((p) => p !== uploadPromise) + }) + } + + const addFiles = (files: FileList | File[] | null | undefined) => { + if (!files) return + Array.from(files).forEach(addFile) + } + + const removeFile = (index: number) => { + const item = fileList.value[index] + if (!item) return + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) + fileList.value.splice(index, 1) + } + + const clearFiles = () => { + fileList.value.forEach((f) => { + if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) + }) + fileList.value = [] + } + + const send = async () => { + if (sending.value || !canSend.value) return + sending.value = true + try { + if (uploadPromises.value.length) await Promise.all(uploadPromises.value) + + const text = question.value.trim() + const cid = getChatId() + + // 首条消息:此刻把会话 push 进左侧列表(标题乐观更新),流结束后 renameChat 落库 + const isFirstMessage = messages.value.length === 0 + const abstract = text.substring(0, 256) + if (isFirstMessage && !conversations.value.some((c: any) => c.id === cid)) { + conversations.value.unshift({ id: cid, abstract: text ? abstract : '新对话' }) + } + + const pick = (pred: (n: string) => boolean) => + fileList.value.filter((f) => pred(f.name)).map((f) => ({ url: f.url, file_id: f.file_id, name: f.name })) + const images = pick(isImage) + const documents = pick(isDocument) + const audio = pick(isAudio) + const video = pick(isVideo) + const other = fileList.value + .filter((f) => !isImage(f.name) && !isDocument(f.name) && !isAudio(f.name) && !isVideo(f.name)) + .map((f) => ({ url: f.url, file_id: f.file_id, name: f.name })) + + const questionContent: any = { type: 'QUESTION', content: text } + const message: any = { content: text, type: 'QUESTION' } + if (images.length) questionContent.image_list = message.image_list = images + if (documents.length) questionContent.document_list = message.document_list = documents + if (audio.length) questionContent.audio_list = message.audio_list = audio + if (video.length) questionContent.video_list = message.video_list = video + if (other.length) questionContent.other_list = message.other_list = other + + sendMessage({ + chatId: cid, + message, + newQuestion: true, + questionContent, + reChat: false, + onComplete: (e) => { + if (!e && isFirstMessage && text) renameChat(cid, abstract).catch(() => {}) + }, + }) + question.value = '' + clearFiles() + } catch (e) { + console.error('send failed:', e) + loading.value = false + } finally { + sending.value = false + } + } + + // 新建/切换会话时清空暂存文件与输入 + watch( + () => composerResetSignal.value, + () => { + clearFiles() + question.value = '' + }, + ) + + return { + question, + fileList, + showDelete, + maxFiles, + maxSizeMB, + acceptList, + loading: loading, + placeholder, + canSend, + imageFiles, + documentFiles, + audioFiles, + videoFiles, + getFileIcon, + addFile, + addFiles, + removeFile, + send, + stop: stop, + } +} + +export type MessageInputStore = ReturnType + +export const MESSAGE_INPUT_KEY: InjectionKey = Symbol('message-input') + +export function useMessageInputStore(): MessageInputStore { + const store = inject(MESSAGE_INPUT_KEY) + if (!store) throw new Error('useMessageInputStore 必须在 ConversationView 内使用') + return store +} diff --git a/ui/src/conversation-panel/components/message-input/index.vue b/ui/src/conversation-panel/components/message-input/index.vue new file mode 100644 index 00000000000..c707173828b --- /dev/null +++ b/ui/src/conversation-panel/components/message-input/index.vue @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + {{ f.name }} + + + + + + + + + + + + + + + + + {{ f.name }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 支持上传图片、文档、音频、视频文件,最多{{ maxFiles }}个,单个文件最大{{ maxSizeMB }}MB + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/conversation-panel/components/message-list/index.ts b/ui/src/conversation-panel/components/message-list/index.ts new file mode 100644 index 00000000000..1058de15bbc --- /dev/null +++ b/ui/src/conversation-panel/components/message-list/index.ts @@ -0,0 +1,265 @@ +import { ref, inject, type InjectionKey } from 'vue' +import { nanoid } from 'nanoid' +import { aggregators } from '../../core/aggregators' +import { ConversationStream } from '../../core/stream' +import type { ChatMessage, ChatRecordMeta, SendMessageOptions } from '../../core/types' + +export interface MessageListDeps { + pageRecords: (chatId: string, page: number, size: number) => Promise + recordDetail: (chatId: string, chatRecordId: string) => Promise + chatMessage: (chatId: string, data: any) => Promise + resumeMessage: (chatId: string, chatRecordId: string) => Promise + cancel: (chatId: string) => Promise + uploadFile: (file: File, chatId: string) => Promise + // 只读获取当前会话 id(单一数据源在 conversation-list)。必须无副作用:不可惰性创建, + // 否则无会话时的 stop/cancel/loadMore 会凭空生成 id 并误请求后端。 + getChatId: () => string +} + +// 从后端 chat_record(分页项或详情接口,均为 reset_chat_record 结构)提取底部信息栏数据 +const buildRecordMeta = (raw: any): ChatRecordMeta => ({ + messageTokens: raw?.message_tokens || 0, + answerTokens: raw?.answer_tokens || 0, + runTime: raw?.run_time || 0, + knowledgeCount: (raw?.paragraph_list || []).length, + executionDetails: raw?.execution_details || [], +}) + +export function createMessageListStore(deps: MessageListDeps) { + const { pageRecords, recordDetail, chatMessage, resumeMessage, cancel: cancelApi, uploadFile, getChatId } = deps + + const messages = ref([]) + const loading = ref(false) + + // ── 分页 ───────────────────────────────────────────── + const pageSize = 20 + const currentPage = ref(1) + const hasMore = ref(true) + + const loadMessages = async (cid: string, page = 1) => { + loading.value = true + try { + const res = await pageRecords(cid, page, pageSize) + const records = (res?.records || []).reverse() + const result = records.flatMap((record: any) => [ + { role: 'USER', content: [record.question], id: record.id + '_USER' }, + { + role: 'ASSISTANT', + content: record.messages, + id: record.id + '_ASSISTANT', + recordId: record.id, + record: buildRecordMeta(record), + }, + ]) + messages.value = page === 1 ? result : [...result, ...messages.value] + currentPage.value = page + hasMore.value = records.length >= pageSize + } catch (e) { + // 静默处理 + } finally { + loading.value = false + } + } + + const loadMore = async () => { + const cid = getChatId() + if (!hasMore.value || loading.value || !cid) return + await loadMessages(cid, currentPage.value + 1) + } + + const resetMessages = () => { + messages.value = [] + currentPage.value = 1 + hasMore.value = true + } + + // 对话结束后拉取该条记录详情,填充底部信息栏(tokens/耗时/知识来源/执行详情) + const getChatRecord = async (target: any, cid: string) => { + const rid = target?.recordId + if (!cid || !rid) return + try { + target.record = buildRecordMeta(await recordDetail(cid, rid)) + } catch { + /* 底部信息栏拉取失败不影响对话 */ + } + } + + // ── 流式聚合 ───────────────────────────────────────── + const appendChunk = (message: ChatMessage, chunk: any) => { + if (!chunk) return + const contentArray = Array.isArray(chunk.content) ? chunk.content : chunk.type ? [chunk] : null + if (!contentArray) return + contentArray.forEach((item: any) => { + if (!item?.type) return + const aggregator = aggregators[item.type] + if (!aggregator) return + const index = message.content.findIndex((c: any) => c.id === item.id && c.type === item.type) + if (index >= 0) message.content[index] = aggregator(message.content[index], item) + else message.content.push(aggregator({}, item)) + }) + } + + // ── 流生命周期 + 进度订阅 ──────────────────────────── + let currentStream: ConversationStream | null = null + const closeStream = () => { + if (currentStream) { + currentStream.cancel() + currentStream = null + return true + } + return false + } + + const progressListeners = new Set<() => void>() + const onStreamProgress = (fn: () => void) => { + progressListeners.add(fn) + return () => progressListeners.delete(fn) + } + const emitProgress = () => + progressListeners.forEach((fn) => { + try { + fn() + } catch { + /* 单个订阅者异常不影响其他 */ + } + }) + + // onNext 逐段回调,onComplete(e?) 结束回调:e 为空表示正常结束,有值表示失败 + const runStream = ( + request: () => Promise, + onNext: (chunk: any) => void, + onComplete: (e?: any) => void, + ) => { + closeStream() + request() + .then((response: any) => { + currentStream = new ConversationStream( + response, + (chunk: any) => { + onNext(chunk) + emitProgress() + }, + (e) => { + currentStream = null + onComplete(e) + }, + ) + currentStream.start() + }) + .catch((e) => onComplete(e ?? new Error('stream request failed'))) + } + + // ── 发送 / 续跑 ───────────────────────────────────── + const sendMessage = (opts: SendMessageOptions) => { + // 新建会话由调用方(message-input)惰性生成后传入 chatId;表单续跑等缺省沿用当前会话 + const cid = opts.chatId || getChatId() + if (!cid) return + + let target: any + if (opts.newQuestion) { + messages.value.push({ role: 'USER', content: [opts.questionContent ?? opts.message], id: nanoid() }) + messages.value.push({ role: 'ASSISTANT', content: [], id: '' }) + target = messages.value[messages.value.length - 1] + } else { + target = + messages.value.find((m: any) => m.id === `${opts.chatRecordId}_ASSISTANT`) || + messages.value[messages.value.length - 1] + } + if (!target) return + + const payload: any = { message: opts.message, stream: true, re_chat: opts.reChat ?? false } + if (opts.formData) payload.form_data = opts.formData + if (opts.position !== undefined) payload.position = opts.position + if (opts.chatRecordId) payload.chat_record_id = opts.chatRecordId + if (opts.chunkId) payload.chunk_id = opts.chunkId + + loading.value = true + runStream( + () => chatMessage(cid, payload), + (chunk) => { + if (chunk?.chat_record_id && !target.recordId) target.recordId = chunk.chat_record_id + appendChunk(target, chunk) + opts.onNext?.(chunk) + }, + (e) => { + target.write_ed = true + loading.value = false + if (!e) getChatRecord(target, cid) + opts.onComplete?.(e) + }, + ) + } + + // 优雅停止:仅通知后端取消,本地流继续读到 [DONE] 自行结束 + const stop = () => { + const cid = getChatId() + if (!currentStream || !cid) return + cancelApi(cid).catch(() => { }) + } + // 硬取消:卸载/离开时断开本地流并通知后端 + const cancel = () => { + const cid = getChatId() + if (closeStream() && cid) cancelApi(cid).catch(() => { }) + } + + // ── 切换会话:由 conversation-list 主动调用(list 先写好 currentChatId 再调本函数)── + const load = async () => { + const cid = getChatId() + closeStream() + if (!cid) { + resetMessages() + return + } + await loadMessages(cid, 1) + const lastMsg = messages.value[messages.value.length - 1] + if (!lastMsg || (lastMsg.content && lastMsg.content.length > 0)) return + loading.value = true + const recordId = lastMsg.id.replace('_USER', '').replace('_ASSISTANT', '') + runStream( + () => resumeMessage(cid, recordId), + (chunk) => { + const m = messages.value[messages.value.length - 1] + if (m) appendChunk(m, chunk) + }, + (e) => { + if (!e) { + const m = messages.value[messages.value.length - 1] + if (m) { + m.write_ed = true + if (!m.recordId) m.recordId = recordId + getChatRecord(m, cid) + } + } + loading.value = false + }, + ) + } + + return { + messages, + loading, + hasMore, + load, + loadMore, + sendMessage, + uploadFile, + onStreamProgress, + stop, + cancel, + } +} + +export type MessageListStore = ReturnType + +export const MESSAGE_LIST_KEY: InjectionKey = Symbol('message-list') + +export function useMessageListStore(): MessageListStore { + const store = inject(MESSAGE_LIST_KEY) + if (!store) throw new Error('useMessageListStore 必须在 ConversationView 内使用') + return store +} + +// 供“只显示”等场景可选注入:拿不到返回 null(如 form 在无发送能力时降级只读) +export function useMessageListStoreOptional(): MessageListStore | null { + return inject(MESSAGE_LIST_KEY, null) +} diff --git a/ui/src/conversation-panel/components/message-list/index.vue b/ui/src/conversation-panel/components/message-list/index.vue new file mode 100644 index 00000000000..b25583c30c4 --- /dev/null +++ b/ui/src/conversation-panel/components/message-list/index.vue @@ -0,0 +1,112 @@ + + + + + {{ appName }} + 有什么可以帮你的? + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/conversation-panel/index.ts b/ui/src/conversation-panel/core/aggregators.ts similarity index 56% rename from ui/src/conversation-panel/index.ts rename to ui/src/conversation-panel/core/aggregators.ts index b9e112894c4..13ccc365f13 100644 --- a/ui/src/conversation-panel/index.ts +++ b/ui/src/conversation-panel/core/aggregators.ts @@ -1,56 +1,11 @@ -export class Scroll { - private bottomSuction: boolean - private isProgrammaticScroll?: boolean - private element: HTMLElement - - constructor(element: HTMLElement) { - this.bottomSuction = true - this.isProgrammaticScroll = false - this.element = element - this.initEventListener() - } - - private initEventListener() { - this.element.addEventListener('scroll', () => { - if (this.isProgrammaticScroll) { - this.isProgrammaticScroll = undefined - } else { - if (this.element.scrollHeight - this.element.scrollTop <= this.element.clientHeight + 15) { - this.bottomSuction = true - this.isProgrammaticScroll = true - } else { - this.isProgrammaticScroll = undefined - this.bottomSuction = false - } - } - }) - } - - scrollBottom() { - if (this.bottomSuction) { - this.element.scrollTop = this.element.scrollHeight - this.isProgrammaticScroll = true - } - } - - forceBottom() { - this.bottomSuction = true - this.isProgrammaticScroll = true - this.element.scrollTop = this.element.scrollHeight - requestAnimationFrame(() => { - this.element.scrollTop = this.element.scrollHeight - this.isProgrammaticScroll = true - }) - } -} - +// 流式分片聚合:按 id+type 把分片累积成一条内容项(TEXT/REASONING/FAILURE/TOOL/FORM/PROGRESS)。 const TEXT = (prev: any = {}, chunk: any) => { return { type: 'TEXT', id: chunk.id ?? prev.id, content: (prev.content || '') + (chunk.content || ''), workflowRunId: chunk.workflowRunId ?? prev.workflowRunId, - extra: chunk.extra ?? prev.extra + extra: chunk.extra ?? prev.extra, } } @@ -61,7 +16,7 @@ const REASONING = (prev: any, chunk: any) => { content: (prev.content || '') + (chunk.content || ''), workflowRunId: chunk.workflowRunId ?? prev.workflowRunId, extra: chunk.extra ?? prev.extra, - status: chunk.status ?? prev.status + status: chunk.status ?? prev.status, } } @@ -71,7 +26,7 @@ const FAILURE = (prev: any, chunk: any) => { id: chunk.id ?? prev.id, content: (prev.content || '') + (chunk.content || ''), workflowRunId: chunk.workflowRunId ?? prev.workflowRunId, - extra: chunk.extra ?? prev.extra + extra: chunk.extra ?? prev.extra, } } @@ -84,7 +39,7 @@ const TOOL = (prev: any, chunk: any) => { content: (prev.content || '') + (chunk.content || ''), status: chunk.status ?? prev.status, workflowRunId: chunk.workflowRunId ?? prev.workflowRunId, - extra: chunk.extra ?? prev.extra + extra: chunk.extra ?? prev.extra, } } @@ -100,7 +55,7 @@ const FORM = (prev: any, chunk: any) => { position: chunk.position ?? prev.position, chat_record_id: chunk.chat_record_id ?? prev.chat_record_id, workflowRunId: chunk.workflowRunId ?? prev.workflowRunId, - extra: chunk.extra ?? prev.extra + extra: chunk.extra ?? prev.extra, } } @@ -108,9 +63,9 @@ const PROGRESS = (prev: any = {}, chunk: any) => { return { type: 'PROGRESS', id: chunk.id ?? prev.id, - status: chunk.status ?? prev.status, + status: chunk.status ?? prev.status, workflowRunId: chunk.workflowRunId ?? prev.workflowRunId, - extra: chunk.extra ?? prev.extra + extra: chunk.extra ?? prev.extra, } } @@ -120,5 +75,5 @@ export const aggregators: Record any> = { FAILURE, TOOL, FORM, - PROGRESS + PROGRESS, } diff --git a/ui/src/conversation-panel/common/enums.ts b/ui/src/conversation-panel/core/enums.ts similarity index 100% rename from ui/src/conversation-panel/common/enums.ts rename to ui/src/conversation-panel/core/enums.ts diff --git a/ui/src/conversation-panel/core/layout/index.vue b/ui/src/conversation-panel/core/layout/index.vue new file mode 100644 index 00000000000..a328ba983a3 --- /dev/null +++ b/ui/src/conversation-panel/core/layout/index.vue @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + diff --git a/ui/src/conversation-panel/core/layout/types.ts b/ui/src/conversation-panel/core/layout/types.ts new file mode 100644 index 00000000000..f678a2fb284 --- /dev/null +++ b/ui/src/conversation-panel/core/layout/types.ts @@ -0,0 +1,4 @@ +export type SidePanelSide = 'left' | 'right' +export type SidePanelMode = 'push' | 'drawer' +export type SidePanelLayoutMode = SidePanelMode | 'auto' +export type SidePanelOpenMode = boolean | 'auto' diff --git a/ui/src/conversation-panel/stream.ts b/ui/src/conversation-panel/core/stream.ts similarity index 61% rename from ui/src/conversation-panel/stream.ts rename to ui/src/conversation-panel/core/stream.ts index a62acb52684..77ee6806377 100644 --- a/ui/src/conversation-panel/stream.ts +++ b/ui/src/conversation-panel/core/stream.ts @@ -1,3 +1,4 @@ +// SSE 流解析:逐段 data: {...} 回调 onNext,遇 [DONE]/结束/异常回调 onComplete(e?)。 export class ConversationStream { private response: any private onNext: (chunk: any) => void @@ -6,17 +7,13 @@ export class ConversationStream { private completed = false private reader: ReadableStreamDefaultReader | null = null - constructor( - response: any, - onNext: (chunk: any) => void, - onComplete: (e?: any) => void, - ) { + constructor(response: any, onNext: (chunk: any) => void, onComplete: (e?: any) => void) { this.response = response this.onComplete = onComplete this.onNext = onNext } - private complete(e?:any) { + private complete(e?: any) { if (this.completed || this.cancelled) return this.completed = true this.onComplete(e) @@ -106,3 +103,50 @@ export class ConversationStream { } } } + +// 消息容器“贴底吸附”滚动:用户上滑离底后不再自动吸底,回到底部附近恢复吸附。 +export class Scroll { + private bottomSuction: boolean + private isProgrammaticScroll?: boolean + private element: HTMLElement + + constructor(element: HTMLElement) { + this.bottomSuction = true + this.isProgrammaticScroll = false + this.element = element + this.initEventListener() + } + + private initEventListener() { + this.element.addEventListener('scroll', () => { + if (this.isProgrammaticScroll) { + this.isProgrammaticScroll = undefined + } else { + if (this.element.scrollHeight - this.element.scrollTop <= this.element.clientHeight + 15) { + this.bottomSuction = true + this.isProgrammaticScroll = true + } else { + this.isProgrammaticScroll = undefined + this.bottomSuction = false + } + } + }) + } + + scrollBottom() { + if (this.bottomSuction) { + this.element.scrollTop = this.element.scrollHeight + this.isProgrammaticScroll = true + } + } + + forceBottom() { + this.bottomSuction = true + this.isProgrammaticScroll = true + this.element.scrollTop = this.element.scrollHeight + requestAnimationFrame(() => { + this.element.scrollTop = this.element.scrollHeight + this.isProgrammaticScroll = true + }) + } +} diff --git a/ui/src/conversation-panel/core/types.ts b/ui/src/conversation-panel/core/types.ts new file mode 100644 index 00000000000..0b94252632d --- /dev/null +++ b/ui/src/conversation-panel/core/types.ts @@ -0,0 +1,67 @@ +import type { CHAT_TYPE } from './enums' + +export interface Conversation { + id: string + abstract: string + application_id?: string + create_time?: string + update_time?: string +} + +// 助手消息底部展示数据(对话结束后 / 历史加载时填充):知识来源数、tokens、耗时、执行详情 +export interface ChatRecordMeta { + messageTokens: number + answerTokens: number + runTime: number + knowledgeCount: number + executionDetails: any[] +} + +export interface ChatMessage { + role: 'USER' | 'ASSISTANT' + content: any[] + id: string + write_ed?: boolean + recordId?: string // 对应后端 chat_record id(用于拉取执行详情) + record?: ChatRecordMeta // 底部信息栏数据 +} + +export interface StreamChunk { + content?: any[] + chat_id?: string + chat_record_id?: string + [key: string]: any +} + +export type ChatType = (typeof CHAT_TYPE)[keyof typeof CHAT_TYPE] + +// 通用「发起/续跑一次对话」的参数。message-input、表单节点等任何能发起对话的地方共用。 +export interface SendMessageOptions { + chatId?: string // 目标会话 id;新建会话时由调用方(message-input)惰性生成后传入,缺省沿用当前已加载会话 + message: any // 后端 message dict {content,type,image_list?,...} + newQuestion: boolean // true=新增 question(新 USER 气泡 + 新 answer 消息);false=续跑已存在的 assistant 消息 + questionContent?: any // newQuestion=true 时 USER 气泡展示的 content 项,缺省用 message + reChat?: boolean + formData?: Record + position?: any + chatRecordId?: string | null + chunkId?: string | null + onNext?: (chunk: any) => void // 逐段回调 + onComplete?: (e?: any) => void // 结束回调:e 为空=正常结束,有值=失败 +} + +// 模式适配:chat / debug 只在这些 API 上有差异,由 view/{mode} 组装时注入。 +export interface ConversationApiAdapter { + pageConversations: (page: number, size: number) => Promise + pageRecords: (chatId: string, page: number, size: number) => Promise + recordDetail: (chatId: string, chatRecordId: string) => Promise + remove: (chatId: string) => Promise + rename: (chatId: string, data: { abstract: string }) => Promise + chatMessage: (chatId: string, data: any) => Promise + resumeMessage: (chatId: string, chatRecordId: string) => Promise + uploadFile: (file: File, chatId: string) => Promise + cancel: (chatId: string) => Promise +} + +// 说明:各组件 store 的类型不在此集中声明,而是各组件 index.ts 用 ReturnType 自导出 +// (ConversationListStore / MessageListStore / MessageInputStore / ExecutionDetailStore)。 diff --git a/ui/src/conversation-panel/index.vue b/ui/src/conversation-panel/index.vue deleted file mode 100644 index 0a09e789662..00000000000 --- a/ui/src/conversation-panel/index.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - emit('refresh', cid)" - @stop="store.stopWorkflow(store.currentChatId.value)" - /> - - - - - - - diff --git a/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts b/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts new file mode 100644 index 00000000000..6456fb6d8d7 --- /dev/null +++ b/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts @@ -0,0 +1,124 @@ +import { ref, computed, inject, type InjectionKey } from 'vue' +import { v4 as uuidv4 } from 'uuid' +import type { Conversation } from '../../core/types' + +// 只声明本组件真正用到的依赖(接口隔离到函数级) +export interface ConversationListDeps { + pageConversations: (page: number, size: number) => Promise + remove: (id: string) => Promise + rename: (id: string, data: { abstract: string }) => Promise +} + +/** + * conversation-list 组件 store:本组件独有——会话列表 + 当前会话 + 应用信息 + 左侧开合。 + */ +export function createConversationListStore(deps: ConversationListDeps) { + const { pageConversations, remove, rename } = deps + + // 切换会话时主动驱动 message-list 加载。延迟绑定(view 里 bindLoader(msgs.load)), + // 以打破「list 需要 msgs.load、msgs 需要 list 的 currentChatId」的实例化环。 + let loadMessages: () => void = () => {} + const bindLoader = (fn: () => void) => (loadMessages = fn) + + const conversations = ref([]) + const currentChatId = ref('') + const appInfo = ref<{ name: string; icon: string } | null>(null) + const leftSideOpen = ref(true) + // composer 重置信号:新建/切换会话时自增,message-input 监听后清空暂存文件与输入 + const composerResetSignal = ref(0) + + const currentConversation = computed( + () => conversations.value.find((c) => c.id === currentChatId.value) || null, + ) + + // 列表分页 + const pageSize = 50 + const page = ref(1) + const hasMore = ref(true) + + const loadConversations = async (p = 1) => { + try { + const res = await pageConversations(p, pageSize) + const records = res?.records || [] + conversations.value = p === 1 ? records : [...conversations.value, ...records] + page.value = p + hasMore.value = records.length >= pageSize + } catch (e) { + // 历史接口在部分模式下可能不存在,静默处理 + } + } + const loadMore = async () => { + if (!hasMore.value) return + await loadConversations(page.value + 1) + } + + const resetComposer = () => { + composerResetSignal.value++ + } + + // 惰性取 chat_id:已有原样返回,为空才前端生成(幂等,保证上传文件 source_id 与发送一致) + const getChatId = () => { + if (currentChatId.value) return currentChatId.value + currentChatId.value = uuidv4() + return currentChatId.value + } + + // 先写入单一数据源 currentChatId,再驱动 message-list 加载(load 内部读 currentChatId) + const selectConversation = (id: string) => { + resetComposer() + currentChatId.value = id + loadMessages() + } + + const newConversation = () => { + resetComposer() + currentChatId.value = '' + loadMessages() + } + + const deleteChat = async (id: string) => { + await remove(id) + const idx = conversations.value.findIndex((c) => c.id === id) + if (idx >= 0) conversations.value.splice(idx, 1) + if (currentChatId.value === id) { + currentChatId.value = conversations.value[0]?.id || '' + loadMessages() + } + } + + const renameChat = async (id: string, name: string) => { + await rename(id, { abstract: name }) + const c = conversations.value.find((x) => x.id === id) + if (c) c.abstract = name + } + + return { + conversations, + currentChatId, + currentConversation, + appInfo, + leftSideOpen, + hasMore, + composerResetSignal, + loadConversations, + loadMore, + resetComposer, + getChatId, + bindLoader, + selectConversation, + newConversation, + deleteChat, + renameChat, + toggleLeftSide: () => (leftSideOpen.value = !leftSideOpen.value), + } +} + +export type ConversationListStore = ReturnType + +export const CONVERSATION_LIST_KEY: InjectionKey = Symbol('conversation-list') + +export function useConversationListStore(): ConversationListStore { + const store = inject(CONVERSATION_LIST_KEY) + if (!store) throw new Error('useConversationListStore 必须在 ConversationView 内使用') + return store +} diff --git a/ui/src/conversation-panel/sidebar/index.vue b/ui/src/conversation-panel/left-sidebar/conversation-list/index.vue similarity index 57% rename from ui/src/conversation-panel/sidebar/index.vue rename to ui/src/conversation-panel/left-sidebar/conversation-list/index.vue index f2982cf78e6..05250526102 100644 --- a/ui/src/conversation-panel/sidebar/index.vue +++ b/ui/src/conversation-panel/left-sidebar/conversation-list/index.vue @@ -1,50 +1,47 @@ -
{{ appInfo?.name || 'AI 助手' }}
有什么可以帮你的?
{{ appName }}