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

@@ -1,330 +0,0 @@
/**
* 简化的 WebSocket 管理器Web 版)
* 专门负责 WebSocket 连接和消息传输,不包含打字机逻辑
*/
import { IdUtils, CallbackUtils, MessageUtils, TimerUtils } from './index'
/* =======================
* 类型定义
* ======================= */
export interface WebSocketCallbacks {
onConnect?: (event?: Event) => void
onDisconnect?: (event?: CloseEvent) => void
onError?: (error: any) => void
onMessage?: (message: any) => void
getConversationId?: () => string
getAgentId?: () => string
}
export interface WebSocketManagerOptions extends WebSocketCallbacks {
wsUrl?: string
protocols?: string[]
reconnectInterval?: number
maxReconnectAttempts?: number
heartbeatInterval?: number
messageId?: string
baseDelay?: number
retries?: number
maxDelay?: number
tryReconnect?: boolean
}
export interface QueuedMessage {
[key: string]: any
retryCount?: number
timestamp?: number
}
export interface WebSocketStats {
messagesReceived: number
messagesSent: number
messagesDropped: number
reconnectCount: number
connectionStartTime: number | null
lastMessageTime: number | null
}
/* =======================
* WebSocketManager
* ======================= */
export class WebSocketManager {
private wsUrl = ''
private protocols: string[] = []
private reconnectInterval = 3000
private maxReconnectAttempts = 5
private heartbeatInterval = 30000
private ws: WebSocket | null = null
private reconnectAttempts = 0
private isConnecting = false
private connectionState = false
private heartbeatTimer: number | null = null
private reconnectTimer: number | null = null
private callbacks: Required<WebSocketCallbacks>
private messageQueue: QueuedMessage[] = []
private stats: WebSocketStats = {
messagesReceived: 0,
messagesSent: 0,
messagesDropped: 0,
reconnectCount: 0,
connectionStartTime: null,
lastMessageTime: null,
}
constructor(options: WebSocketManagerOptions = {}) {
this.wsUrl = options.wsUrl ?? ''
this.protocols = options.protocols ?? []
this.reconnectInterval = options.reconnectInterval ?? 3000
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5
this.heartbeatInterval = options.heartbeatInterval ?? 30000
this.callbacks = {
onConnect: options.onConnect ?? (() => { }),
onDisconnect: options.onDisconnect ?? (() => { }),
onError: options.onError ?? (() => { }),
onMessage: options.onMessage ?? (() => { }),
getConversationId: options.getConversationId ?? (() => ''),
getAgentId: options.getAgentId ?? (() => ''),
}
}
/* =======================
* 内部工具
* ======================= */
private safeCall(name: keyof WebSocketCallbacks, ...args: any[]): void {
CallbackUtils.safeCall(this.callbacks, name as string, ...args)
}
/* =======================
* 连接管理
* ======================= */
async init(wsUrl?: string): Promise<void> {
if (wsUrl) this.wsUrl = wsUrl
if (!this.wsUrl) throw new Error('WebSocket URL is required')
await this.connect()
}
// 改进方案让connect()真正等待连接
async connect(): Promise<void> {
console.log('[WebSocket] connect() called, isConnecting:', this.isConnecting, 'connectionState:', this.connectionState)
if (this.isConnecting || this.connectionState) {
console.log('[WebSocket] Already connecting or connected, returning early')
return
}
this.isConnecting = true
console.log('[WebSocket] Starting connection...')
return new Promise((resolve, reject) => {
try {
console.log('[WebSocket] About to create new WebSocket with URL:', this.wsUrl)
this.ws = new WebSocket(this.wsUrl, this.protocols)
console.log('[WebSocket] WebSocket object created, readyState:', this.ws?.readyState)
// 包装handleOpen以resolve Promise
this.ws.onopen = (event: Event) => {
console.log('[WebSocket] onopen event fired')
this.handleOpen(event)
resolve() // ← 真正的连接成功
}
this.ws.onmessage = this.handleMessage
this.ws.onclose = (event: CloseEvent) => {
console.log('[WebSocket] onclose event fired, code:', event.code, 'reason:', event.reason)
this.handleClose(event)
}
this.ws.onerror = (error: Event) => {
console.log('[WebSocket] onerror event fired', error)
this.handleError(error)
reject(error) // ← Promise拒绝
}
} catch (error) {
this.isConnecting = false
this.safeCall('onError', error)
this.scheduleReconnect()
reject(error)
}
})
}
private handleOpen = (event: Event): void => {
this.isConnecting = false
this.connectionState = true
this.reconnectAttempts = 0
this.stats.connectionStartTime = Date.now()
this.startHeartbeat()
this.safeCall('onConnect', event)
this.processQueue()
}
private handleMessage = (event: MessageEvent): void => {
const raw = event.data
if (MessageUtils.isPongMessage(raw)) return
const data =
typeof raw === 'string'
? MessageUtils.safeParseJSON(raw)
: raw
if (!data) return
this.stats.messagesReceived++
this.stats.lastMessageTime = Date.now()
this.safeCall('onMessage', data)
}
private handleClose = (event: CloseEvent): void => {
this.connectionState = false
this.isConnecting = false
this.stopHeartbeat()
this.safeCall('onDisconnect', event)
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
private handleError = (error: Event): void => {
this.connectionState = false
this.isConnecting = false
this.safeCall('onError', {
type: 'WEBSOCKET_ERROR',
error,
})
if (!this.reconnectTimer) {
this.scheduleReconnect()
}
}
/* =======================
* 消息发送
* ======================= */
sendMessage(message: QueuedMessage): boolean {
const data = {
...message,
timestamp: Date.now(),
retryCount: message.retryCount ?? 0,
}
if (!this.isConnected()) {
this.messageQueue.push(data)
this.connect()
return false
}
try {
this.ws!.send(JSON.stringify(data))
this.stats.messagesSent++
return true
} catch (error) {
this.messageQueue.push(data)
this.safeCall('onError', error)
return false
}
}
private processQueue(): void {
while (this.messageQueue.length) {
const msg = this.messageQueue.shift()!
if ((msg.retryCount ?? 0) >= 3) {
this.stats.messagesDropped++
continue
}
msg.retryCount = (msg.retryCount ?? 0) + 1
this.sendMessage(msg)
}
}
/* =======================
* 心跳 & 重连
* ======================= */
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (!this.isConnected()) return
this.sendMessage({
messageType: '3',
messageContent: 'heartbeat',
messageId: IdUtils.generateMessageId(),
conversationId: this.callbacks.getConversationId(),
agentId: this.callbacks.getAgentId(),
})
}, this.heartbeatInterval)
}
private stopHeartbeat(): void {
this.heartbeatTimer = TimerUtils.clearTimer(this.heartbeatTimer, 'interval')
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
this.reconnectAttempts++
this.stats.reconnectCount++
const delay = Math.min(
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
30000
)
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
/* =======================
* 对外 API
* ======================= */
isConnected(): boolean {
return (
this.connectionState &&
!!this.ws &&
this.ws.readyState === WebSocket.OPEN
)
}
getStats() {
return {
...this.stats,
queueLength: this.messageQueue.length,
isConnected: this.isConnected(),
}
}
close(): void {
this.stopHeartbeat()
TimerUtils.clearTimer(this.reconnectTimer)
this.reconnectTimer = null
this.ws?.close(1000)
this.ws = null
this.connectionState = false
this.isConnecting = false
this.messageQueue = []
}
destroy(): void {
this.close()
}
}
export default WebSocketManager

View File

@@ -70,6 +70,10 @@ export enum IPC_EVENTS {
SCRIPT_RECORD_STOP = 'script:record-stop',
SCRIPT_CODEGEN = 'script:codegen',
// Gateway (对齐 ClawX)
GATEWAY_RPC = 'gateway:rpc',
GATEWAY_EVENT = 'gateway:event',
// 更新
UPDATE_CHECK = 'update:check',
UPDATE_DOWNLOAD = 'update:download',

15
src/lib/gateway-client.ts Normal file
View File

@@ -0,0 +1,15 @@
import { IPC_EVENTS } from '@lib/constants';
import type { GatewayEvent } from '@electron/gateway/types';
export async function gatewayRpc<T = any>(method: string, params?: any): Promise<T> {
if (!window.api?.invoke) {
throw new Error('IPC not available');
}
return window.api.invoke(IPC_EVENTS.GATEWAY_RPC, method, params);
}
export function onGatewayEvent(
callback: (event: GatewayEvent) => void
): () => void {
return window.api.on(IPC_EVENTS.GATEWAY_EVENT, callback as (event: any) => void);
}

View File

@@ -1,15 +1,21 @@
import { IPC_EVENTS } from '@lib/constants';
import { Session } from '@utils/storage';
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const method = init?.method || 'GET';
const token = Session.get('token');
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
try {
// Attempt to call via IPC if window.api exists
if ((window as any).api && (window as any).api.invoke) {
const response = await (window as any).api.invoke('hostapi:fetch', {
path,
method,
headers: init?.headers || {},
headers: {
...authHeaders,
...(init?.headers || {}),
},
body: init?.body ?? null,
});

View File

@@ -6,6 +6,7 @@ export const PROVIDER_TYPES = [
'ark',
'moonshot',
'siliconflow',
'deepseek',
'minimax-portal',
'minimax-portal-cn',
'modelstudio',
@@ -22,6 +23,7 @@ export const BUILTIN_PROVIDER_TYPES = [
'ark',
'moonshot',
'siliconflow',
'deepseek',
'minimax-portal',
'minimax-portal-cn',
'modelstudio',
@@ -161,6 +163,7 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' },
{ id: 'deepseek', name: 'DeepSeek', icon: '🐋', placeholder: 'sk-...', model: 'DeepSeek', requiresApiKey: true, defaultBaseUrl: 'https://api.deepseek.com/v1', showModelId: true, modelIdPlaceholder: 'deepseek-chat', defaultModelId: 'deepseek-chat', apiKeyUrl: 'https://platform.deepseek.com/api_keys', docsUrl: 'https://api-docs.deepseek.com/' },
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' },
{ id: 'modelstudio', name: 'Model Studio', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: true, defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1', showBaseUrl: true, defaultModelId: 'qwen3.5-plus', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'qwen3.5-plus', apiKeyUrl: 'https://bailian.console.aliyun.com/', hidden: true },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' },

File diff suppressed because it is too large Load Diff

View File

@@ -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>

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>

View File

@@ -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>

View File

@@ -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;
}

1198
src/store/chat.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -24,10 +24,10 @@ export const useProviderStore = defineStore('providers', () => {
const refreshProviderSnapshot = async () => {
loading.value = true;
error.value = null;
try {
const snapshot = await fetchProviderSnapshot();
statuses.value = snapshot.statuses ?? [];
accounts.value = snapshot.accounts ?? [];
vendors.value = snapshot.vendors ?? [];
@@ -236,12 +236,12 @@ export const useProviderStore = defineStore('providers', () => {
method: 'PUT',
body: JSON.stringify({ providerId }),
});
if (!result.success) {
throw new Error(result.error || 'Failed to set default provider');
}
defaultAccountId.value = providerId;
await refreshProviderSnapshot();
} catch (err) {
console.error('Failed to set default provider:', err);
throw err;
@@ -259,13 +259,13 @@ export const useProviderStore = defineStore('providers', () => {
throw new Error(result.error || 'Failed to set default provider account');
}
defaultAccountId.value = accountId;
await refreshProviderSnapshot();
} catch (err) {
console.error('Failed to set default account:', err);
throw err;
}
};
const validateAccountApiKey = async (
providerId: string,
apiKey: string,