From 9a50d06752a41bf71f849aba5b19e31c6022ae18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=91=E5=AE=81?= <1772105645@qq.com> Date: Wed, 20 May 2026 21:07:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E5=9B=9E=E5=A4=8D=20Markdown=20?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20+=20SSE=20=E8=A7=A3=E6=9E=90=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 markdown-it + highlight.js 渲染 AI 回复内容 - SSE 解析改用双换行分割,解决跨 chunk 数据丢失 - 用户消息保持纯文本,AI 回复用 Markdown 渲染 - 添加代码高亮、列表、表格等样式 --- apps/web-antd/.env.production | 6 + apps/web-antd/src/api/core/file-management.ts | 211 +++- apps/web-antd/src/shims.d.ts | 32 + apps/web-antd/src/views/ai-chat/index.vue | 127 +- .../src/views/file-management/files/index.vue | 1054 +++++++++++++---- .../src/views/workflow/definitions/index.vue | 45 +- .../src/views/workflow/instances/index.vue | 36 +- .../src/views/workflow/start/index.vue | 57 +- pnpm-lock.yaml | 48 + 9 files changed, 1322 insertions(+), 294 deletions(-) create mode 100644 apps/web-antd/src/shims.d.ts diff --git a/apps/web-antd/.env.production b/apps/web-antd/.env.production index b5d508a..7fe908a 100644 --- a/apps/web-antd/.env.production +++ b/apps/web-antd/.env.production @@ -18,6 +18,12 @@ VITE_INJECT_APP_LOADING=true # 打包后是否生成dist.zip VITE_ARCHIVER=true +# 文件系统服务 +VITE_FILE_API_URL=/file-api + # IM 即时通讯服务 VITE_IM_API_URL=/im-api VITE_IM_SIGNALR_URL=/im-signalr/hubs/chat + +# Workflow 工作流服务 +VITE_WORKFLOW_API_URL=/workflow-api diff --git a/apps/web-antd/src/api/core/file-management.ts b/apps/web-antd/src/api/core/file-management.ts index d5bda37..d2cb7ea 100644 --- a/apps/web-antd/src/api/core/file-management.ts +++ b/apps/web-antd/src/api/core/file-management.ts @@ -1,8 +1,16 @@ import { fileRequestClient } from '#/api/request'; +// --- 枚举 --- + +export enum ResourceType { + File = 'file', + Folder = 'folder', +} + // --- 类型定义(file_system Go 响应为 PascalCase) --- export namespace FileApi { + // S3 原始文件信息 export interface FileInfo { Key: string; Size: number; @@ -21,9 +29,90 @@ export namespace FileApi { max_keys?: number; token?: string; } + + // 数据库文件元数据 + export interface FileMeta { + ID: string; + FolderID: string; + Name: string; + S3Key: string; + S3Bucket: string; + Size: number; + ContentType: string; + OwnerID: string; + CreatedAt: string; + UpdatedAt: string; + } + + // 分片上传 + export interface MultipartInitResult { + upload_id: string; + } + + export interface UploadPartResult { + etag: string; + } } -// --- 文件操作 --- +export namespace FolderApi { + export interface Folder { + ID: string; + ParentID: string | null; + Name: string; + OwnerID: string; + CreatedAt: string; + UpdatedAt: string; + } + + export interface FolderWithChildren { + Folder: Folder; + SubFolders: Folder[]; + Files: FileApi.FileMeta[]; + } + + export interface CreateFolderParams { + name: string; + parent_id?: string | null; + } + + export interface RenameFolderParams { + name: string; + } +} + +export namespace ShareApi { + export interface ShareLink { + ID: string; + ResourceType: string; + ResourceID: string; + Token: string; + Password: string | null; + ExpiresAt: string | null; + DownloadCount: number; + MaxDownloads: number | null; + CreatedBy: string; + CreatedAt: string; + } + + export interface ShareInfo { + Token: string; + ResourceType: string; + FileName: string; + FileSize: number; + HasPassword: boolean; + ExpiresAt: string | null; + } + + export interface CreateShareParams { + resource_type: string; + resource_id: string; + password?: string; + expires_at?: string; + max_downloads?: number; + } +} + +// --- 文件操作(S3 原始) --- export async function listFilesApi(params: FileApi.ListFilesParams) { return fileRequestClient.get('/files/list', { @@ -65,6 +154,61 @@ export async function getFileContentApi(bucketName: string, objectKey: string) { }); } +// --- 分片上传 --- + +export async function initMultipartUploadApi( + bucketName: string, + objectKey: string, +) { + return fileRequestClient.post( + '/files/multipart/init', + { bucket_name: bucketName, object_key: objectKey }, + ); +} + +export async function uploadPartApi( + bucketName: string, + objectKey: string, + uploadId: string, + partNumber: number, + data: Blob, +) { + const formData = new FormData(); + formData.append('bucket_name', bucketName); + formData.append('object_key', objectKey); + formData.append('upload_id', uploadId); + formData.append('part_number', String(partNumber)); + formData.append('data', data); + return fileRequestClient.put( + '/files/multipart/part', + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); +} + +export async function completeMultipartUploadApi( + bucketName: string, + objectKey: string, + uploadId: string, + parts: { part_number: number; etag: string }[], +) { + return fileRequestClient.post<{ message: string }>( + '/files/multipart/complete', + { bucket_name: bucketName, object_key: objectKey, upload_id: uploadId, parts }, + ); +} + +export async function abortMultipartUploadApi( + bucketName: string, + objectKey: string, + uploadId: string, +) { + return fileRequestClient.post<{ message: string }>( + '/files/multipart/abort', + { bucket_name: bucketName, object_key: objectKey, upload_id: uploadId }, + ); +} + // --- 存储桶操作 --- export async function listBucketsApi() { @@ -82,3 +226,68 @@ export async function deleteBucketApi(bucketName: string) { data: { bucket_name: bucketName }, }); } + +// --- 文件夹操作 --- + +export async function getFolderTreeApi() { + return fileRequestClient.get('/folders/tree'); +} + +export async function getFolderApi(folderId: string) { + return fileRequestClient.get( + `/folders/${folderId}`, + ); +} + +export async function createFolderApi(params: FolderApi.CreateFolderParams) { + return fileRequestClient.post('/folders', params); +} + +export async function renameFolderApi( + folderId: string, + params: FolderApi.RenameFolderParams, +) { + return fileRequestClient.put( + `/folders/${folderId}`, + params, + ); +} + +export async function deleteFolderApi(folderId: string) { + return fileRequestClient.delete<{ message: string }>(`/folders/${folderId}`); +} + +export async function uploadToFolderApi( + folderId: string, + bucket: string, + file: File, +) { + const formData = new FormData(); + formData.append('file', file); + formData.append('bucket', bucket); + return fileRequestClient.post( + `/folders/${folderId}/files`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); +} + +export async function moveFileApi(fileId: string, targetFolderId: string) { + return fileRequestClient.post<{ message: string }>(`/files/${fileId}/move`, { + target_folder_id: targetFolderId, + }); +} + +// --- 分享操作 --- + +export async function createShareApi(params: ShareApi.CreateShareParams) { + return fileRequestClient.post('/share', params); +} + +export async function deleteShareApi(shareId: string) { + return fileRequestClient.delete<{ message: string }>(`/share/${shareId}`); +} + +export async function getShareInfoApi(token: string) { + return fileRequestClient.get(`/share/${token}`); +} diff --git a/apps/web-antd/src/shims.d.ts b/apps/web-antd/src/shims.d.ts new file mode 100644 index 0000000..cb6b6d4 --- /dev/null +++ b/apps/web-antd/src/shims.d.ts @@ -0,0 +1,32 @@ +declare module 'markdown-it' { + import type { PluginSimple, Renderer, ParserRules, PresetCores } from 'markdown-it'; + + interface Options { + html?: boolean; + xhtmlOut?: boolean; + breaks?: boolean; + langPrefix?: string; + linkify?: boolean; + typographer?: boolean; + quotes?: string; + highlight?: (str: string, lang: string) => string; + } + + type RenderRule = (tokens: any[], idx: number, options: Options, env: any, self: any) => string; + + interface MarkdownIt { + render(text: string, env?: any): string; + utils: { + escapeHtml(str: string): string; + }; + set(options: Options): MarkdownIt; + } + + interface MarkdownItConstructor { + new (preset?: string | PresetCores, options?: Options): MarkdownIt; + (preset?: string | PresetCores, options?: Options): MarkdownIt; + } + + const MarkdownIt: MarkdownItConstructor; + export default MarkdownIt; +} diff --git a/apps/web-antd/src/views/ai-chat/index.vue b/apps/web-antd/src/views/ai-chat/index.vue index e46ac4e..ea365e9 100644 --- a/apps/web-antd/src/views/ai-chat/index.vue +++ b/apps/web-antd/src/views/ai-chat/index.vue @@ -13,6 +13,9 @@ import { Spin, } from 'ant-design-vue'; +import MarkdownIt from 'markdown-it'; +import hljs from 'highlight.js'; + import { createConversationApi, deleteConversationApi, @@ -21,6 +24,22 @@ import { streamMessageApi, } from '#/api/core/ai-chat'; +const md = new MarkdownIt({ + html: false, + linkify: true, + breaks: true, + highlight(str: string, lang: string) { + if (lang && hljs.getLanguage(lang)) { + try { + return `
${hljs.highlight(str, { language: lang }).value}
`; + } catch { + // fallback + } + } + return `
${md.utils.escapeHtml(str)}
`; + }, +}); + const inputMessage = ref(''); const sending = ref(false); const loading = ref(false); @@ -46,6 +65,10 @@ function formatTime(dateStr: string) { }); } +function renderMarkdown(text: string) { + return md.render(text); +} + function scrollToBottom() { nextTick(() => { if (messageListRef.value) { @@ -140,23 +163,35 @@ async function handleSend(e?: any) { const reader = stream.getReader(); const decoder = new TextDecoder(); let aiContent = ''; + let buffer = ''; // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) break; - const text = decoder.decode(value, { stream: true }); - const lines = text.split('\n'); + buffer += decoder.decode(value, { stream: true }); - for (const line of lines) { - if (line.startsWith('event: done')) continue; - if (!line.startsWith('data: ')) continue; - const data = line.slice(6).trim(); - if (data === '[DONE]') continue; + // 按双换行分割 SSE 事件 + const parts = buffer.split('\n\n'); + // 最后一段可能不完整,保留到下次处理 + buffer = parts.pop() ?? ''; + + for (const part of parts) { + const lines = part.split('\n'); + let dataLine = ''; + for (const line of lines) { + if (line.startsWith('event: done')) { + break; + } + if (line.startsWith('data: ')) { + dataLine = line.slice(6).trim(); + } + } + if (!dataLine || dataLine === '[DONE]') continue; try { - const parsed = JSON.parse(data); + const parsed = JSON.parse(dataLine); if (parsed.content) { aiContent += parsed.content; const msg = messages.value.find((m) => m.id === aiMsgId); @@ -164,7 +199,7 @@ async function handleSend(e?: any) { scrollToBottom(); } } catch { - // skip + // skip malformed JSON } } } @@ -270,15 +305,17 @@ onMounted(loadConversations); {{ msg.role === 'User' ? '你' : 'AI 助手' }}
- + + +
@@ -324,3 +361,69 @@ onMounted(loadConversations); + + diff --git a/apps/web-antd/src/views/file-management/files/index.vue b/apps/web-antd/src/views/file-management/files/index.vue index 77933f9..45a0cff 100644 --- a/apps/web-antd/src/views/file-management/files/index.vue +++ b/apps/web-antd/src/views/file-management/files/index.vue @@ -1,5 +1,5 @@