jiaxing.liao недель назад: 3
Родитель
Сommit
8ca7daa205

+ 6 - 0
apps/web/components.d.ts

@@ -12,6 +12,7 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    ChatInput: typeof import('./src/components/Chat/ChatInput.vue')['default']
     CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAside: typeof import('element-plus/es')['ElAside']
@@ -75,6 +76,8 @@ declare module 'vue' {
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
+    MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
+    MessageList: typeof import('./src/components/Chat/MessageList.vue')['default']
     RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
@@ -91,6 +94,7 @@ declare module 'vue' {
 
 // For TSX support
 declare global {
+  const ChatInput: typeof import('./src/components/Chat/ChatInput.vue')['default']
   const CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
   const ElAlert: typeof import('element-plus/es')['ElAlert']
   const ElAside: typeof import('element-plus/es')['ElAside']
@@ -154,6 +158,8 @@ declare global {
   const ElTooltip: typeof import('element-plus/es')['ElTooltip']
   const ElUpload: typeof import('element-plus/es')['ElUpload']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
+  const MarkdownEditor: typeof import('./src/components/MarkdownEditor/index.vue')['default']
+  const MessageList: typeof import('./src/components/Chat/MessageList.vue')['default']
   const RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
   const RouterLink: typeof import('vue-router')['RouterLink']
   const RouterView: typeof import('vue-router')['RouterView']

+ 1 - 0
apps/web/package.json

@@ -9,6 +9,7 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@bytemd/vue-next": "^1.22.0",
     "@element-plus/icons-vue": "^2.3.2",
     "@tinymce/tinymce-vue": "^6.3.0",
     "@vitejs/plugin-vue-jsx": "^5.1.3",

+ 4 - 26
apps/web/src/views/chat/components/ChatInput.vue

@@ -3,25 +3,10 @@
 		<Sender v-model="modelValue" variant="updown" submitType='cmdOrCtrlEnter' :auto-size="{ minRows: 2, maxRows: 5 }"
 			clearable allow-speech :placeholder="t('pages.chat.senderPlaceholder')" @submit="emit('submit', modelValue!)"
 			:loading="loading" @cancel="emit('cancel')">
+			<!-- 插槽内容 -->
 			<template #prefix>
 				<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
 					<slot name="prefix-extra" />
-
-					<!-- <div :class="{ isSelect: isDeep }" style="
-							display: flex;
-							align-items: center;
-							gap: 4px;
-							padding: 2px 12px;
-							border: 1px solid silver;
-							border-radius: 15px;
-							cursor: pointer;
-							font-size: 12px;
-						" @click="isDeep = !isDeep">
-						<el-icon>
-							<ElementPlus />
-						</el-icon>
-						<span>深度思考</span>
-					</div> -->
 				</div>
 			</template>
 
@@ -39,14 +24,8 @@
 			<template #footer>
 				<div v-if="attachments.length" class="attachment-preview">
 					<div v-for="file in attachments" :key="file.id" class="attachment-preview__item">
-						<el-image
-							:src="getImageSrc(file)"
-							class="attachment-preview__image"
-							fit="cover"
-							preview-teleported
-							hide-on-click-modal
-							:preview-src-list="attachments.map(getImageSrc)"
-						/>
+						<el-image :src="getImageSrc(file)" class="attachment-preview__image" fit="cover" preview-teleported
+							hide-on-click-modal :preview-src-list="attachments.map(getImageSrc)" />
 					</div>
 				</div>
 			</template>
@@ -58,7 +37,7 @@
 import { computed, ref } from 'vue'
 import { Sender } from 'vue-element-plus-x'
 import { useI18n } from '@/composables/useI18n'
-import { ElementPlus, Promotion } from '@element-plus/icons-vue'
+import { Promotion } from '@element-plus/icons-vue'
 import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
 
 const { t } = useI18n()
@@ -80,7 +59,6 @@ const emit = defineEmits<{
 const getImageSrc = (file: WorkflowUploadFile) => `/File/GetImage?fileId=${file.path || file.id}`
 
 const attachments = computed(() => props.attachments)
-const isDeep = ref(false)
 </script>
 
 <style lang="less" scoped>

+ 509 - 0
apps/web/src/components/Chat/MessageList.vue

@@ -0,0 +1,509 @@
+<template>
+	<div class="chat-content">
+		<div v-if="messages.length > 0" ref="scrollContainerRef" class="item-list">
+			<Bubble v-for="item in messages" :key="item.id || item.key" :content="item.content" :placement="item.placement"
+				:loading="item.loading" :shape="item.shape" :variant="item.variant" :is-markdown="item.isMarkdown"
+				:is-fog="item.isFog" :typing="item.typing" class="message-bubble" maxWidth="800px">
+				<template #avatar>
+					<div class="avatar-wrapper">
+						<Icon v-if="item.role === 'ai'" icon="mingcute:ai-line" :width="22" />
+						<Icon v-if="item.role === 'user'" icon="ri:user-line" :width="22" />
+					</div>
+				</template>
+
+				<template #header>
+					<div class="header-wrapper">
+						<div class="header-name">
+						</div>
+					</div>
+				</template>
+
+				<template #content>
+					<div class="msg-content-wrapper">
+						<div v-if="item.role === 'ai' && hasStructuredBlocks(item)" class="structured-blocks">
+							<Thinking v-if="shouldShowThinking(item)" :content="getThinkingText(item)"
+								:status="getThinkingStatus(item)" :model-value="!item.streamCompleted" auto-collapse max-width="100%"
+								background-color="var(--bg-container)" color="var(--text-primary)" />
+
+							<div v-if="getToolCalls(item).length" class="structured-block">
+								<div class="structured-block__head">
+									<span class="structured-block__title">{{ t('pages.chat.toolCallTitle') }}</span>
+									<el-tag size="small" type="warning" effect="plain">{{ getToolCalls(item).length }}</el-tag>
+								</div>
+								<div class="tool-list">
+									<div v-for="(toolCall, index) in getToolCalls(item)"
+										:key="`${item.id || item.key}-tool-call-${index}`" class="tool-card">
+										<div class="tool-card__head">
+											<div class="tool-card__meta">
+												<el-tag size="small" type="warning" effect="dark">
+													{{ getToolName(toolCall.toolName) }}
+												</el-tag>
+												<span v-if="toolCall.toolCallId" class="tool-card__id">{{ toolCall.toolCallId }}</span>
+											</div>
+										</div>
+										<pre
+											class="tool-card__content">{{ formatStructuredValue(toolCall.arguments || toolCall.content) }}</pre>
+									</div>
+								</div>
+							</div>
+
+							<div v-if="getToolResults(item).length" class="structured-block">
+								<div class="structured-block__head">
+									<span class="structured-block__title">{{ t('pages.chat.toolResultTitle') }}</span>
+									<el-tag size="small" type="success" effect="plain">{{ getToolResults(item).length }}</el-tag>
+								</div>
+								<div class="tool-list">
+									<div v-for="(toolResult, index) in getToolResults(item)"
+										:key="`${item.id || item.key}-tool-result-${index}`" class="tool-card">
+										<div class="tool-card__head">
+											<div class="tool-card__meta">
+												<el-tag :type="toolResult.success === false ? 'danger' : 'success'" size="small" effect="dark">
+													{{ toolResult.success === false ? t('pages.chat.failedTag') : 'OK' }}
+												</el-tag>
+												<span class="tool-card__name">{{ getToolName(toolResult.toolName) }}</span>
+												<span v-if="toolResult.toolCallId" class="tool-card__id">{{ toolResult.toolCallId }}</span>
+											</div>
+											<div v-if="toolResult.durationMs !== undefined" class="tool-card__duration">
+												{{ toolResult.durationMs }}ms
+											</div>
+										</div>
+										<div v-if="hasToolResultThought(toolResult)" class="tool-card__section">
+											<div class="tool-card__label">{{ t('pages.chat.thinkTitle') }}</div>
+											<pre class="tool-card__content">{{ getToolResultThought(toolResult) }}</pre>
+										</div>
+										<div v-if="hasToolResultOutput(toolResult)" class="tool-card__section">
+											<div class="tool-card__label">{{ t('pages.chat.toolResultTitle') }}</div>
+											<pre class="tool-card__content">{{ getToolResultOutput(toolResult) }}</pre>
+										</div>
+										<div v-if="hasToolResultItems(toolResult)" class="tool-card__section">
+											<div class="tool-card__label">content_items</div>
+											<pre class="tool-card__content">{{ formatStructuredValue(getToolResultItems(toolResult)) }}</pre>
+										</div>
+										<div v-if="toolResult.error" class="tool-card__section">
+											<div class="tool-card__label">{{ t('pages.chat.failedTag') }}</div>
+											<pre class="tool-card__content tool-card__content--error">{{ toolResult.error }}</pre>
+										</div>
+									</div>
+								</div>
+							</div>
+						</div>
+
+						<XMarkdown v-if="item.content" class="msg-content-text" :markdown="item.content" enableLatex enableBreaks />
+					</div>
+				</template>
+
+				<template #footer>
+					<div v-if="getMessageImages(item).length" class="file-wrap">
+						<el-image v-for="(src, index) in getMessageImages(item)" :key="`${item.id || item.key}-file-${index}`"
+							:src="src" class="file-image" fit="cover" preview-teleported hide-on-click-modal
+							:preview-src-list="getMessageImages(item)" />
+					</div>
+					<div class="footer-wrapper" v-if="item.role === 'ai' && item.streamCompleted">
+						<div class="footer-container">
+							<el-button type="info" :icon="Refresh" size="small" circle @click="handleRetry(item)" />
+							<el-button color="#626aef" :icon="DocumentCopy" size="small" circle @click="handleCopy(item)" />
+						</div>
+						<div class="footer-time">
+							{{ item.updateTime }}
+						</div>
+					</div>
+				</template>
+			</Bubble>
+		</div>
+
+		<div v-else class="empty-state">
+			<div class="empty-icon">
+				<SvgIcon name="sparkle" size="60" />
+			</div>
+			<div class="empty-text">{{ t('pages.chat.emptyTitle') }}</div>
+			<div class="empty-subtext">{{ t('pages.chat.emptySubtitle') }}</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { nextTick, ref } from 'vue'
+import { Bubble, Thinking, XMarkdown } from 'vue-element-plus-x'
+import { useI18n } from '@/composables/useI18n'
+import { ElMessage } from 'element-plus'
+import { DocumentCopy, Refresh } from '@element-plus/icons-vue'
+import { Icon } from "@repo/ui"
+import type { BubbleMessage, ChatToolResult } from '../../views/chat/types'
+
+const { t } = useI18n()
+
+defineProps<{
+	messages: BubbleMessage[]
+	loading: boolean
+}>()
+
+const emit = defineEmits<{
+	retry: [message: BubbleMessage]
+}>()
+
+const getMessageImages = (message: BubbleMessage) => {
+	const files = message.message_files
+	const ids = Array.isArray(files) ? files : files ? [files] : []
+	return ids.filter(Boolean).map((fileId) => `/File/GetImage?fileId=${fileId}`)
+}
+
+const getThinkingText = (message: BubbleMessage) => `${message.thinking || ''}`.trim()
+
+const shouldShowThinking = (message: BubbleMessage) => !!getThinkingText(message)
+
+const getThinkingStatus = (message: BubbleMessage) => {
+	if (message.error) return 'error'
+	return message.streamCompleted ? 'end' : 'thinking'
+}
+
+const getToolCalls = (message: BubbleMessage) => message.toolCalls || []
+
+const getToolResults = (message: BubbleMessage) => message.toolResults || []
+
+const getToolName = (name?: string) => `${name || 'tool'}`
+
+const getToolResultThought = (toolResult: ChatToolResult) => `${toolResult.thought || ''}`.trim()
+
+const hasToolResultThought = (toolResult: ChatToolResult) => !!getToolResultThought(toolResult)
+
+const getToolResultOutput = (toolResult: ChatToolResult) => formatStructuredValue(toolResult.output)
+
+const hasToolResultOutput = (toolResult: ChatToolResult) => {
+	const output = toolResult.output
+	return output !== null && output !== undefined && `${output}`.trim() !== ''
+}
+
+const getToolResultItems = (toolResult: ChatToolResult) => Array.isArray(toolResult.contentItems) ? toolResult.contentItems : []
+
+const hasToolResultItems = (toolResult: ChatToolResult) => getToolResultItems(toolResult).length > 0
+
+const hasStructuredBlocks = (message: BubbleMessage) =>
+	!!(
+		getThinkingText(message) ||
+		getToolCalls(message).length ||
+		getToolResults(message).length
+	)
+
+const formatStructuredValue = (value: any) => {
+	if (value === null || value === undefined || value === '') return '-'
+	if (typeof value === 'string') return value
+	try {
+		return JSON.stringify(value, null, 2)
+	} catch {
+		return `${value}`
+	}
+}
+
+
+const scrollContainerRef = ref<HTMLDivElement>()
+
+defineExpose({
+	scrollToBottom: () => {
+		nextTick(() => {
+			const container = scrollContainerRef.value
+			if (!container) return
+			container.scrollTop = container.scrollHeight
+		})
+	}
+})
+
+const handleRetry = (message: BubbleMessage) => {
+	emit('retry', message)
+}
+
+const handleCopy = (message: BubbleMessage) => {
+	const text = `${message.rawText || message.answerText || message.content || ''}`
+	window.navigator.clipboard.writeText(text)
+	ElMessage.success(t('pages.chat.copySuccess'))
+}
+</script>
+
+<style lang="less" scoped>
+.chat-content {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+	overflow: hidden;
+	padding: 18px;
+
+	.item-list {
+		flex: 1;
+		overflow-y: auto;
+		padding-right: 4px;
+	}
+
+	.message-bubble+.message-bubble {
+		margin-top: 16px;
+	}
+
+	.empty-state {
+		flex: 1;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		color: var(--text-tertiary);
+	}
+}
+
+.avatar-wrapper {
+	width: 40px;
+	height: 40px;
+
+	img {
+		width: 100%;
+		height: 100%;
+		border-radius: 50%;
+	}
+}
+
+.header-wrapper {
+	.header-name {
+		font-size: 14px;
+		color: #979797;
+	}
+}
+
+.msg-content-wrapper {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+
+	.msg-content-text {
+		font-size: 14px;
+		line-height: 1.7;
+		color: var(--text-primary);
+		word-break: break-word;
+	}
+
+	.msg-content-text :deep(h3) {
+		margin: 0 0 10px;
+		font-size: 12px;
+		font-weight: 700;
+		color: var(--text-secondary);
+	}
+
+	.msg-content-text :deep(p) {
+		margin: 0 0 10px;
+		white-space: pre-wrap;
+	}
+
+	.msg-content-text :deep(blockquote) {
+		margin: 8px 0;
+		padding: 8px 12px;
+		border-left: 4px solid var(--el-color-primary);
+		background: var(--bg-container);
+		border-radius: 0 8px 8px 0;
+		color: var(--text-secondary);
+		white-space: pre-wrap;
+	}
+
+	.msg-content-text :deep(pre) {
+		margin: 8px 0;
+		padding: 10px 12px;
+		border-radius: 8px;
+		background: var(--bg-container);
+		white-space: pre-wrap;
+		overflow: auto;
+	}
+
+	.msg-content-text :deep(code) {
+		font-size: 13px;
+	}
+
+	.msg-content-text :deep(ul),
+	.msg-content-text :deep(ol) {
+		margin: 0 0 10px 20px;
+		padding: 0;
+	}
+
+	.msg-content-text :deep(li) {
+		margin: 4px 0;
+	}
+}
+
+.structured-blocks {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.structured-block {
+	padding: 12px;
+	border: 1px solid var(--border-light);
+	border-radius: 8px;
+	background: var(--bg-container);
+}
+
+.structured-block__head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	margin-bottom: 10px;
+}
+
+.structured-block__title {
+	font-size: 13px;
+	font-weight: 600;
+	color: var(--text-secondary);
+}
+
+.structured-block__body {
+	font-size: 13px;
+	line-height: 1.7;
+	color: var(--text-primary);
+}
+
+.structured-block__body--thinking {
+	white-space: pre-wrap;
+}
+
+.structured-block__markdown :deep(p) {
+	margin: 0 0 8px;
+}
+
+.tool-list {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.tool-card {
+	padding: 10px;
+	border-radius: 8px;
+	border: 1px solid var(--border-light);
+	background: var(--bg-page);
+}
+
+.tool-card__head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	margin-bottom: 8px;
+}
+
+.tool-card__meta {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 8px;
+	min-width: 0;
+}
+
+.tool-card__name,
+.tool-card__id,
+.tool-card__duration {
+	font-size: 12px;
+	color: var(--text-secondary);
+	word-break: break-all;
+}
+
+.tool-card__section+.tool-card__section {
+	margin-top: 8px;
+}
+
+.tool-card__label {
+	margin-bottom: 4px;
+	font-size: 12px;
+	font-weight: 600;
+	color: var(--text-secondary);
+}
+
+.tool-card__content {
+	margin: 0;
+	padding: 10px 12px;
+	border-radius: 8px;
+	background: var(--bg-container);
+	color: var(--text-primary);
+	font-size: 12px;
+	line-height: 1.6;
+	white-space: pre-wrap;
+	word-break: break-word;
+	overflow: auto;
+}
+
+.tool-card__content--error {
+	color: var(--el-color-danger);
+}
+
+.footer-wrapper {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+
+	.footer-time {
+		font-size: 12px;
+		margin-top: 3px;
+	}
+}
+
+.file-wrap {
+	display: flex;
+	flex-wrap: wrap;
+	margin-top: 8px;
+}
+
+.file-image {
+	width: 96px;
+	height: 96px;
+	border-radius: 8px;
+	object-fit: cover;
+	margin-right: 8px;
+	margin-bottom: 8px;
+	border: 1px solid var(--border-light);
+	cursor: zoom-in;
+}
+
+.footer-container {
+	:deep(.el-button + .el-button) {
+		margin-left: 8px;
+	}
+}
+
+.loading-container {
+	font-size: 14px;
+	color: #333;
+	padding: 12px;
+	background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
+	border-radius: 15px;
+	box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.loading-container span {
+	display: inline-block;
+	margin-left: 8px;
+}
+
+@keyframes bounce {
+
+	0%,
+	100% {
+		transform: translateY(5px);
+	}
+
+	50% {
+		transform: translateY(-5px);
+	}
+}
+
+.loading-container span:nth-child(4n) {
+	animation: bounce 1.2s ease infinite;
+}
+
+.loading-container span:nth-child(4n + 1) {
+	animation: bounce 1.2s ease infinite;
+	animation-delay: 0.3s;
+}
+
+.loading-container span:nth-child(4n + 2) {
+	animation: bounce 1.2s ease infinite;
+	animation-delay: 0.6s;
+}
+
+.loading-container span:nth-child(4n + 3) {
+	animation: bounce 1.2s ease infinite;
+	animation-delay: 0.9s;
+}
+</style>

+ 12 - 0
apps/web/src/components/MarkdownEditor/index.vue

@@ -0,0 +1,12 @@
+<template>
+  <div>
+    <Editor />
+    <Viewer />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Editor, Viewer } from "@bytemd/vue-next"
+</script>
+
+<style scoped></style>

+ 0 - 327
apps/web/src/views/chat/components/MessageList.vue

@@ -1,327 +0,0 @@
-<template>
-	<div class="chat-content">
-		<div v-if="messages.length > 0" ref="scrollContainerRef" class="item-list">
-			<Bubble v-for="item in messages" :key="item.id || item.key" :content="item.content" :placement="item.placement"
-				:loading="item.loading" :shape="item.shape" :variant="item.variant" :is-markdown="item.isMarkdown"
-				:is-fog="item.isFog" :typing="item.typing" class="message-bubble">
-				<template #avatar>
-					<div class="avatar-wrapper">
-						<Icon v-if="item.role === 'ai'" icon="mingcute:ai-line" :width="22" />
-						<Icon v-if="item.role === 'user'" icon="ri:user-line" :width="22" />
-					</div>
-				</template>
-
-				<template #header>
-					<div class="header-wrapper">
-						<div class="header-name">
-							<!-- {{ item.role === 'ai' ? 'AI' : '用户' }} -->
-						</div>
-					</div>
-				</template>
-
-				<template #content>
-					<div class="msg-content-wrapper">
-						<div class="msg-content-text" v-html="item.content"></div>
-					</div>
-				</template>
-
-				<template #footer>
-					<div v-if="getMessageImages(item).length" class="file-wrap">
-						<el-image v-for="(src, index) in getMessageImages(item)" :key="`${item.id || item.key}-file-${index}`"
-							:src="src" class="file-image" fit="cover" preview-teleported hide-on-click-modal
-							:preview-src-list="getMessageImages(item)" />
-					</div>
-					<div class="footer-wrapper" v-if="item.role === 'ai' && item.streamCompleted">
-						<div class="footer-container">
-							<el-button type="info" :icon="Refresh" size="small" circle @click="handleRetry(item)" />
-							<el-button color="#626aef" :icon="DocumentCopy" size="small" circle @click="handleCopy(item)" />
-						</div>
-						<div class="footer-time">
-							{{ item.updateTime }}
-						</div>
-					</div>
-				</template>
-			</Bubble>
-		</div>
-
-		<div v-else class="empty-state">
-			<div class="empty-icon">
-				<SvgIcon name="sparkle" size="60" />
-			</div>
-			<div class="empty-text">{{ t('pages.chat.emptyTitle') }}</div>
-			<div class="empty-subtext">{{ t('pages.chat.emptySubtitle') }}</div>
-		</div>
-	</div>
-</template>
-
-<script setup lang="ts">
-import { nextTick, ref } from 'vue'
-import { Bubble } from 'vue-element-plus-x'
-import { useI18n } from '@/composables/useI18n'
-import { ElMessage } from 'element-plus'
-import { DocumentCopy, Refresh } from '@element-plus/icons-vue'
-import { Icon } from "@repo/ui"
-import type { BubbleMessage } from '../types' // 提取类型
-
-const { t } = useI18n()
-
-const props = defineProps<{
-	messages: BubbleMessage[]
-	loading: boolean
-}>()
-
-const emit = defineEmits<{
-	retry: [message: BubbleMessage]
-}>()
-
-const getMessageImages = (message: BubbleMessage) => {
-	console.log('message', message)
-	const files = message.message_files
-	const ids = Array.isArray(files) ? files : files ? [files] : []
-	return ids.filter(Boolean).map((fileId) => `/File/GetImage?fileId=${fileId}`)
-}
-
-const scrollContainerRef = ref<HTMLDivElement>()
-
-defineExpose({
-	scrollToBottom: () => {
-		nextTick(() => {
-			const container = scrollContainerRef.value
-			if (!container) return
-			container.scrollTop = container.scrollHeight
-		})
-	}
-})
-
-const handleRetry = (message: BubbleMessage) => {
-	emit('retry', message)
-}
-
-const handleCopy = (message: BubbleMessage) => {
-	const text = `${message.answerText || message.content || ''}`.replace(/<[^>]+>/g, '')
-	window.navigator.clipboard.writeText(text)
-	ElMessage.success(t('pages.chat.copySuccess'))
-}
-</script>
-
-<style lang="less" scoped>
-.chat-content {
-	flex: 1;
-	display: flex;
-	flex-direction: column;
-	overflow: hidden;
-	padding: 18px;
-
-	.item-list {
-		flex: 1;
-		overflow-y: auto;
-		padding-right: 4px;
-	}
-
-	.message-bubble+.message-bubble {
-		margin-top: 16px;
-	}
-
-	.empty-state {
-		flex: 1;
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		justify-content: center;
-		color: var(--text-tertiary);
-	}
-}
-
-.avatar-wrapper {
-	width: 40px;
-	height: 40px;
-
-	img {
-		width: 100%;
-		height: 100%;
-		border-radius: 50%;
-	}
-}
-
-.header-wrapper {
-	.header-name {
-		font-size: 14px;
-		color: #979797;
-	}
-}
-
-.msg-content-wrapper {
-	.msg-content-text {
-		font-size: 14px;
-		line-height: 1.7;
-		color: var(--text-primary);
-		word-break: break-word;
-
-		:deep(.chat-answer) {
-			white-space: normal;
-		}
-
-		:deep(.chat-section) {
-			display: flex;
-			flex-direction: column;
-			gap: 8px;
-			margin-bottom: 10px;
-		}
-
-		:deep(.chat-section__title) {
-			margin-bottom: 6px;
-			font-size: 12px;
-			font-weight: 700;
-			color: var(--text-secondary);
-		}
-
-		:deep(.chat-block) {
-			padding: 10px 12px;
-			border-radius: 8px;
-			border: 1px solid var(--border-light);
-			background: var(--bg-base);
-		}
-
-		:deep(.chat-block--think) {
-			border-left: 4px solid #626aef;
-			background: rgba(98, 106, 239, 0.06);
-			margin-bottom: 10px;
-		}
-
-		:deep(.chat-block__title) {
-			display: flex;
-			align-items: center;
-			gap: 6px;
-			margin-bottom: 6px;
-			font-size: 13px;
-			font-weight: 700;
-			color: var(--text-primary);
-		}
-
-		:deep(.chat-block__body),
-		:deep(.chat-block__meta) {
-			font-size: 13px;
-			color: var(--text-secondary);
-		}
-
-		:deep(.chat-block__code) {
-			margin: 6px 0 0;
-			padding: 8px;
-			border-radius: 6px;
-			background: var(--bg-container);
-			color: var(--text-primary);
-			white-space: pre-wrap;
-		}
-
-		:deep(.chat-block__error) {
-			color: var(--el-color-danger);
-		}
-
-		:deep(.chat-answer),
-		:deep(.chat-block__body),
-		:deep(.chat-reference__desc),
-		:deep(.chat-reference__content),
-		:deep(.chat-block__error) {
-			white-space: pre-wrap;
-		}
-
-		:deep(.chat-reference) {
-			padding: 8px 10px;
-			border-radius: 8px;
-			border: 1px solid var(--border-light);
-			background: var(--bg-container);
-		}
-
-		:deep(.chat-reference__title) {
-			font-size: 13px;
-			font-weight: 700;
-		}
-
-		:deep(.chat-reference__desc),
-		:deep(.chat-reference__content) {
-			margin-top: 4px;
-			font-size: 12px;
-			color: var(--text-secondary);
-		}
-	}
-}
-
-.footer-wrapper {
-	display: flex;
-	align-items: center;
-	gap: 10px;
-
-	.footer-time {
-		font-size: 12px;
-		margin-top: 3px;
-	}
-}
-
-.file-wrap {
-	display: flex;
-	flex-wrap: wrap;
-	margin-top: 8px;
-}
-
-.file-image {
-	width: 96px;
-	height: 96px;
-	border-radius: 8px;
-	object-fit: cover;
-	margin-right: 8px;
-	margin-bottom: 8px;
-	border: 1px solid var(--border-light);
-	cursor: zoom-in;
-}
-
-.footer-container {
-	:deep(.el-button + .el-button) {
-		margin-left: 8px;
-	}
-}
-
-.loading-container {
-	font-size: 14px;
-	color: #333;
-	padding: 12px;
-	background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
-	border-radius: 15px;
-	box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.loading-container span {
-	display: inline-block;
-	margin-left: 8px;
-}
-
-@keyframes bounce {
-
-	0%,
-	100% {
-		transform: translateY(5px);
-	}
-
-	50% {
-		transform: translateY(-5px);
-	}
-}
-
-.loading-container span:nth-child(4n) {
-	animation: bounce 1.2s ease infinite;
-}
-
-.loading-container span:nth-child(4n + 1) {
-	animation: bounce 1.2s ease infinite;
-	animation-delay: 0.3s;
-}
-
-.loading-container span:nth-child(4n + 2) {
-	animation: bounce 1.2s ease infinite;
-	animation-delay: 0.6s;
-}
-
-.loading-container span:nth-child(4n + 3) {
-	animation: bounce 1.2s ease infinite;
-	animation-delay: 0.9s;
-}
-</style>

+ 22 - 130
apps/web/src/views/chat/index.vue

@@ -9,7 +9,6 @@
 		<div class="chat-main">
 			<ChatHeader :title="activeConversationTitle">
 				<template #actions>
-
 				</template>
 			</ChatHeader>
 
@@ -18,6 +17,15 @@
 			<ChatInput v-model="senderValue" :loading="isLoading" :attachments="currentAttachments" @submit="handleSend"
 				@cancel="handleCancel">
 				<template #prefix-extra>
+					<!-- 切换对话模式 -->
+					<el-select v-model="activeTargetType" class="chat-target-select" size="small"
+						@change="handleTargetTypeChange">
+						<el-option :label="t('pages.chat.targetKnowledge')" value="knowledge" />
+						<el-option :label="t('pages.chat.targetAgent')" value="agent" />
+						<el-option :label="t('pages.chat.targetModel')" value="model" />
+					</el-select>
+					<el-button :icon="Setting" type="text" plain @click="openSettingsDialog" />
+					<!-- 选择图片 -->
 					<el-badge :value="currentAttachments.length" :hidden="!currentAttachments.length">
 						<el-button v-if="showImageUploadButton" round plain color="#626aef"
 							@click="imageUploadDialogVisible = true">
@@ -26,13 +34,6 @@
 							</el-icon>
 						</el-button>
 					</el-badge>
-					<el-select v-model="activeTargetType" class="chat-target-select" size="small"
-						@change="handleTargetTypeChange">
-						<el-option :label="t('pages.chat.targetKnowledge')" value="knowledge" />
-						<el-option :label="t('pages.chat.targetAgent')" value="agent" />
-						<el-option :label="t('pages.chat.targetModel')" value="model" />
-					</el-select>
-					<el-button :icon="Setting" type="text" plain @click="openSettingsDialog" />
 				</template>
 			</ChatInput>
 		</div>
@@ -129,9 +130,9 @@ import {
 	type ChatOptionItem
 } from './api/chat.api'
 import ChatHeader from './components/ChatHeader.vue'
-import ChatInput from './components/ChatInput.vue'
+import ChatInput from '@/components/Chat/ChatInput.vue'
 import ChatSidebar from './components/ChatSidebar.vue'
-import MessageList from './components/MessageList.vue'
+import MessageList from '@/components/Chat/MessageList.vue'
 import type {
 	BubbleMessage,
 	ChatReference,
@@ -337,23 +338,10 @@ async function handleSaveSettings() {
 	}
 }
 
-function escapeHtml(value: string) {
-	return value
-		.replaceAll('&', '&amp;')
-		.replaceAll('<', '&lt;')
-		.replaceAll('>', '&gt;')
-		.replaceAll('"', '&quot;')
-		.replaceAll("'", '&#39;')
-}
-
 function normalizeText(value?: string) {
 	return `${value || ''}`.trim()
 }
 
-function formatText(value?: string) {
-	return escapeHtml(normalizeText(value)).replace(/\n/g, '<br>')
-}
-
 function extractThinkParts(text?: string) {
 	const raw = `${text || ''}`
 	if (!raw) return { thinking: '', answer: '' }
@@ -371,116 +359,20 @@ function extractThinkParts(text?: string) {
 	}
 }
 
-function renderThinkBlock(text: string) {
-	return `
-		<div class="chat-block chat-block--think">
-			<div class="chat-block__title">
-				<svg viewBox="0 0 1024 1024" width="16" height="16" aria-hidden="true">
-					<path fill="currentColor" d="M741.7 188.6c31.6 201.8-19.8 348.1-117.7 409.1-97.9 61-228.3 39.6-288 0-58.9 121.8-18.4 152.2-18.4 152.2l10 90.4h313l8.5-100c0 0 342.5-207.2 66-512.4z"/>
-				</svg>
-				<span>${escapeHtml(t('pages.chat.thinkTitle'))}</span>
-			</div>
-			<div class="chat-block__body">${formatText(text)}</div>
-		</div>
-	`
-}
-
-function renderToolCallBlock(tool: ChatToolCall) {
-	return `
-		<div class="chat-block chat-block--tool">
-			<div class="chat-block__title">${escapeHtml(t('pages.chat.toolCallTitle'))}${tool.toolName ? `: ${escapeHtml(tool.toolName)}` : ''}</div>
-			${tool.toolCallId ? `<div class="chat-block__meta">#${escapeHtml(tool.toolCallId)}</div>` : ''}
-			${tool.arguments ? `<pre class="chat-block__code">${escapeHtml(JSON.stringify(tool.arguments, null, 2))}</pre>` : ''}
-		</div>
-	`
-}
-
-function renderToolResultBlock(result: ChatToolResult) {
-	return `
-		<div class="chat-block chat-block--tool-result">
-			<div class="chat-block__title">
-				${escapeHtml(t('pages.chat.toolResultTitle'))}${result.toolName ? `: ${escapeHtml(result.toolName)}` : ''}
-				${result.success === false ? `<span class="chat-block__status chat-block__status--error">${escapeHtml(t('pages.chat.failedTag'))}</span>` : ''}
-			</div>
-			${result.thought ? `<div class="chat-block__body">${formatText(result.thought)}</div>` : ''}
-			${result.output ? `<pre class="chat-block__code">${formatText(result.output)}</pre>` : ''}
-			${result.error ? `<div class="chat-block__error">${formatText(result.error)}</div>` : ''}
-		</div>
-	`
-}
-
-function renderReferenceBlock(reference: ChatReference) {
-	return `
-		<div class="chat-reference">
-			<div class="chat-reference__title">${escapeHtml(reference.knowledgeTitle || reference.knowledgeFilename || reference.knowledgeId || t('pages.chat.referenceTitle'))}</div>
-			${reference.knowledgeDescription ? `<div class="chat-reference__desc">${formatText(reference.knowledgeDescription)}</div>` : ''}
-			${reference.matchedContent ? `<div class="chat-reference__content">${formatText(reference.matchedContent)}</div>` : ''}
-		</div>
-	`
-}
-
-function renderMessageErrorBlock(errorText: string) {
-	return `
-		<div class="chat-block chat-block--tool-result">
-			<div class="chat-block__title">
-				${escapeHtml(t('pages.chat.requestFailed'))}
-				<span class="chat-block__status chat-block__status--error">${escapeHtml(t('pages.chat.failedTag'))}</span>
-			</div>
-			<div class="chat-block__error">${formatText(errorText)}</div>
-		</div>
-	`
-}
-
-function composeAiContent(message: BubbleMessage) {
-	const parts: string[] = []
-	const answer = normalizeText(message.answerText || message.content || '')
-	const thinkParts = extractThinkParts(answer)
-	const thinking = normalizeText(message.thinking || thinkParts.thinking)
-	const answerText = normalizeText(thinkParts.answer || answer)
-	const errorText = normalizeText(message.error)
-
-	if (thinking) {
-		parts.push(renderThinkBlock(thinking))
-	}
-
-	if (Array.isArray(message.toolCalls) && message.toolCalls.length) {
-		parts.push(
-			`<div class="chat-section">${message.toolCalls.map((item) => renderToolCallBlock(item)).join('')}</div>`
-		)
-	}
-
-	if (Array.isArray(message.toolResults) && message.toolResults.length) {
-		parts.push(
-			`<div class="chat-section">${message.toolResults.map((item) => renderToolResultBlock(item)).join('')}</div>`
-		)
-	}
-
-	if (Array.isArray(message.references) && message.references.length) {
-		parts.push(
-			`<div class="chat-section chat-section--references">
-				<div class="chat-section__title">${escapeHtml(t('pages.chat.referenceTitle'))}</div>
-				${message.references.map((item) => renderReferenceBlock(item)).join('')}
-			</div>`
-		)
-	}
-
-	if (errorText) {
-		parts.push(renderMessageErrorBlock(errorText))
-	}
-
-	if (answerText) {
-		parts.push(`<div class="chat-answer">${formatText(answerText)}</div>`)
-	}
-
-	return parts.join('')
-}
-
 function updateMessageContent(message: BubbleMessage) {
-	message.content = composeAiContent(message)
+	message.content = normalizeText(message.answerText || message.output)
 }
 
 function hasRenderableContent(message: BubbleMessage) {
-	return !!normalizeText(message.content)
+	return !!(
+		normalizeText(message.content) ||
+		normalizeText(message.answerText) ||
+		normalizeText(message.thinking) ||
+		(message.toolCalls || []).length ||
+		(message.toolResults || []).length ||
+		(message.references || []).length ||
+		normalizeText(message.error)
+	)
 }
 
 function createUserMessage(content: string, updateTime?: string): BubbleMessage {
@@ -490,7 +382,7 @@ function createUserMessage(content: string, updateTime?: string): BubbleMessage
 		key: id,
 		role: 'user',
 		placement: 'end',
-		content: formatText(content),
+		content: content,
 		rawText: content,
 		loading: false,
 		shape: 'corner',

Разница между файлами не показана из-за своего большого размера
+ 625 - 0
pnpm-lock.yaml