|
|
@@ -1,5 +1,5 @@
|
|
|
import { ref, toRaw } from 'vue'
|
|
|
-import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile } from './interface'
|
|
|
+import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile, MessageSegment } from './interface'
|
|
|
import { useUserStore } from '@/stores/user'
|
|
|
import { chatDB } from './chatDB'
|
|
|
import {
|
|
|
@@ -21,6 +21,42 @@ export interface SessionLoadDoneInfo {
|
|
|
suggestNewSession: boolean
|
|
|
}
|
|
|
|
|
|
+// 文件下载标记:$$FILE_DOWNLOAD:{...}$$(模块作用域,避免每次调用重新编译)
|
|
|
+const FILE_MARKER_RE = /\$\$FILE_DOWNLOAD:([\s\S]*?)\$\$/g
|
|
|
+
|
|
|
+// 解析出的行内文件片段(用于内联下载按钮)
|
|
|
+interface ParsedFileChunk {
|
|
|
+ kind: 'file'
|
|
|
+ file: { key: string, fileName: string, size: number }
|
|
|
+}
|
|
|
+// 解析出的文本片段
|
|
|
+interface ParsedTextChunk {
|
|
|
+ kind: 'text'
|
|
|
+ content: string
|
|
|
+}
|
|
|
+type ParsedChunk = ParsedTextChunk | ParsedFileChunk
|
|
|
+
|
|
|
+// 按 $$FILE_DOWNLOAD$$ 标记把文本切分为「文本 / 文件」有序片段。
|
|
|
+// 非法/残缺标记会被丢弃(既不产生文件片段,也不保留标记文本)。
|
|
|
+function splitByFileMarker(text: string): ParsedChunk[] {
|
|
|
+ const chunks: ParsedChunk[] = []
|
|
|
+ let lastIndex = 0
|
|
|
+ for (const m of text.matchAll(FILE_MARKER_RE)) {
|
|
|
+ const idx = m.index ?? 0
|
|
|
+ const before = text.slice(lastIndex, idx)
|
|
|
+ if (before) chunks.push({ kind: 'text', content: before })
|
|
|
+ try {
|
|
|
+ const fileData = JSON.parse(m[1])
|
|
|
+ chunks.push({ kind: 'file', file: { key: fileData.key, fileName: fileData.fileName, size: fileData.size } })
|
|
|
+ }
|
|
|
+ catch { /* ignore malformed / partial marker */ }
|
|
|
+ lastIndex = idx + m[0].length
|
|
|
+ }
|
|
|
+ const rest = text.slice(lastIndex)
|
|
|
+ if (rest) chunks.push({ kind: 'text', content: rest })
|
|
|
+ return chunks
|
|
|
+}
|
|
|
+
|
|
|
export function useAiChat() {
|
|
|
const messages = ref<ChatMessage[]>([])
|
|
|
const isStreaming = ref(false)
|
|
|
@@ -267,6 +303,8 @@ export function useAiChat() {
|
|
|
content: stripContextPrefix(payload.content, payload.role),
|
|
|
timestamp: Date.now() - 1000 + messageIndex,
|
|
|
}
|
|
|
+ // 解析历史消息中的下载标记,生成内联下载按钮
|
|
|
+ hydrateReplayMessage(chatMsg)
|
|
|
messages.value.push(chatMsg)
|
|
|
messageIndex++
|
|
|
}
|
|
|
@@ -313,6 +351,7 @@ export function useAiChat() {
|
|
|
content: stripContextPrefix(payload.content, payload.role),
|
|
|
timestamp: Date.now() - 1000 + messageIndex,
|
|
|
}
|
|
|
+ hydrateReplayMessage(chatMsg)
|
|
|
messages.value.push(chatMsg)
|
|
|
}
|
|
|
else if (eventType === 'done') {
|
|
|
@@ -548,6 +587,20 @@ export function useAiChat() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 将一段文本追加到 segments:末尾是文本片段则合并,否则新建文本片段。
|
|
|
+ // file/tool 片段会“打断”文本合并,从而让后续文本另起片段,保持先后顺序。
|
|
|
+ function appendTextSegment(aiMessage: ChatMessage, text: string) {
|
|
|
+ if (!text) return
|
|
|
+ if (!aiMessage.segments) aiMessage.segments = []
|
|
|
+ const last = aiMessage.segments.at(-1)
|
|
|
+ if (last && last.kind === 'text') {
|
|
|
+ last.content += text
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ aiMessage.segments.push({ kind: 'text', content: text })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
function parseSSEEvent(raw: string, aiMessage: ChatMessage) {
|
|
|
const lines = raw.split('\n')
|
|
|
let eventType = ''
|
|
|
@@ -569,31 +622,22 @@ export function useAiChat() {
|
|
|
switch (eventType || payload.type) {
|
|
|
case 'text': {
|
|
|
const text = payload.content || ''
|
|
|
- // 实时检测 $$FILE_DOWNLOAD:{...}$$ 标记
|
|
|
- const fileRegex = /\$\$FILE_DOWNLOAD:(.*?)\$\$/g
|
|
|
- let match
|
|
|
- let cleanText = text
|
|
|
- while ((match = fileRegex.exec(text)) !== null) {
|
|
|
- try {
|
|
|
- const fileData = JSON.parse(match[1])
|
|
|
- if (!aiMessage.files) aiMessage.files = []
|
|
|
- aiMessage.files.push({ key: fileData.key, fileName: fileData.fileName, size: fileData.size })
|
|
|
- }
|
|
|
- catch { /* ignore malformed */ }
|
|
|
- cleanText = cleanText.replace(match[0], '')
|
|
|
- }
|
|
|
- aiMessage.content += cleanText
|
|
|
- // 同步写入有序 segments:追加到末尾的文本片段,若末尾不是文本片段则新建
|
|
|
- if (cleanText) {
|
|
|
- if (!aiMessage.segments) aiMessage.segments = []
|
|
|
- const last = aiMessage.segments.at(-1)
|
|
|
- if (last && last.kind === 'text') {
|
|
|
- last.content += cleanText
|
|
|
+ // 按 $$FILE_DOWNLOAD:{...}$$ 标记位置切分文本,保持「文本 / 文件按钮」的先后顺序
|
|
|
+ if (!aiMessage.segments) aiMessage.segments = []
|
|
|
+ let cleanText = ''
|
|
|
+ for (const chunk of splitByFileMarker(text)) {
|
|
|
+ if (chunk.kind === 'text') {
|
|
|
+ cleanText += chunk.content
|
|
|
+ appendTextSegment(aiMessage, chunk.content)
|
|
|
}
|
|
|
else {
|
|
|
- aiMessage.segments.push({ kind: 'text', content: cleanText })
|
|
|
+ // 内联插入 file segment,同时写入 files 供旧渲染回退(按 key 去重)
|
|
|
+ if (!aiMessage.files) aiMessage.files = []
|
|
|
+ if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
|
|
|
+ aiMessage.segments.push({ kind: 'file', file: chunk.file })
|
|
|
}
|
|
|
}
|
|
|
+ aiMessage.content += cleanText
|
|
|
break
|
|
|
}
|
|
|
case 'tool_call':
|
|
|
@@ -681,16 +725,9 @@ export function useAiChat() {
|
|
|
case 'error': {
|
|
|
const errText = `\n\n[错误: ${payload.content || '未知'}]`
|
|
|
aiMessage.content += errText
|
|
|
- // 同步写入 segments:当本轮已产生 tool/text 片段时,渲染走 segments 分支,
|
|
|
+ // 同步写入 segments:当本轮已产生 tool/text/file 片段时,渲染走 segments 分支,
|
|
|
// 若不同步写入,错误信息将不会显示(只存在于 content 中)。
|
|
|
- if (!aiMessage.segments) aiMessage.segments = []
|
|
|
- const last = aiMessage.segments.at(-1)
|
|
|
- if (last && last.kind === 'text') {
|
|
|
- last.content += errText
|
|
|
- }
|
|
|
- else {
|
|
|
- aiMessage.segments.push({ kind: 'text', content: errText })
|
|
|
- }
|
|
|
+ appendTextSegment(aiMessage, errText)
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
@@ -706,36 +743,64 @@ export function useAiChat() {
|
|
|
return content.replace(/^\[context\] userId=.*?, sessionId=.*?\n\n/, '')
|
|
|
}
|
|
|
|
|
|
- // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记
|
|
|
+ // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记。
|
|
|
+ // 1) 从完整 content 提取所有文件,去重补进 files(保证按钮至少能显示);
|
|
|
+ // 2) 归一化 segments:合并相邻文本片段后按标记切分,重建内联的 file 片段,
|
|
|
+ // 同时保留 tool / 已有 file 片段的原始顺序作为锚点。
|
|
|
function extractFileDownloads(aiMessage: ChatMessage) {
|
|
|
- const fileRegex = /\$\$FILE_DOWNLOAD:(.*?)\$\$/g
|
|
|
- let match
|
|
|
- let hasMatch = false
|
|
|
- while ((match = fileRegex.exec(aiMessage.content)) !== null) {
|
|
|
- try {
|
|
|
- const fileData = JSON.parse(match[1])
|
|
|
- if (!aiMessage.files) aiMessage.files = []
|
|
|
- // 避免重复添加(实时匹配可能已添加)
|
|
|
- const exists = aiMessage.files.some(f => f.key === fileData.key)
|
|
|
- if (!exists) {
|
|
|
- aiMessage.files.push({ key: fileData.key, fileName: fileData.fileName, size: fileData.size })
|
|
|
- }
|
|
|
- hasMatch = true
|
|
|
+ // —— 1) content 层:提取 + 去重补进 files,并清除标记文本 ——
|
|
|
+ for (const chunk of splitByFileMarker(aiMessage.content)) {
|
|
|
+ if (chunk.kind !== 'file') continue
|
|
|
+ if (!aiMessage.files) aiMessage.files = []
|
|
|
+ if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
|
|
|
+ }
|
|
|
+ aiMessage.content = aiMessage.content.replace(FILE_MARKER_RE, '').trim()
|
|
|
+
|
|
|
+ // —— 2) segments 层:归一化重建 ——
|
|
|
+ if (!aiMessage.segments || aiMessage.segments.length === 0) return
|
|
|
+
|
|
|
+ const rebuilt: MessageSegment[] = []
|
|
|
+ let textBuf = ''
|
|
|
+
|
|
|
+ // 把累积的文本按标记切分后刷入 rebuilt(文本、内联 file 交错)
|
|
|
+ const flushText = () => {
|
|
|
+ if (!textBuf) return
|
|
|
+ for (const chunk of splitByFileMarker(textBuf)) {
|
|
|
+ rebuilt.push(chunk)
|
|
|
}
|
|
|
- catch { /* ignore */ }
|
|
|
- }
|
|
|
- // 清除内容中的标记文本
|
|
|
- if (hasMatch) {
|
|
|
- aiMessage.content = aiMessage.content.replace(/\$\$FILE_DOWNLOAD:.*?\$\$/g, '').trim()
|
|
|
- // 同步清除 segments 中文本片段的标记文本
|
|
|
- if (aiMessage.segments) {
|
|
|
- for (const seg of aiMessage.segments) {
|
|
|
- if (seg.kind === 'text') {
|
|
|
- seg.content = seg.content.replace(/\$\$FILE_DOWNLOAD:.*?\$\$/g, '')
|
|
|
- }
|
|
|
- }
|
|
|
+ textBuf = ''
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const seg of aiMessage.segments) {
|
|
|
+ if (seg.kind === 'text') {
|
|
|
+ // 累积相邻文本,使跨片段的标记残片能拼接成完整标记
|
|
|
+ textBuf += seg.content
|
|
|
+ }
|
|
|
+ else {
|
|
|
+ // tool / 已有 file 片段:作为锚点,刷出前面的文本后原样保留
|
|
|
+ flushText()
|
|
|
+ rebuilt.push(seg)
|
|
|
}
|
|
|
}
|
|
|
+ flushText()
|
|
|
+
|
|
|
+ // 去除重复的 file 片段(同一 key 只保留首次出现)
|
|
|
+ const seenKeys = new Set<string>()
|
|
|
+ aiMessage.segments = rebuilt.filter((seg) => {
|
|
|
+ if (seg.kind !== 'file') return true
|
|
|
+ if (seenKeys.has(seg.file.key)) return false
|
|
|
+ seenKeys.add(seg.file.key)
|
|
|
+ return true
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 会话回放:为一条 assistant 历史消息解析 $$FILE_DOWNLOAD$$ 标记,
|
|
|
+ // 生成内联 file 片段(复用 extractFileDownloads 的归一化逻辑)。
|
|
|
+ // 历史消息文本一次到位,先整体作为一个文本片段,再交给归一化切分。
|
|
|
+ function hydrateReplayMessage(chatMsg: ChatMessage) {
|
|
|
+ if (chatMsg.role !== 'assistant' || !chatMsg.content) return
|
|
|
+ chatMsg.segments = [{ kind: 'text', content: chatMsg.content }]
|
|
|
+ extractFileDownloads(chatMsg)
|
|
|
}
|
|
|
|
|
|
async function resolvePermission(optionId: string) {
|