feat: 重构对话功能
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<aside :class="['h-full box-border flex flex-col transition-all duration-300', sidebarCollapsed ? 'w-16' : 'w-50']">
|
||||
<div class="h-full">
|
||||
<aside :class="['h-full box-border flex flex-col transition-all duration-300', sidebarCollapsed ? 'w-16' : 'w-50']">
|
||||
<div class="flex items-center justify-center m-2">
|
||||
<img v-if="!sidebarCollapsed" class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
|
||||
<div v-if="!sidebarCollapsed" class="font-bold text-gray-80">YINIAN</div>
|
||||
@@ -31,7 +32,7 @@
|
||||
<ul class="list-none">
|
||||
<li v-for="item in bucket.sessions" :key="item.conversationId" @click="selectedHistoryMessage(item.conversationId)"
|
||||
:class="[
|
||||
'flex items-center gap-2 p-2 text-gray-600 rounded-lg cursor-pointer transition-colors',
|
||||
'flex items-center gap-2 p-2 text-gray-600 rounded-lg cursor-pointer transition-colors mb-2',
|
||||
item.conversationId === selectedConversationId ? 'bg-white shadow-sm border-[#E5E8EE] py-1.5 relative z-10' : 'hover:bg-gray-200'
|
||||
]">
|
||||
<span class="w-2 h-2 rounded-full bg-[#BEDBFF] flex-none"></span>
|
||||
@@ -82,13 +83,25 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { RiSideBarLine, RiSidebarFoldLine, RiArrowDownSLine, RiAddLine } from '@remixicon/vue'
|
||||
import { getSessionList, deleteSession, updateSession } from '../../api/SessionsApi';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RiSideBarLine, RiSidebarFoldLine, RiAddLine } from '@remixicon/vue'
|
||||
import { useChatStore } from '@store/chat'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
const deleteDialogVisible = ref(false)
|
||||
const renameDialogFormVisible = ref(false)
|
||||
const newMessageName = ref('')
|
||||
const renamingConversationId = ref('')
|
||||
const formLabelWidth = '100px'
|
||||
const nowMs = ref(Date.now())
|
||||
let timer: number | undefined
|
||||
|
||||
type SessionBucketKey =
|
||||
| 'today'
|
||||
@@ -96,43 +109,31 @@ type SessionBucketKey =
|
||||
| 'withinWeek'
|
||||
| 'withinTwoWeeks'
|
||||
| 'withinMonth'
|
||||
| 'older';
|
||||
| 'older'
|
||||
|
||||
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
|
||||
if (!activityMs || activityMs <= 0) return 'older';
|
||||
const now = new Date(nowMs);
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
if (!activityMs || activityMs <= 0) return 'older'
|
||||
const now = new Date(nowMs)
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000
|
||||
|
||||
if (activityMs >= startOfToday) return 'today';
|
||||
if (activityMs >= startOfYesterday) return 'yesterday';
|
||||
if (activityMs >= startOfToday) return 'today'
|
||||
if (activityMs >= startOfYesterday) return 'yesterday'
|
||||
|
||||
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
|
||||
if (daysAgo <= 7) return 'withinWeek';
|
||||
if (daysAgo <= 14) return 'withinTwoWeeks';
|
||||
if (daysAgo <= 30) return 'withinMonth';
|
||||
return 'older';
|
||||
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000)
|
||||
if (daysAgo <= 7) return 'withinWeek'
|
||||
if (daysAgo <= 14) return 'withinTwoWeeks'
|
||||
if (daysAgo <= 30) return 'withinMonth'
|
||||
return 'older'
|
||||
}
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
const deleteDialogVisible = ref(false)
|
||||
const renameDialogFormVisible = ref(false)
|
||||
const newMessageName = ref('')
|
||||
const formLabelWidth = '100px'
|
||||
const nowMs = ref(Date.now())
|
||||
let timer: number | undefined
|
||||
|
||||
interface HistoryMessage {
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
updatedAt?: number;
|
||||
conversationId: string
|
||||
conversationTitle: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
/// 记录选择的历史消息ID
|
||||
const selectedConversationId = ref<string>('')
|
||||
|
||||
/// 历史消息分组数据
|
||||
const groups = ref<Array<HistoryMessage>>([])
|
||||
const selectedConversationId = computed(() => chatStore.currentSessionKey)
|
||||
|
||||
const sessionBuckets = computed(() => {
|
||||
const buckets: Array<{ key: SessionBucketKey; label: string; sessions: HistoryMessage[] }> = [
|
||||
@@ -142,83 +143,64 @@ const sessionBuckets = computed(() => {
|
||||
{ key: 'withinTwoWeeks', label: '近14天', sessions: [] },
|
||||
{ key: 'withinMonth', label: '近30天', sessions: [] },
|
||||
{ key: 'older', label: '更早', sessions: [] },
|
||||
];
|
||||
const map = {} as Record<SessionBucketKey, typeof buckets[number]>;
|
||||
]
|
||||
const map = {} as Record<SessionBucketKey, typeof buckets[number]>
|
||||
for (const b of buckets) {
|
||||
map[b.key] = b;
|
||||
map[b.key] = b
|
||||
}
|
||||
|
||||
for (const session of [...groups.value].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))) {
|
||||
const bucketKey = getSessionBucket(session.updatedAt || 0, nowMs.value);
|
||||
map[bucketKey].sessions.push(session);
|
||||
}
|
||||
return buckets;
|
||||
});
|
||||
const sessionsWithMeta = chatStore.sessions.map((s) => ({
|
||||
conversationId: s.key,
|
||||
conversationTitle: chatStore.sessionLabels[s.key] || s.displayName || s.key,
|
||||
updatedAt: chatStore.sessionLastActivity[s.key] || s.updatedAt || 0,
|
||||
}))
|
||||
|
||||
for (const session of [...sessionsWithMeta].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))) {
|
||||
const bucketKey = getSessionBucket(session.updatedAt || 0, nowMs.value)
|
||||
map[bucketKey].sessions.push(session)
|
||||
}
|
||||
return buckets
|
||||
})
|
||||
|
||||
/// 定义事件
|
||||
const emit = defineEmits(['new-chat', 'select-chat'])
|
||||
|
||||
/// 添加新对话
|
||||
const addNewChat = () => {
|
||||
console.log('add new chat')
|
||||
updateNewChat()
|
||||
}
|
||||
|
||||
const updateNewChat = () => {
|
||||
// 触发新对话事件
|
||||
emit('new-chat')
|
||||
// 清空选择的历史消息ID
|
||||
selectedConversationId.value = ''
|
||||
// 获取最新的历史会话列表
|
||||
getHistoryConversationList()
|
||||
}
|
||||
|
||||
/// 选择历史消息
|
||||
const selectedHistoryMessage = (conversationId: string) => {
|
||||
selectedConversationId.value = conversationId
|
||||
emit('select-chat', conversationId)
|
||||
}
|
||||
|
||||
/// 重命名历史消息
|
||||
const renameHistoryMessage = (conversationId: string) => {
|
||||
console.log('rename message', conversationId)
|
||||
renamingConversationId.value = conversationId
|
||||
renameDialogFormVisible.value = true
|
||||
}
|
||||
|
||||
/// 删除历史消息
|
||||
const deleteHistoryMessage = (conversationId: string) => {
|
||||
console.log('delete message', conversationId)
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
/// 提交重命名
|
||||
const submitNameChange = async () => {
|
||||
console.log('submit name change', newMessageName.value)
|
||||
renameDialogFormVisible.value = false
|
||||
const res = await updateSession({
|
||||
session_id: selectedConversationId.value,
|
||||
title: newMessageName.value
|
||||
})
|
||||
if (res && res.success) {
|
||||
updateNewChat()
|
||||
const targetId = renamingConversationId.value || selectedConversationId.value
|
||||
if (targetId && newMessageName.value.trim()) {
|
||||
chatStore.sessionLabels = {
|
||||
...chatStore.sessionLabels,
|
||||
[targetId]: newMessageName.value.trim(),
|
||||
}
|
||||
newMessageName.value = ''
|
||||
renamingConversationId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交删除
|
||||
const submitDelete = async () => {
|
||||
console.log('submit delete')
|
||||
deleteDialogVisible.value = false
|
||||
const res = await deleteSession({
|
||||
session_id: selectedConversationId.value
|
||||
})
|
||||
if (res && res.success) {
|
||||
updateNewChat()
|
||||
}
|
||||
await chatStore.deleteSession(selectedConversationId.value)
|
||||
}
|
||||
|
||||
/// 页面加载时获取历史会话列表
|
||||
onMounted(() => {
|
||||
getHistoryConversationList()
|
||||
timer = window.setInterval(() => {
|
||||
nowMs.value = Date.now()
|
||||
}, 60 * 1000)
|
||||
@@ -227,17 +209,4 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
/// 获取历史会话列表
|
||||
const getHistoryConversationList = async () => {
|
||||
const list = await getSessionList({ limit: 50, offset: 0 })
|
||||
if (!list || !list.sessions) return;
|
||||
// 使用整体赋值替换 push,避免重复累加
|
||||
groups.value = list.sessions.map((item: any) => ({
|
||||
conversationId: item.session_id,
|
||||
conversationTitle: item.title,
|
||||
updatedAt: item.updated_at ? new Date(item.updated_at).getTime() : Date.now(),
|
||||
}))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
75
src/pages/home/components/chat/AttachmentPreview.vue
Normal file
75
src/pages/home/components/chat/AttachmentPreview.vue
Normal 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>
|
||||
25
src/pages/home/components/chat/ChatActivityIndicator.vue
Normal file
25
src/pages/home/components/chat/ChatActivityIndicator.vue
Normal 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>
|
||||
31
src/pages/home/components/chat/ChatEmpty.vue
Normal file
31
src/pages/home/components/chat/ChatEmpty.vue
Normal 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>
|
||||
26
src/pages/home/components/chat/ChatErrorBar.vue
Normal file
26
src/pages/home/components/chat/ChatErrorBar.vue
Normal 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>
|
||||
161
src/pages/home/components/chat/ChatInput.vue
Normal file
161
src/pages/home/components/chat/ChatInput.vue
Normal 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>
|
||||
272
src/pages/home/components/chat/ChatMessage.vue
Normal file
272
src/pages/home/components/chat/ChatMessage.vue
Normal 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>
|
||||
44
src/pages/home/components/chat/ChatTypingIndicator.vue
Normal file
44
src/pages/home/components/chat/ChatTypingIndicator.vue
Normal 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>
|
||||
56
src/pages/home/components/chat/ExecutionGraphCard.vue
Normal file
56
src/pages/home/components/chat/ExecutionGraphCard.vue
Normal 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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex h-full w-full flex-col md:flex-row">
|
||||
<ChatHistory class="flex-none w-50" @new-chat="handleNewChat" @select-chat="handleSelectChat" />
|
||||
<div class="flex-1 mr-2 overflow-hidden bg-white rounded-xl">
|
||||
<ChatBox v-model:guide="guide" :conversationId="selectedConversationId" />
|
||||
<ChatBox />
|
||||
</div>
|
||||
<TaskList />
|
||||
</div>
|
||||
@@ -13,35 +13,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import TaskList from '@src/components/TaskList/index.vue'
|
||||
import TaskOperationDialog from './components/TaskOperationDialog.vue'
|
||||
import ChatHistory from './ChatHistory.vue'
|
||||
import ChatBox from './ChatBox.vue'
|
||||
import { ref } from 'vue'
|
||||
import { useChatStore } from '@store/chat'
|
||||
import emitter from '@src/utils/emitter'
|
||||
|
||||
/// 是否显示引导页
|
||||
const guide = ref(true)
|
||||
/// 选择的历史会话ID
|
||||
const selectedConversationId = ref('')
|
||||
|
||||
/// 任务操作弹窗实例
|
||||
const chatStore = useChatStore()
|
||||
const taskOperationDialog = ref()
|
||||
|
||||
onMounted(() => {
|
||||
chatStore.loadSessions()
|
||||
chatStore.initConnection()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chatStore.cleanupEmptySession()
|
||||
chatStore.closeConnection()
|
||||
})
|
||||
|
||||
/// 处理新对话事件:切换到引导页并清空选中的历史会话ID
|
||||
const handleNewChat = () => {
|
||||
guide.value = true;
|
||||
selectedConversationId.value = '';
|
||||
};
|
||||
chatStore.newSession()
|
||||
}
|
||||
|
||||
/// 选择历史会话
|
||||
const handleSelectChat = (conversationId: string) => {
|
||||
guide.value = false;
|
||||
selectedConversationId.value = conversationId;
|
||||
};
|
||||
chatStore.switchSession(conversationId)
|
||||
}
|
||||
|
||||
emitter.on('OPERATION_CHANNEL', (item) => {
|
||||
emitter.on('OPERATION_CHANNEL', (item: any) => {
|
||||
taskOperationDialog.value?.open(item)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,53 +1,167 @@
|
||||
/// 消息角色枚举
|
||||
export enum MessageRole {
|
||||
// 智能体消息
|
||||
AI = "AI",
|
||||
// 我发送的消息
|
||||
ME = "ME",
|
||||
// 其他消息
|
||||
OTHER = "OTHER",
|
||||
};
|
||||
|
||||
/// Chat消息模型
|
||||
export class ChatMessage {
|
||||
// 消息唯一标识
|
||||
messageId: string;
|
||||
// 消息类型
|
||||
messageRole: MessageRole;
|
||||
// 消息内容
|
||||
messageContent: string;
|
||||
// 消息内容列表(用于流式更新)
|
||||
messageContentList: string[];
|
||||
// 是否加载中
|
||||
isLoading?: boolean;
|
||||
// 是否完成
|
||||
finished?: boolean;
|
||||
// 工具调用信息
|
||||
toolCall?: any;
|
||||
// 问题信息
|
||||
question?: string;
|
||||
// 时间戳
|
||||
timestamp?: number;
|
||||
|
||||
constructor(
|
||||
messageId: string,
|
||||
messageRole: MessageRole,
|
||||
messageContent: string,
|
||||
messageContentList: string[] = [],
|
||||
isLoading: boolean = false,
|
||||
finished: boolean = false,
|
||||
toolCall?: any,
|
||||
question?: any,
|
||||
timestamp?: number
|
||||
) {
|
||||
this.messageId = messageId;
|
||||
this.messageRole = messageRole;
|
||||
this.messageContent = messageContent;
|
||||
this.messageContentList = messageContentList;
|
||||
this.isLoading = isLoading;
|
||||
this.finished = finished;
|
||||
this.toolCall = toolCall;
|
||||
this.question = question;
|
||||
this.timestamp = timestamp || Date.now();
|
||||
}
|
||||
/// 附件文件元数据(与 ClawX 对齐)
|
||||
export interface AttachedFileMeta {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
preview: string | null;
|
||||
filePath?: string;
|
||||
source?: 'user-upload' | 'tool-result' | 'message-ref';
|
||||
}
|
||||
|
||||
/// 内容块(与 ClawX 对齐,用于未来扩展结构化消息)
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
source?: { type: string; media_type?: string; data?: string; url?: string };
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
arguments?: unknown;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
/// 原始消息(与 ClawX RawMessage 对齐)
|
||||
export interface RawMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'toolresult';
|
||||
content: string | ContentBlock[];
|
||||
timestamp?: number;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
/** zn-ai 特有:问题标签(保留现有能力) */
|
||||
question?: string[];
|
||||
/** zn-ai 特有:工具调用结果(保留现有能力) */
|
||||
toolCall?: any;
|
||||
/** 本地-only:附件 */
|
||||
_attachedFiles?: AttachedFileMeta[];
|
||||
}
|
||||
|
||||
/// 工具状态(与 ClawX 对齐)
|
||||
export interface ToolStatus {
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/// 会话(与 ClawX ChatSession 对齐)
|
||||
export interface ChatSession {
|
||||
key: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
thinkingLevel?: string;
|
||||
model?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取纯文本
|
||||
export function extractText(message?: RawMessage | null): string {
|
||||
if (!message) return '';
|
||||
const content = message.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return (content as Array<{ type?: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text!)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取 thinking 文本
|
||||
export function extractThinking(message?: RawMessage | null): string | null {
|
||||
if (!message) return null;
|
||||
const content = message.content;
|
||||
if (Array.isArray(content)) {
|
||||
const block = content.find((b: any) => b.type === 'thinking');
|
||||
return block?.thinking || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取图片
|
||||
export function extractImages(message?: RawMessage | null): Array<{ url?: string; data?: string; mimeType: string }> {
|
||||
if (!message) return [];
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
const images: Array<{ url?: string; data?: string; mimeType: string }> = [];
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type === 'image') {
|
||||
if (block.source) {
|
||||
const src = block.source;
|
||||
if (src.type === 'base64' && src.data) {
|
||||
images.push({ data: src.data, mimeType: src.media_type || 'image/jpeg' });
|
||||
} else if (src.type === 'url' && src.url) {
|
||||
images.push({ url: src.url, mimeType: src.media_type || 'image/jpeg' });
|
||||
}
|
||||
} else if (block.data) {
|
||||
images.push({ data: block.data, mimeType: block.mimeType || 'image/jpeg' });
|
||||
}
|
||||
}
|
||||
if ((block.type === 'tool_result' || block.type === 'toolResult') && block.content) {
|
||||
images.push(...extractImages({ role: 'toolresult', content: block.content }));
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取 tool_use
|
||||
export function extractToolUse(message?: RawMessage | null): Array<{ id?: string; name: string; input?: unknown }> {
|
||||
if (!message) return [];
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
return (content as ContentBlock[])
|
||||
.filter((b) => b.type === 'tool_use' || b.type === 'toolCall')
|
||||
.map((b) => ({ id: b.id, name: b.name || b.id || 'tool', input: b.input ?? b.arguments }));
|
||||
}
|
||||
|
||||
/// 格式化时间戳(秒/ms 兼容)
|
||||
export function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const ms = ts < 1e12 ? ts * 1000 : ts;
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
/// 判断是否为 tool-only 消息
|
||||
export function isToolOnlyMessage(message?: RawMessage): boolean {
|
||||
if (!message) return false;
|
||||
const role = message.role;
|
||||
if (role === 'toolresult' || role === 'tool_result') return true;
|
||||
const content = message.content;
|
||||
if (Array.isArray(content)) {
|
||||
const hasTool = content.some((b: any) =>
|
||||
['tool_use', 'tool_result', 'toolCall', 'toolResult'].includes(b.type)
|
||||
);
|
||||
const hasText = content.some(
|
||||
(b: any) => b.type === 'text' && b.text?.trim?.()
|
||||
);
|
||||
const hasImage = content.some((b: any) => b.type === 'image');
|
||||
return hasTool && !hasText && !hasImage;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判断是否为 tool result 角色
|
||||
export function isToolResultRole(role?: string): boolean {
|
||||
if (!role) return false;
|
||||
const normalized = role.toLowerCase();
|
||||
return normalized === 'toolresult' || normalized === 'tool_result';
|
||||
}
|
||||
|
||||
/// 判断是否为内部消息(不应展示在 UI)
|
||||
export function isInternalMessage(msg: { role?: string; content?: unknown }): boolean {
|
||||
if (msg.role === 'system') return true;
|
||||
if (msg.role === 'assistant') {
|
||||
const text = typeof msg.content === 'string' ? msg.content : extractText(msg as RawMessage);
|
||||
if (/^(HEARTBEAT_OK|NO_REPLY)\s*$/.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user