mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-23 06:51:10 +08:00
feat: 追加思考内容块
This commit is contained in:
+257
-78
@@ -10,23 +10,37 @@ import type { ToolContext, OwnerInfo } from './tools/types'
|
||||
import { aiLogger } from './logger'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
// 思考类标签列表(可按需扩展)
|
||||
const THINK_TAGS = ['think', 'analysis', 'reasoning', 'reflection', 'thought', 'thinking']
|
||||
const THINK_START_TAGS = THINK_TAGS.map((tag) => `<${tag}>`)
|
||||
const TOOL_CALL_START_TAG = '<tool_call>'
|
||||
const TOOL_CALL_END_TAG = '</tool_call>'
|
||||
|
||||
// ==================== Fallback 解析器 ====================
|
||||
|
||||
/**
|
||||
* 从文本内容中提取 <think> 标签内容
|
||||
* 从文本内容中提取思考类标签内容
|
||||
*/
|
||||
function extractThinkingContent(content: string): { thinking: string; cleanContent: string } {
|
||||
const thinkRegex = /<think>([\s\S]*?)<\/think>/gi
|
||||
let thinking = ''
|
||||
if (!content) {
|
||||
return { thinking: '', cleanContent: '' }
|
||||
}
|
||||
|
||||
const tagPattern = THINK_TAGS.join('|')
|
||||
const thinkRegex = new RegExp(`<(${tagPattern})>([\\s\\S]*?)<\\/\\1>`, 'gi')
|
||||
const thinkingParts: string[] = []
|
||||
let cleanContent = content
|
||||
|
||||
const matches = content.matchAll(thinkRegex)
|
||||
for (const match of matches) {
|
||||
thinking += match[1].trim() + '\n'
|
||||
const thinkText = match[2].trim()
|
||||
if (thinkText) {
|
||||
thinkingParts.push(thinkText)
|
||||
}
|
||||
cleanContent = cleanContent.replace(match[0], '')
|
||||
}
|
||||
|
||||
return { thinking: thinking.trim(), cleanContent: cleanContent.trim() }
|
||||
return { thinking: thinkingParts.join('\n').trim(), cleanContent: cleanContent.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +81,159 @@ function hasToolCallTags(content: string): boolean {
|
||||
return /<tool_call>/i.test(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 <tool_call> 标签内容,避免将工具调用文本展示给用户
|
||||
*/
|
||||
function stripToolCallTags(content: string): string {
|
||||
return content.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '').trim()
|
||||
}
|
||||
|
||||
type StreamMode = 'text' | 'think' | 'tool_call'
|
||||
|
||||
/**
|
||||
* 创建流式解析器:将文本按 content/think/tool_call 分流
|
||||
*/
|
||||
function createStreamParser(handlers: {
|
||||
onText: (text: string) => void
|
||||
onThink: (text: string, tag: string) => void
|
||||
}): { push: (text: string) => void; flush: () => void } {
|
||||
let buffer = ''
|
||||
let mode: StreamMode = 'text'
|
||||
let currentThinkTag = ''
|
||||
|
||||
const startTags = [...THINK_START_TAGS, TOOL_CALL_START_TAG]
|
||||
const startTagsLower = startTags.map((tag) => tag.toLowerCase())
|
||||
const toolCallStartLower = TOOL_CALL_START_TAG.toLowerCase()
|
||||
const toolCallEndLower = TOOL_CALL_END_TAG.toLowerCase()
|
||||
const maxStartTagLength = Math.max(...startTags.map((tag) => tag.length))
|
||||
|
||||
const findNextTagIndex = (lowerBuffer: string): { index: number; tag: string } | null => {
|
||||
let hitIndex = -1
|
||||
let hitTag = ''
|
||||
for (const tag of startTagsLower) {
|
||||
const index = lowerBuffer.indexOf(tag)
|
||||
if (index !== -1 && (hitIndex === -1 || index < hitIndex)) {
|
||||
hitIndex = index
|
||||
hitTag = tag
|
||||
}
|
||||
}
|
||||
return hitIndex === -1 ? null : { index: hitIndex, tag: hitTag }
|
||||
}
|
||||
|
||||
const emitText = (text: string) => {
|
||||
if (text) {
|
||||
handlers.onText(text)
|
||||
}
|
||||
}
|
||||
|
||||
const emitThink = (text: string) => {
|
||||
if (text) {
|
||||
handlers.onThink(text, currentThinkTag || 'think')
|
||||
}
|
||||
}
|
||||
|
||||
const processBuffer = () => {
|
||||
let safety = 0
|
||||
while (buffer && safety < 10000) {
|
||||
safety += 1
|
||||
if (mode === 'text') {
|
||||
const lowerBuffer = buffer.toLowerCase()
|
||||
const hit = findNextTagIndex(lowerBuffer)
|
||||
if (!hit) {
|
||||
// 保留一段尾部,避免标签被截断
|
||||
const keepLength = Math.max(1, maxStartTagLength - 1)
|
||||
if (buffer.length > keepLength) {
|
||||
emitText(buffer.slice(0, buffer.length - keepLength))
|
||||
buffer = buffer.slice(buffer.length - keepLength)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (hit.index > 0) {
|
||||
emitText(buffer.slice(0, hit.index))
|
||||
buffer = buffer.slice(hit.index)
|
||||
}
|
||||
|
||||
const lowerHead = buffer.toLowerCase()
|
||||
if (lowerHead.startsWith(hit.tag)) {
|
||||
if (hit.tag === toolCallStartLower) {
|
||||
mode = 'tool_call'
|
||||
buffer = buffer.slice(TOOL_CALL_START_TAG.length)
|
||||
continue
|
||||
}
|
||||
|
||||
// 进入思考模式
|
||||
currentThinkTag = hit.tag.slice(1, -1)
|
||||
mode = 'think'
|
||||
buffer = buffer.slice(startTags[startTagsLower.indexOf(hit.tag)].length)
|
||||
continue
|
||||
}
|
||||
|
||||
// 未识别的 < 视为普通文本
|
||||
emitText(buffer.slice(0, 1))
|
||||
buffer = buffer.slice(1)
|
||||
continue
|
||||
}
|
||||
|
||||
if (mode === 'think') {
|
||||
const endTag = `</${currentThinkTag}>`
|
||||
const lowerBuffer = buffer.toLowerCase()
|
||||
const endIndex = lowerBuffer.indexOf(endTag)
|
||||
if (endIndex === -1) {
|
||||
const keepLength = Math.max(1, endTag.length - 1)
|
||||
if (buffer.length > keepLength) {
|
||||
emitThink(buffer.slice(0, buffer.length - keepLength))
|
||||
buffer = buffer.slice(buffer.length - keepLength)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
emitThink(buffer.slice(0, endIndex))
|
||||
}
|
||||
|
||||
buffer = buffer.slice(endIndex + endTag.length)
|
||||
mode = 'text'
|
||||
currentThinkTag = ''
|
||||
continue
|
||||
}
|
||||
|
||||
if (mode === 'tool_call') {
|
||||
const lowerBuffer = buffer.toLowerCase()
|
||||
const endIndex = lowerBuffer.indexOf(toolCallEndLower)
|
||||
if (endIndex === -1) {
|
||||
const keepLength = Math.max(1, TOOL_CALL_END_TAG.length - 1)
|
||||
if (buffer.length > keepLength) {
|
||||
buffer = buffer.slice(buffer.length - keepLength)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
buffer = buffer.slice(endIndex + TOOL_CALL_END_TAG.length)
|
||||
mode = 'text'
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push(text: string) {
|
||||
if (!text) return
|
||||
buffer += text
|
||||
processBuffer()
|
||||
},
|
||||
flush() {
|
||||
if (!buffer) return
|
||||
if (mode === 'text') {
|
||||
emitText(buffer)
|
||||
} else if (mode === 'think') {
|
||||
emitThink(buffer)
|
||||
}
|
||||
buffer = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 配置
|
||||
*/
|
||||
@@ -93,9 +260,11 @@ export interface TokenUsage {
|
||||
*/
|
||||
export interface AgentStreamChunk {
|
||||
/** chunk 类型 */
|
||||
type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error'
|
||||
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'done' | 'error'
|
||||
/** 文本内容(type=content 时) */
|
||||
content?: string
|
||||
/** 思考标签名称(type=think 时) */
|
||||
thinkTag?: string
|
||||
/** 工具名称(type=tool_start/tool_result 时) */
|
||||
toolName?: string
|
||||
/** 工具调用参数(type=tool_start 时) */
|
||||
@@ -411,24 +580,23 @@ export class Agent {
|
||||
// 累加 Token 使用量
|
||||
this.addUsage(response.usage)
|
||||
|
||||
const { cleanContent } = extractThinkingContent(response.content)
|
||||
let toolCallsToProcess = response.tool_calls
|
||||
|
||||
// 如果没有标准 tool_calls,尝试 fallback 解析
|
||||
if (response.finishReason !== 'tool_calls' || !response.tool_calls) {
|
||||
// Fallback: 检查内容中是否有 <tool_call> 标签
|
||||
if (hasToolCallTags(response.content)) {
|
||||
// 提取 thinking 内容
|
||||
const { cleanContent } = extractThinkingContent(response.content)
|
||||
|
||||
// 解析 tool_call 标签
|
||||
const fallbackToolCalls = parseToolCallTags(response.content)
|
||||
if (fallbackToolCalls && fallbackToolCalls.length > 0) {
|
||||
toolCallsToProcess = fallbackToolCalls
|
||||
} else {
|
||||
// 解析失败,返回清理后的内容
|
||||
aiLogger.info('Agent', 'AI 回复', cleanContent)
|
||||
const sanitizedContent = stripToolCallTags(cleanContent)
|
||||
aiLogger.info('Agent', 'AI 回复', sanitizedContent)
|
||||
return {
|
||||
content: cleanContent,
|
||||
content: sanitizedContent,
|
||||
toolsUsed: this.toolsUsed,
|
||||
toolRounds: this.toolRounds,
|
||||
totalUsage: this.totalUsage,
|
||||
@@ -436,9 +604,9 @@ export class Agent {
|
||||
}
|
||||
} else {
|
||||
// 没有 tool_call 标签,正常完成
|
||||
aiLogger.info('Agent', 'AI 回复', response.content)
|
||||
aiLogger.info('Agent', 'AI 回复', cleanContent)
|
||||
return {
|
||||
content: response.content,
|
||||
content: cleanContent,
|
||||
toolsUsed: this.toolsUsed,
|
||||
toolRounds: this.toolRounds,
|
||||
totalUsage: this.totalUsage,
|
||||
@@ -460,8 +628,9 @@ export class Agent {
|
||||
|
||||
const finalResponse = await chat(this.messages, this.config.llmOptions)
|
||||
this.addUsage(finalResponse.usage)
|
||||
const finalCleanContent = stripToolCallTags(extractThinkingContent(finalResponse.content).cleanContent)
|
||||
return {
|
||||
content: finalResponse.content,
|
||||
content: finalCleanContent,
|
||||
toolsUsed: this.toolsUsed,
|
||||
toolRounds: this.toolRounds,
|
||||
totalUsage: this.totalUsage,
|
||||
@@ -509,10 +678,17 @@ export class Agent {
|
||||
}
|
||||
|
||||
let accumulatedContent = ''
|
||||
let displayedContent = '' // 已发送给前端的内容
|
||||
let roundContent = ''
|
||||
let toolCalls: ToolCall[] | undefined
|
||||
let isBufferingToolCall = false // 是否正在缓冲 tool_call 内容
|
||||
let isBufferingThink = false // 是否正在缓冲 <think> 内容
|
||||
const parser = createStreamParser({
|
||||
onText: (text) => {
|
||||
roundContent += text
|
||||
onChunk({ type: 'content', content: text })
|
||||
},
|
||||
onThink: (text, tag) => {
|
||||
onChunk({ type: 'think', content: text, thinkTag: tag })
|
||||
},
|
||||
})
|
||||
|
||||
// 流式调用 LLM(传入 abortSignal)
|
||||
for await (const chunk of chatStream(this.messages, {
|
||||
@@ -524,7 +700,7 @@ export class Agent {
|
||||
if (this.isAborted()) {
|
||||
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
|
||||
return {
|
||||
content: finalContent + accumulatedContent,
|
||||
content: finalContent + roundContent,
|
||||
toolsUsed: this.toolsUsed,
|
||||
toolRounds: this.toolRounds,
|
||||
totalUsage: this.totalUsage,
|
||||
@@ -532,57 +708,8 @@ export class Agent {
|
||||
}
|
||||
if (chunk.content) {
|
||||
accumulatedContent += chunk.content
|
||||
|
||||
// 检测是否开始出现 <think> 标签(过滤思考内容)
|
||||
if (!isBufferingThink && /<think>/i.test(accumulatedContent)) {
|
||||
isBufferingThink = true
|
||||
// 发送 <think> 标签之前的内容
|
||||
const thinkStart = accumulatedContent.toLowerCase().indexOf('<think>')
|
||||
if (thinkStart > displayedContent.length) {
|
||||
const newContent = accumulatedContent.slice(displayedContent.length, thinkStart)
|
||||
if (newContent) {
|
||||
onChunk({ type: 'content', content: newContent })
|
||||
displayedContent = accumulatedContent.slice(0, thinkStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 </think> 结束标签,退出思考缓冲模式
|
||||
if (isBufferingThink && /<\/think>/i.test(accumulatedContent)) {
|
||||
isBufferingThink = false
|
||||
// 跳过 <think>...</think> 内容,更新 displayedContent
|
||||
const thinkEnd = accumulatedContent.toLowerCase().indexOf('</think>') + '</think>'.length
|
||||
displayedContent = accumulatedContent.slice(0, thinkEnd)
|
||||
}
|
||||
|
||||
// 如果正在缓冲思考内容,不发送
|
||||
if (isBufferingThink) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检测是否开始出现 <tool_call> 标签(用于 fallback 解析)
|
||||
if (!isBufferingToolCall) {
|
||||
if (/<tool_call>/i.test(accumulatedContent)) {
|
||||
isBufferingToolCall = true
|
||||
// 发送标签之前的内容(如果有)
|
||||
const tagStart = accumulatedContent.indexOf('<tool_call>')
|
||||
if (tagStart > displayedContent.length) {
|
||||
const newContent = accumulatedContent.slice(displayedContent.length, tagStart)
|
||||
if (newContent) {
|
||||
onChunk({ type: 'content', content: newContent })
|
||||
displayedContent = accumulatedContent.slice(0, tagStart)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 正常发送内容(但要排除已发送的部分)
|
||||
const newContent = accumulatedContent.slice(displayedContent.length)
|
||||
if (newContent) {
|
||||
onChunk({ type: 'content', content: newContent })
|
||||
displayedContent = accumulatedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果已经在缓冲模式,不发送内容
|
||||
// 按标签切分后输出到内容/思考区
|
||||
parser.push(chunk.content)
|
||||
}
|
||||
|
||||
if (chunk.tool_calls) {
|
||||
@@ -595,6 +722,9 @@ export class Agent {
|
||||
}
|
||||
|
||||
if (chunk.isFinished) {
|
||||
// 收尾:清空解析器缓冲
|
||||
parser.flush()
|
||||
|
||||
// 如果没有标准 tool_calls,尝试 fallback 解析
|
||||
if (chunk.finishReason !== 'tool_calls' || !toolCalls) {
|
||||
// Fallback: 检查内容中是否有 <tool_call> 标签
|
||||
@@ -606,16 +736,22 @@ export class Agent {
|
||||
const fallbackToolCalls = parseToolCallTags(accumulatedContent)
|
||||
if (fallbackToolCalls && fallbackToolCalls.length > 0) {
|
||||
toolCalls = fallbackToolCalls
|
||||
// 更新累积内容为清理后的内容(移除 think 和 tool_call 标签)
|
||||
accumulatedContent = cleanContent.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '').trim()
|
||||
// 不返回,继续执行工具调用
|
||||
} else {
|
||||
// 解析失败,作为普通响应处理
|
||||
const remainingContent = cleanContent.slice(displayedContent.length)
|
||||
if (remainingContent) {
|
||||
onChunk({ type: 'content', content: remainingContent })
|
||||
const sanitizedContent = stripToolCallTags(cleanContent)
|
||||
if (sanitizedContent.startsWith(roundContent)) {
|
||||
const remainingContent = sanitizedContent.slice(roundContent.length)
|
||||
if (remainingContent) {
|
||||
onChunk({ type: 'content', content: remainingContent })
|
||||
}
|
||||
} else if (sanitizedContent) {
|
||||
aiLogger.warn('Agent', '流式内容与清理结果不一致,跳过补发', {
|
||||
roundContentLength: roundContent.length,
|
||||
sanitizedLength: sanitizedContent.length,
|
||||
})
|
||||
}
|
||||
finalContent = cleanContent
|
||||
finalContent = sanitizedContent
|
||||
aiLogger.info('Agent', 'AI 回复', finalContent)
|
||||
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
|
||||
return {
|
||||
@@ -627,7 +763,19 @@ export class Agent {
|
||||
}
|
||||
} else {
|
||||
// 没有 tool_call 标签,正常完成
|
||||
finalContent = extractThinkingContent(accumulatedContent).cleanContent
|
||||
const sanitizedContent = stripToolCallTags(extractThinkingContent(accumulatedContent).cleanContent)
|
||||
if (sanitizedContent.startsWith(roundContent)) {
|
||||
const remainingContent = sanitizedContent.slice(roundContent.length)
|
||||
if (remainingContent) {
|
||||
onChunk({ type: 'content', content: remainingContent })
|
||||
}
|
||||
} else if (sanitizedContent) {
|
||||
aiLogger.warn('Agent', '流式内容与清理结果不一致,跳过补发', {
|
||||
roundContentLength: roundContent.length,
|
||||
sanitizedLength: sanitizedContent.length,
|
||||
})
|
||||
}
|
||||
finalContent = sanitizedContent
|
||||
aiLogger.info('Agent', 'AI 回复', finalContent)
|
||||
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
|
||||
return {
|
||||
@@ -641,6 +789,9 @@ export class Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底收尾:防止未收到 isFinished
|
||||
parser.flush()
|
||||
|
||||
// 处理工具调用
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
// 通知前端开始执行工具(包含参数和时间范围)
|
||||
@@ -699,6 +850,16 @@ export class Agent {
|
||||
})
|
||||
|
||||
// 最后一轮不带 tools(传入 abortSignal)
|
||||
let finalRawContent = ''
|
||||
const finalParser = createStreamParser({
|
||||
onText: (text) => {
|
||||
finalContent += text
|
||||
onChunk({ type: 'content', content: text })
|
||||
},
|
||||
onThink: (text, tag) => {
|
||||
onChunk({ type: 'think', content: text, thinkTag: tag })
|
||||
},
|
||||
})
|
||||
for await (const chunk of chatStream(this.messages, {
|
||||
...this.config.llmOptions,
|
||||
abortSignal: this.abortSignal,
|
||||
@@ -708,18 +869,36 @@ export class Agent {
|
||||
break
|
||||
}
|
||||
if (chunk.content) {
|
||||
finalContent += chunk.content
|
||||
onChunk({ type: 'content', content: chunk.content })
|
||||
finalRawContent += chunk.content
|
||||
finalParser.push(chunk.content)
|
||||
}
|
||||
// 累加 Token 使用量
|
||||
if (chunk.usage) {
|
||||
this.addUsage(chunk.usage)
|
||||
}
|
||||
if (chunk.isFinished) {
|
||||
finalParser.flush()
|
||||
const sanitizedContent = stripToolCallTags(extractThinkingContent(finalRawContent).cleanContent)
|
||||
if (sanitizedContent.startsWith(finalContent)) {
|
||||
const remainingContent = sanitizedContent.slice(finalContent.length)
|
||||
if (remainingContent) {
|
||||
finalContent += remainingContent
|
||||
onChunk({ type: 'content', content: remainingContent })
|
||||
}
|
||||
} else if (sanitizedContent) {
|
||||
aiLogger.warn('Agent', '最终内容与清理结果不一致,跳过补发', {
|
||||
finalContentLength: finalContent.length,
|
||||
sanitizedLength: sanitizedContent.length,
|
||||
})
|
||||
finalContent = sanitizedContent
|
||||
}
|
||||
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底收尾:防止未收到 isFinished
|
||||
finalParser.flush()
|
||||
|
||||
return {
|
||||
content: finalContent,
|
||||
toolsUsed: this.toolsUsed,
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface AIConversation {
|
||||
*/
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'think'; tag: string; text: string } // 思考内容块
|
||||
| {
|
||||
type: 'tool'
|
||||
tool: {
|
||||
@@ -323,4 +324,3 @@ export function deleteMessage(messageId: string): boolean {
|
||||
const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -403,8 +403,9 @@ interface TokenUsage {
|
||||
|
||||
// Agent 相关类型
|
||||
interface AgentStreamChunk {
|
||||
type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error'
|
||||
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'done' | 'error'
|
||||
content?: string
|
||||
thinkTag?: string
|
||||
toolName?: string
|
||||
toolParams?: Record<string, unknown>
|
||||
toolResult?: unknown
|
||||
|
||||
@@ -772,8 +772,9 @@ interface ChatStreamChunk {
|
||||
|
||||
// Agent API 类型定义
|
||||
interface AgentStreamChunk {
|
||||
type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error'
|
||||
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'done' | 'error'
|
||||
content?: string
|
||||
thinkTag?: string
|
||||
toolName?: string
|
||||
toolParams?: Record<string, unknown>
|
||||
toolResult?: unknown
|
||||
|
||||
@@ -43,6 +43,18 @@ function renderMarkdown(text: string): string {
|
||||
return md.render(text)
|
||||
}
|
||||
|
||||
// 思考标签名称映射
|
||||
function getThinkLabel(tag: string): string {
|
||||
const normalized = tag?.toLowerCase() || 'think'
|
||||
if (normalized === 'analysis') return t('think.labels.analysis')
|
||||
if (normalized === 'reasoning') return t('think.labels.reasoning')
|
||||
if (normalized === 'reflection') return t('think.labels.reflection')
|
||||
if (normalized === 'think' || normalized === 'thought' || normalized === 'thinking') {
|
||||
return t('think.labels.think')
|
||||
}
|
||||
return t('think.labels.other', { tag })
|
||||
}
|
||||
|
||||
// 渲染后的 HTML(用于用户消息或纯文本 AI 消息)
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return ''
|
||||
@@ -231,6 +243,19 @@ function formatToolParams(tool: ToolBlockContent): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 思考块(默认折叠) -->
|
||||
<details
|
||||
v-else-if="block.type === 'think'"
|
||||
class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200"
|
||||
>
|
||||
<summary class="cursor-pointer select-none text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ getThinkLabel(block.tag) }}
|
||||
</summary>
|
||||
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none leading-relaxed">
|
||||
<div v-html="renderMarkdown(block.text)" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 工具块 -->
|
||||
<div
|
||||
v-else-if="block.type === 'tool'"
|
||||
@@ -326,6 +351,15 @@ function formatToolParams(tool: ToolBlockContent): string {
|
||||
"calling": "调用",
|
||||
"generating": "正在生成回复..."
|
||||
},
|
||||
"think": {
|
||||
"labels": {
|
||||
"think": "思考",
|
||||
"analysis": "分析",
|
||||
"reasoning": "推理",
|
||||
"reflection": "反思",
|
||||
"other": "思考({tag})"
|
||||
}
|
||||
},
|
||||
"toolParams": {
|
||||
"keywords": "关键词",
|
||||
"time": "时间",
|
||||
@@ -350,6 +384,15 @@ function formatToolParams(tool: ToolBlockContent): string {
|
||||
"calling": "Calling",
|
||||
"generating": "Generating response..."
|
||||
},
|
||||
"think": {
|
||||
"labels": {
|
||||
"think": "Thinking",
|
||||
"analysis": "Analysis",
|
||||
"reasoning": "Reasoning",
|
||||
"reflection": "Reflection",
|
||||
"other": "Thinking ({tag})"
|
||||
}
|
||||
},
|
||||
"toolParams": {
|
||||
"keywords": "Keywords",
|
||||
"time": "Time",
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ToolBlockContent {
|
||||
// 内容块类型(用于 AI 消息的流式混合渲染)
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'think'; tag: string; text: string } // 思考内容块
|
||||
| {
|
||||
type: 'tool'
|
||||
tool: ToolBlockContent
|
||||
@@ -278,6 +279,21 @@ export function useAIChat(
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数:追加思考块(单独渲染,不写入 content)
|
||||
const appendThinkToBlocks = (text: string, tag?: string) => {
|
||||
const blocks = messages.value[aiMessageIndex].contentBlocks || []
|
||||
const thinkTag = tag || 'think'
|
||||
const lastBlock = blocks[blocks.length - 1]
|
||||
|
||||
if (lastBlock && lastBlock.type === 'think' && lastBlock.tag === thinkTag) {
|
||||
lastBlock.text += text
|
||||
} else {
|
||||
blocks.push({ type: 'think', tag: thinkTag, text })
|
||||
}
|
||||
|
||||
updateAIMessage({ contentBlocks: [...blocks] })
|
||||
}
|
||||
|
||||
// 辅助函数:添加工具块
|
||||
const addToolBlock = (toolName: string, params?: Record<string, unknown>) => {
|
||||
const blocks = messages.value[aiMessageIndex].contentBlocks || []
|
||||
@@ -377,6 +393,13 @@ export function useAIChat(
|
||||
}
|
||||
break
|
||||
|
||||
case 'think':
|
||||
// 思考内容 - 写入思考块
|
||||
if (chunk.content) {
|
||||
appendThinkToBlocks(chunk.content, chunk.thinkTag)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_start':
|
||||
// 工具开始执行 - 添加工具块到 contentBlocks
|
||||
console.log('[AI] 工具开始执行:', chunk.toolName, chunk.toolParams)
|
||||
|
||||
Reference in New Issue
Block a user