feat: 重构对话功能

This commit is contained in:
DEV_DSW
2026-04-14 17:02:20 +08:00
parent b3f07c4cfe
commit c61e41049f
53 changed files with 5200 additions and 1982 deletions

View File

@@ -0,0 +1,75 @@
<template>
<div
class="relative inline-flex items-center gap-2 px-2 py-1.5 bg-white border border-[#E5E8EE] rounded-lg overflow-hidden"
>
<!-- staging overlay -->
<div
v-if="status === 'staging'"
class="absolute inset-0 bg-white/60 flex items-center justify-center z-10"
>
<RiLoader4Line class="animate-spin text-[#2B7FFF]" size="16px" />
</div>
<!-- error overlay -->
<div
v-if="status === 'error'"
class="absolute inset-0 bg-red-50/80 flex items-center justify-center z-10"
>
<span class="text-[10px] text-red-600 font-medium">{{ errorText || 'Error' }}</span>
</div>
<img
v-if="attachment.preview && isImageMime(attachment.mimeType)"
:src="attachment.preview"
class="w-10 h-10 object-cover rounded"
/>
<div v-else class="w-10 h-10 flex items-center justify-center bg-[#F5F7FA] rounded">
<RiFileLine size="18px" class="text-gray-400" />
</div>
<div class="flex flex-col min-w-0">
<span class="text-xs text-gray-700 truncate max-w-[120px]">
{{ attachment.fileName }}
</span>
<span v-if="attachment.fileSize > 0" class="text-[10px] text-gray-400">
{{ formatSize(attachment.fileSize) }}
</span>
</div>
<button
class="ml-1 text-gray-400 hover:text-red-500"
@click="emit('remove')"
>
<RiCloseLine size="12px" />
</button>
</div>
</template>
<script setup lang="ts">
import { RiLoader4Line, RiFileLine, RiCloseLine } from '@remixicon/vue'
import type { AttachedFileMeta } from '../../model/ChatModel'
interface Props {
attachment: AttachedFileMeta
status?: 'staging' | 'error' | 'ready'
errorText?: string
}
withDefaults(defineProps<Props>(), {
status: 'ready',
})
const emit = defineEmits<{
(e: 'remove'): void
}>()
function isImageMime(mime?: string) {
return !!mime && mime.startsWith('image/')
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div class="flex items-start gap-3 justify-start">
<ChatAvatar :src="aiAvatarSrc" />
<div
class="px-4 py-3 bg-white border border-[#E5E8EE] rounded-2xl rounded-tl-sm flex items-center gap-2 text-sm text-gray-600"
>
<RiLoader4Line class="animate-spin" size="16px" />
<span>{{ text }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { RiLoader4Line } from '@remixicon/vue'
import ChatAvatar from '../ChatAvatar.vue'
import aiAvatarSrc from '@assets/images/login/blue_logo.png'
interface Props {
text?: string
}
withDefaults(defineProps<Props>(), {
text: 'Processing tool results…',
})
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex flex-col h-full overflow-auto py-6 px-6">
<div class="pt-30">
<h1 class="text-[28px] font-bold mb-7 leading-tight">
你好<br />
我今天能帮你什么
</h1>
<div class="flex flex-wrap gap-3">
<button
v-for="tag in quickTags"
:key="tag"
class="px-3 py-1.5 rounded-2xl border border-[#E5E8EE] text-[13px] text-[#333] cursor-pointer hover:bg-[#2B7FFF] hover:text-white hover:border-[#2B7FFF] transition-colors"
@click="emit('click-tag', tag)"
>
{{ tag }}
</button>
</div>
</div>
<div class="mt-auto">
<slot name="task-center" />
</div>
</div>
</template>
<script setup lang="ts">
const quickTags = ['智能问数', '写代码', '查数据', '生成图片']
const emit = defineEmits<{
(e: 'click-tag', tag: string): void
}>()
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div
v-if="error"
class="flex items-center gap-3 px-4 py-2 bg-red-50 border-t border-red-200 text-red-600 text-sm"
>
<RiErrorWarningLine size="16px" />
<span class="flex-1">{{ error }}</span>
<button class="text-xs font-medium hover:underline" @click="emit('dismiss')">
关闭
</button>
</div>
</template>
<script setup lang="ts">
import { RiErrorWarningLine } from '@remixicon/vue'
interface Props {
error?: string
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'dismiss'): void
}>()
</script>

View File

@@ -0,0 +1,161 @@
<template>
<div
class="bg-white rounded-lg border border-[#eef2f6] shadow-[0_1px_0_rgba(0,0,0,0.03)] p-4 mt-2 flex flex-col justify-between gap-3"
>
<!-- Agent chip -->
<div v-if="agentName" class="flex items-center gap-2">
<span class="px-2 py-0.5 bg-[#2B7FFF]/10 text-[#2B7FFF] text-[11px] rounded-full">
@{{ agentName }}
</span>
<button
class="text-gray-400 hover:text-gray-600"
@click="emit('clear-agent')"
>
<RiCloseLine size="12px" />
</button>
</div>
<textarea
rows="2"
:value="modelValue"
:placeholder="placeholder"
class="w-full flex-1 resize-none outline-none text-sm text-gray-700"
@input="onInput"
@keydown.enter="onEnter"
/>
<!-- Attachment previews -->
<div v-if="attachments.length" class="flex flex-wrap gap-2">
<div
v-for="(file, idx) in attachments"
:key="idx"
class="inline-flex items-center gap-2 px-2 py-1 bg-[#F5F7FA] border border-[#E5E8EE] rounded-lg"
>
<img
v-if="file.preview && isImageMime(file.mimeType)"
:src="file.preview"
class="w-8 h-8 object-cover rounded"
/>
<span class="text-xs text-gray-600 truncate max-w-[120px]">
{{ file.fileName }}
</span>
<button
class="text-gray-400 hover:text-red-500"
@click="emit('remove-attachment', idx)"
>
<RiCloseLine size="12px" />
</button>
</div>
</div>
<div class="flex justify-between items-end">
<div class="flex items-center gap-1">
<button
class="p-2 rounded-md cursor-pointer hover:bg-[#F5F7FA] hover:text-[#2B7FFF]"
@click="triggerFileInput"
>
<RiLink size="18px" />
</button>
<input
ref="fileInput"
type="file"
multiple
class="hidden"
@change="onFileChange"
/>
<button
class="p-2 rounded-md cursor-pointer hover:bg-[#F5F7FA] hover:text-[#2B7FFF]"
@click="emit('mention-agent')"
>
<RiAtLine size="18px" />
</button>
</div>
<button
class="w-12 h-12 px-2.5 py-1.5 rounded-md flex items-center justify-center cursor-pointer transition-colors"
:class="
isSending
? 'bg-gray-200 text-gray-600'
: 'bg-[#F5F7FA] hover:bg-[#2B7FFF] hover:text-white'
"
@click="onAction"
>
<RiStopFill v-if="isSending" />
<RiSendPlaneFill v-else />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
RiLink,
RiSendPlaneFill,
RiStopFill,
RiAtLine,
RiCloseLine,
} from '@remixicon/vue'
import type { AttachedFileMeta } from '../../model/ChatModel'
interface Props {
modelValue: string
isSending?: boolean
attachments?: AttachedFileMeta[]
placeholder?: string
agentName?: string
}
const props = withDefaults(defineProps<Props>(), {
isSending: false,
attachments: () => [],
placeholder: '给我发布或者布置任务',
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'send'): void
(e: 'stop'): void
(e: 'attach', files: File[]): void
(e: 'remove-attachment', index: number): void
(e: 'mention-agent'): void
(e: 'clear-agent'): void
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
}
function onEnter(e: KeyboardEvent) {
if (e.shiftKey) return
e.preventDefault()
onAction()
}
function onAction() {
if (props.isSending) {
emit('stop')
} else {
emit('send')
}
}
function triggerFileInput() {
fileInput.value?.click()
}
function onFileChange(e: Event) {
const target = e.target as HTMLInputElement
const files = Array.from(target.files || [])
if (files.length) {
emit('attach', files)
}
if (target) target.value = ''
}
function isImageMime(mime?: string) {
return !!mime && mime.startsWith('image/')
}
</script>

View File

@@ -0,0 +1,272 @@
<template>
<div class="flex items-start gap-3" :class="isUser ? 'justify-end' : 'justify-start'">
<!-- AI Avatar -->
<ChatAvatar v-if="!isUser" :src="aiAvatarSrc" />
<!-- Message Bubble -->
<div class="max-w-[75%] flex flex-col group">
<!-- Name / Time -->
<div
class="flex items-start gap-2 pt-0.5 mb-2"
:class="isUser ? 'flex-row-reverse' : 'flex-row'"
>
<span class="text-xs text-[#4E5969]">{{ isUser ? '我' : 'NIANXX' }}</span>
<span class="text-xs text-[#86909C]">{{ formattedTime }}</span>
</div>
<!-- User message -->
<template v-if="isUser">
<div class="text-sm text-gray-700 bg-[#f7f9fc] rounded-md px-3 py-2 whitespace-pre-wrap">
{{ displayText }}
</div>
<!-- Attachments -->
<div v-if="attachedFiles.length" class="flex flex-wrap gap-2 mt-2">
<div
v-for="file in attachedFiles"
:key="file.fileName"
class="inline-flex items-center gap-2 px-2 py-1 bg-white border border-[#E5E8EE] rounded-lg text-xs text-gray-600"
>
<img
v-if="file.preview && isImageMime(file.mimeType)"
:src="file.preview"
class="w-10 h-10 object-cover rounded"
/>
<span class="truncate max-w-[200px]">{{ file.fileName }}</span>
</div>
</div>
</template>
<!-- Assistant message -->
<template v-else>
<div class="flex flex-col text-sm text-gray-700">
<!-- Loading -->
<ChatLoading v-if="isStreaming && !displayText && !hasBlocks" />
<!-- Markdown text -->
<div
v-if="markdownHtml"
class="bg-[#f7f9fc] rounded-md px-3 py-2 prose prose-sm max-w-none"
v-html="markdownHtml"
/>
<!-- Thinking block -->
<div
v-if="thinkingText && store.showThinking"
class="mt-2 p-2 rounded bg-gray-100 text-xs text-gray-500 border-l-2 border-[#2B7FFF]"
>
<div class="font-medium mb-1">思考过程</div>
<pre class="whitespace-pre-wrap font-mono">{{ thinkingText }}</pre>
</div>
<!-- Tool use cards -->
<div
v-for="tool in tools"
:key="tool.id || tool.name"
class="mt-2 px-3 py-2 bg-white border border-[#E5E8EE] rounded-lg"
>
<div class="flex items-center gap-2 text-xs text-gray-600">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-[#2B7FFF]" />
<span class="font-medium">Tool: {{ tool.name }}</span>
<span
v-if="toolStatusMap[(tool.id || tool.name) as string]"
class="ml-auto text-[10px] px-1.5 py-0.5 rounded"
:class="toolStatusClass(toolStatusMap[(tool.id || tool.name) as string])"
>
{{ toolStatusText(toolStatusMap[(tool.id || tool.name) as string]) }}
</span>
</div>
<pre
v-if="tool.input || tool.arguments"
class="mt-1 text-[11px] text-gray-500 bg-[#F5F7FA] rounded p-1.5 overflow-x-auto"
>{{ JSON.stringify(tool.input ?? tool.arguments, null, 2) }}</pre>
</div>
<!-- Inline images -->
<div v-for="(img, idx) in images" :key="idx" class="mt-2">
<img
:src="img.url || `data:${img.mimeType};base64,${img.data}`"
class="max-w-full rounded-md border border-[#E5E8EE]"
/>
</div>
<!-- Question tags -->
<ChatAttach
v-if="questionTags.length"
:question="questionTags.join(';')"
@select="(tag: string) => emit('select-tag', tag)"
/>
<!-- AI mark -->
<ChatAIMark v-if="!isStreaming && displayText" class="mt-2" />
<!-- Hover actions -->
<div
class="mt-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-between gap-4 text-gray-500"
>
<div
class="flex items-center gap-1 cursor-pointer hover:text-[#2B7FFF]"
@click="copyText"
>
<RiFileCopyLine size="14px" />
<span class="text-xs">复制</span>
</div>
<div class="flex items-center gap-4">
<RiThumbUpLine size="14px" class="cursor-pointer hover:text-[#2B7FFF]" />
<RiThumbDownLine size="14px" class="cursor-pointer hover:text-[#2B7FFF]" />
</div>
</div>
</div>
</template>
</div>
<!-- User Avatar -->
<ChatAvatar v-if="isUser" :src="userAvatarSrc" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import {
RiFileCopyLine,
RiThumbUpLine,
RiThumbDownLine,
} from '@remixicon/vue'
import type { RawMessage, ContentBlock, ToolStatus } from '../../model/ChatModel'
import {
extractText,
extractThinking,
extractImages,
extractToolUse,
} from '../../model/ChatModel'
import { useChatStore } from '@store/chat'
import ChatAvatar from '../ChatAvatar.vue'
import ChatAttach from '../ChatAttach.vue'
import ChatAIMark from '../ChatAIMark.vue'
import ChatLoading from '../ChatLoading.vue'
import aiAvatarSrc from '@assets/images/login/blue_logo.png'
import userAvatarSrc from '@assets/images/login/user_icon.png'
interface Props {
message: RawMessage
isStreaming?: boolean
streamingTools?: ToolStatus[]
}
const props = withDefaults(defineProps<Props>(), {
isStreaming: false,
streamingTools: () => [],
})
const emit = defineEmits<{
(e: 'select-tag', tag: string): void
}>()
const store = useChatStore()
const isUser = computed(() => props.message.role === 'user')
const displayText = computed(() => extractText(props.message))
const thinkingText = computed(() => extractThinking(props.message))
const images = computed(() => extractImages(props.message))
const questionTags = computed(() => props.message.question || [])
const attachedFiles = computed(() => props.message._attachedFiles || [])
const hasBlocks = computed(() => {
const c = props.message.content
return Array.isArray(c) && c.length > 0
})
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: (str: string, lang: string) => {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
} catch { /* ignore */ }
}
return hljs.highlightAuto(str).value
},
})
const markdownHtml = computed(() => {
const text = displayText.value
return text ? md.render(text) : ''
})
const tools = computed(() => {
const fromMsg = extractToolUse(props.message)
const fromStream = props.streamingTools || []
const map = new Map<string, { id?: string; name: string; input?: unknown }>()
for (const t of fromMsg) {
map.set(t.id || t.name, t)
}
for (const t of fromStream) {
const key = t.toolCallId || t.id || t.name
if (key) map.set(key, { id: t.toolCallId || t.id, name: t.name, input: t.summary })
}
return Array.from(map.values())
})
const toolStatusMap = computed(() => {
const map: Record<string, ToolStatus['status']> = {}
for (const t of props.streamingTools || []) {
const key = t.toolCallId || t.id || t.name
if (key) map[key] = t.status
}
return map
})
function toolStatusClass(status: ToolStatus['status']) {
if (status === 'error') return 'bg-red-100 text-red-600'
if (status === 'completed') return 'bg-green-100 text-green-600'
return 'bg-blue-100 text-[#2B7FFF]'
}
function toolStatusText(status: ToolStatus['status']) {
if (status === 'error') return '失败'
if (status === 'completed') return '完成'
return '运行中'
}
function isImageMime(mime?: string) {
return !!mime && mime.startsWith('image/')
}
const formattedTime = computed(() => {
const ts = props.message.timestamp
if (!ts) return ''
const ms = ts < 1e12 ? ts * 1000 : ts
const d = new Date(ms)
if (isNaN(d.getTime())) return ''
const now = new Date()
const sameDay =
now.getFullYear() === d.getFullYear() &&
now.getMonth() === d.getMonth() &&
now.getDate() === d.getDate()
const pad = (n: number) => String(n).padStart(2, '0')
const h = pad(d.getHours())
const m = pad(d.getMinutes())
const s = pad(d.getSeconds())
if (sameDay) return `${h}:${m}:${s}`
const Y = d.getFullYear()
const M = pad(d.getMonth() + 1)
const D = pad(d.getDate())
return `${Y}-${M}-${D} ${h}:${m}:${s}`
})
async function copyText() {
try {
await navigator.clipboard.writeText(displayText.value)
} catch {
// ignore
}
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex items-start gap-3 justify-start">
<ChatAvatar :src="aiAvatarSrc" />
<div
class="px-4 py-3 bg-white border border-[#E5E8EE] rounded-2xl rounded-tl-sm flex items-center gap-1"
>
<span class="dot" />
<span class="dot" />
<span class="dot" />
</div>
</div>
</template>
<script setup lang="ts">
import ChatAvatar from '../ChatAvatar.vue'
import aiAvatarSrc from '@assets/images/login/blue_logo.png'
</script>
<style scoped>
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 9999px;
background: #2b7fff;
animation: wave 1.3s linear infinite;
}
.dot:nth-child(2) {
animation-delay: -1.1s;
}
.dot:nth-child(3) {
animation-delay: -0.9s;
}
@keyframes wave {
0%,
60%,
100% {
transform: initial;
}
30% {
transform: translateY(-4px);
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="ml-12 border border-[#E5E8EE] rounded-lg bg-white p-3 max-w-[75%]">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-500">{{ agentLabel || '任务执行' }}</span>
<span
v-if="active"
class="inline-flex items-center gap-1 text-[10px] text-[#2B7FFF]"
>
<span class="w-1.5 h-1.5 rounded-full bg-[#2B7FFF] animate-pulse" />
进行中
</span>
</div>
<div class="space-y-2">
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex items-start gap-2"
>
<div
class="mt-1 w-2 h-2 rounded-full shrink-0"
:class="
step.status === 'completed'
? 'bg-green-500'
: step.status === 'error'
? 'bg-red-500'
: 'bg-[#2B7FFF] animate-pulse'
"
/>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-700">{{ step.name }}</div>
<div v-if="step.summary" class="text-[11px] text-gray-400 truncate">
{{ step.summary }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
export interface TaskStep {
name: string
status: 'running' | 'completed' | 'error'
summary?: string
}
interface Props {
agentLabel?: string
steps: TaskStep[]
active?: boolean
}
withDefaults(defineProps<Props>(), {
active: false,
})
</script>