mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-19 10:22:04 +08:00
* feat: add audio input support across providers and chatui recording issue fix - Introduced audio_urls parameter in Provider class and related methods to handle audio input. - Updated ProviderAnthropic, ProviderGoogleGenAI, and ProviderOpenAIOfficial to process audio URLs. - Enhanced media_utils with functions to ensure audio format compatibility and detect audio types. - Modified dashboard components to display audio input support and handle audio attachments in messages. - Updated localization files to include audio as a supported modality. - Added new icons for audio input in the dashboard UI. * feat: enhance audio handling with temporary file cleanup and format support * feat: track temporary local files for converted audio components * fix: update image placeholder in prompt from "[图片]" to "[Image]"
1110 lines
37 KiB
TypeScript
1110 lines
37 KiB
TypeScript
import { ref, reactive, type Ref } from 'vue';
|
||
import axios from 'axios';
|
||
import { useToast } from '@/utils/toast';
|
||
|
||
// 工具调用信息
|
||
export interface ToolCall {
|
||
id: string;
|
||
name: string;
|
||
args: Record<string, any>;
|
||
ts: number; // 开始时间戳
|
||
result?: string; // 工具调用结果
|
||
finished_ts?: number; // 完成时间戳
|
||
}
|
||
|
||
// Token 使用统计
|
||
export interface TokenUsage {
|
||
input_other: number;
|
||
input_cached: number;
|
||
output: number;
|
||
}
|
||
|
||
// Agent 统计信息
|
||
export interface AgentStats {
|
||
token_usage: TokenUsage;
|
||
start_time: number;
|
||
end_time: number;
|
||
time_to_first_token: number;
|
||
}
|
||
|
||
// 文件信息结构
|
||
export interface FileInfo {
|
||
url?: string; // blob URL (可选,点击时才加载)
|
||
filename: string;
|
||
attachment_id?: string; // 用于按需下载
|
||
}
|
||
|
||
// 消息部分的类型定义
|
||
export interface MessagePart {
|
||
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply' | 'tool_call';
|
||
text?: string; // for plain
|
||
attachment_id?: string; // for image, record, file, video
|
||
filename?: string; // for file (filename from backend)
|
||
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
|
||
tool_calls?: ToolCall[]; // for tool_call
|
||
// embedded fields - 加载后填充
|
||
embedded_url?: string; // blob URL for image, record
|
||
embedded_file?: FileInfo; // for file (保留 attachment_id 用于按需下载)
|
||
selected_text?: string; // for reply - 被引用消息的内容
|
||
}
|
||
|
||
// 引用信息 (用于发送消息时)
|
||
export interface ReplyInfo {
|
||
messageId: number;
|
||
selectedText?: string; // 选中的文本内容(可选)
|
||
}
|
||
|
||
// 简化的消息内容结构
|
||
export interface MessageContent {
|
||
type: string; // 'user' | 'bot'
|
||
message: MessagePart[]; // 消息部分列表 (保持顺序)
|
||
reasoning?: string; // reasoning content (for bot)
|
||
isLoading?: boolean; // loading state
|
||
agentStats?: AgentStats; // agent 统计信息 (for bot)
|
||
}
|
||
|
||
export interface Message {
|
||
id?: number;
|
||
content: MessageContent;
|
||
created_at?: string;
|
||
}
|
||
|
||
export type ChatTransportMode = 'sse' | 'websocket';
|
||
|
||
type StreamChunk = {
|
||
type?: string;
|
||
t?: string;
|
||
data?: any;
|
||
chain_type?: string;
|
||
streaming?: boolean;
|
||
session_id?: string;
|
||
message_id?: string;
|
||
code?: string;
|
||
ct?: string;
|
||
[key: string]: any;
|
||
};
|
||
|
||
type WsStreamContext = {
|
||
handleChunk: (payload: StreamChunk) => Promise<void>;
|
||
finish: (err?: unknown) => void;
|
||
};
|
||
|
||
const STREAMING_STORAGE_KEY = 'enableStreaming';
|
||
const TRANSPORT_MODE_STORAGE_KEY = 'chatTransportMode';
|
||
const HIDDEN_TOOL_CALL_NAMES = new Set(['send_message_to_user']);
|
||
|
||
function isHiddenToolCall(toolCall: ToolCall | { name?: unknown } | null | undefined): boolean {
|
||
if (!toolCall || typeof toolCall !== 'object') {
|
||
return false;
|
||
}
|
||
const name = toolCall.name;
|
||
return typeof name === 'string' && HIDDEN_TOOL_CALL_NAMES.has(name);
|
||
}
|
||
|
||
export function useMessages(
|
||
currSessionId: Ref<string>,
|
||
getMediaFile: (filename: string) => Promise<string>,
|
||
updateSessionTitle: (sessionId: string, title: string) => void,
|
||
onSessionsUpdate: () => void
|
||
) {
|
||
const messages = ref<Message[]>([]);
|
||
const isStreaming = ref(false);
|
||
const isConvRunning = ref(false);
|
||
const isToastedRunningInfo = ref(false);
|
||
const activeStreamCount = ref(0);
|
||
const enableStreaming = ref(true);
|
||
const transportMode = ref<ChatTransportMode>('sse');
|
||
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||
const currentRequestController = ref<AbortController | null>(null);
|
||
const currentReader = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||
const currentRunningSessionId = ref('');
|
||
const currentWsMessageId = ref('');
|
||
const currentBoundSessionId = ref('');
|
||
const userStopRequested = ref(false);
|
||
|
||
const currentWebSocket = ref<WebSocket | null>(null);
|
||
const webSocketConnectPromise = ref<Promise<WebSocket> | null>(null);
|
||
const wsContexts = new Map<string, WsStreamContext>();
|
||
|
||
// 当前会话的项目信息
|
||
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
|
||
|
||
// 从 localStorage 读取配置
|
||
const savedStreamingState = localStorage.getItem(STREAMING_STORAGE_KEY);
|
||
if (savedStreamingState !== null) {
|
||
enableStreaming.value = JSON.parse(savedStreamingState);
|
||
}
|
||
|
||
const savedTransportMode = localStorage.getItem(TRANSPORT_MODE_STORAGE_KEY);
|
||
if (savedTransportMode === 'sse' || savedTransportMode === 'websocket') {
|
||
transportMode.value = savedTransportMode;
|
||
}
|
||
|
||
function toggleStreaming() {
|
||
enableStreaming.value = !enableStreaming.value;
|
||
localStorage.setItem(STREAMING_STORAGE_KEY, JSON.stringify(enableStreaming.value));
|
||
}
|
||
|
||
function setTransportMode(mode: ChatTransportMode) {
|
||
transportMode.value = mode;
|
||
localStorage.setItem(TRANSPORT_MODE_STORAGE_KEY, mode);
|
||
if (mode === 'websocket') {
|
||
if (currSessionId.value) {
|
||
void bindSessionToWebSocket(currSessionId.value).catch((err) => {
|
||
console.error('建立 WebSocket 连接失败:', err);
|
||
});
|
||
}
|
||
} else {
|
||
closeChatWebSocket();
|
||
}
|
||
}
|
||
|
||
function generateMessageId(): string {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
function buildWebSocketUrl(): string {
|
||
const token = localStorage.getItem('token');
|
||
if (!token) {
|
||
throw new Error('Missing authentication token');
|
||
}
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = new URL('/api/unified_chat/ws', window.location.href);
|
||
wsUrl.protocol = protocol;
|
||
wsUrl.searchParams.set('token', token);
|
||
return wsUrl.toString();
|
||
}
|
||
|
||
function closeChatWebSocket() {
|
||
if (currentWebSocket.value) {
|
||
try {
|
||
currentWebSocket.value.close();
|
||
} catch {
|
||
// ignore websocket close errors
|
||
}
|
||
currentWebSocket.value = null;
|
||
}
|
||
webSocketConnectPromise.value = null;
|
||
currentBoundSessionId.value = '';
|
||
}
|
||
|
||
async function bindSessionToWebSocket(sessionId: string) {
|
||
if (!sessionId || transportMode.value !== 'websocket') {
|
||
return;
|
||
}
|
||
const ws = await ensureChatWebSocket();
|
||
if (ws.readyState !== WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
if (currentBoundSessionId.value === sessionId) {
|
||
return;
|
||
}
|
||
|
||
ws.send(JSON.stringify({
|
||
ct: 'chat',
|
||
t: 'bind',
|
||
session_id: sessionId
|
||
}));
|
||
currentBoundSessionId.value = sessionId;
|
||
}
|
||
|
||
async function handlePassiveWebSocketChunk(payload: StreamChunk) {
|
||
if (!payload.type) {
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'plain') {
|
||
const chainType = payload.chain_type || 'normal';
|
||
if (chainType === 'reasoning') {
|
||
messages.value.push({
|
||
content: {
|
||
type: 'bot',
|
||
message: [],
|
||
reasoning: String(payload.data || '')
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
messages.value.push({
|
||
content: {
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'plain',
|
||
text: String(payload.data || '')
|
||
}]
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'image') {
|
||
const img = String(payload.data || '').replace('[IMAGE]', '');
|
||
const imageUrl = await getMediaFile(img);
|
||
messages.value.push({
|
||
content: {
|
||
type: 'bot',
|
||
message: [{ type: 'image', embedded_url: imageUrl }]
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'record') {
|
||
const audio = String(payload.data || '').replace('[RECORD]', '');
|
||
const audioUrl = await getMediaFile(audio);
|
||
messages.value.push({
|
||
content: {
|
||
type: 'bot',
|
||
message: [{ type: 'record', embedded_url: audioUrl }]
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'file') {
|
||
const fileData = String(payload.data || '').replace('[FILE]', '');
|
||
const [filename, originalName] = fileData.includes('|')
|
||
? fileData.split('|', 2)
|
||
: [fileData, fileData];
|
||
const fileUrl = await getMediaFile(filename);
|
||
messages.value.push({
|
||
content: {
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'file',
|
||
embedded_file: { url: fileUrl, filename: originalName }
|
||
}]
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function dispatchWebSocketMessage(event: MessageEvent) {
|
||
let payload: StreamChunk;
|
||
try {
|
||
payload = JSON.parse(event.data);
|
||
} catch (err) {
|
||
console.warn('WebSocket JSON parse failed:', err);
|
||
return;
|
||
}
|
||
|
||
if (payload.ct && payload.ct !== 'chat') {
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'session_bound') {
|
||
if (typeof payload.session_id === 'string') {
|
||
currentBoundSessionId.value = payload.session_id;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (payload.t === 'error') {
|
||
const targetMessageId = payload.message_id || currentWsMessageId.value;
|
||
if (!targetMessageId) {
|
||
console.warn('WebSocket chat error:', payload);
|
||
return;
|
||
}
|
||
const ctx = wsContexts.get(targetMessageId);
|
||
if (!ctx) {
|
||
console.warn('WebSocket chat error (no ctx):', payload);
|
||
return;
|
||
}
|
||
|
||
if (userStopRequested.value || payload.code === 'INTERRUPTED') {
|
||
ctx.finish();
|
||
} else {
|
||
ctx.finish(new Error(payload.data || 'WebSocket chat error'));
|
||
}
|
||
return;
|
||
}
|
||
|
||
const targetMessageId = payload.message_id || currentWsMessageId.value;
|
||
if (!targetMessageId) {
|
||
return;
|
||
}
|
||
|
||
const ctx = wsContexts.get(targetMessageId);
|
||
if (!ctx) {
|
||
await handlePassiveWebSocketChunk(payload);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ctx.handleChunk(payload);
|
||
} catch (err) {
|
||
ctx.finish(err);
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'end') {
|
||
ctx.finish();
|
||
}
|
||
}
|
||
|
||
function ensureChatWebSocket(): Promise<WebSocket> {
|
||
if (currentWebSocket.value?.readyState === WebSocket.OPEN) {
|
||
return Promise.resolve(currentWebSocket.value);
|
||
}
|
||
|
||
if (webSocketConnectPromise.value) {
|
||
return webSocketConnectPromise.value;
|
||
}
|
||
|
||
const connectPromise = new Promise<WebSocket>((resolve, reject) => {
|
||
let settled = false;
|
||
let ws: WebSocket;
|
||
|
||
try {
|
||
ws = new WebSocket(buildWebSocketUrl());
|
||
} catch (err) {
|
||
reject(err);
|
||
return;
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
webSocketConnectPromise.value = null;
|
||
try {
|
||
ws.close();
|
||
} catch {
|
||
// ignore close errors
|
||
}
|
||
reject(new Error('WebSocket connection timeout'));
|
||
}, 5000);
|
||
|
||
ws.onopen = () => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
window.clearTimeout(timeoutId);
|
||
currentWebSocket.value = ws;
|
||
resolve(ws);
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
window.clearTimeout(timeoutId);
|
||
webSocketConnectPromise.value = null;
|
||
reject(new Error('WebSocket connection failed'));
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
void dispatchWebSocketMessage(event);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
currentWebSocket.value = null;
|
||
webSocketConnectPromise.value = null;
|
||
const pending = Array.from(wsContexts.values());
|
||
for (const ctx of pending) {
|
||
if (userStopRequested.value) {
|
||
ctx.finish();
|
||
} else {
|
||
ctx.finish(new Error('WebSocket closed'));
|
||
}
|
||
}
|
||
};
|
||
});
|
||
|
||
webSocketConnectPromise.value = connectPromise;
|
||
return connectPromise;
|
||
}
|
||
|
||
function createStreamChunkProcessor() {
|
||
let inStreaming = false;
|
||
let messageObj: MessageContent | null = null;
|
||
|
||
return async (chunkJson: StreamChunk) => {
|
||
if (!chunkJson || typeof chunkJson !== 'object') {
|
||
return;
|
||
}
|
||
|
||
if (chunkJson.type === 'session_id') {
|
||
return;
|
||
}
|
||
|
||
if (!chunkJson.type) {
|
||
return;
|
||
}
|
||
|
||
const lastMsg = messages.value[messages.value.length - 1];
|
||
if (lastMsg?.content?.isLoading) {
|
||
messages.value.pop();
|
||
}
|
||
|
||
if (chunkJson.type === 'error') {
|
||
console.error('Error received:', chunkJson.data);
|
||
return;
|
||
}
|
||
|
||
if (chunkJson.type === 'image') {
|
||
const img = String(chunkJson.data || '').replace('[IMAGE]', '');
|
||
const imageUrl = await getMediaFile(img);
|
||
const botResp: MessageContent = {
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'image',
|
||
embedded_url: imageUrl
|
||
}]
|
||
};
|
||
messages.value.push({ content: botResp });
|
||
} else if (chunkJson.type === 'record') {
|
||
const audio = String(chunkJson.data || '').replace('[RECORD]', '');
|
||
const audioUrl = await getMediaFile(audio);
|
||
const botResp: MessageContent = {
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'record',
|
||
embedded_url: audioUrl
|
||
}]
|
||
};
|
||
messages.value.push({ content: botResp });
|
||
} else if (chunkJson.type === 'file') {
|
||
const fileData = String(chunkJson.data || '').replace('[FILE]', '');
|
||
const [filename, originalName] = fileData.includes('|')
|
||
? fileData.split('|', 2)
|
||
: [fileData, fileData];
|
||
const fileUrl = await getMediaFile(filename);
|
||
const botResp: MessageContent = {
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'file',
|
||
embedded_file: {
|
||
url: fileUrl,
|
||
filename: originalName
|
||
}
|
||
}]
|
||
};
|
||
messages.value.push({ content: botResp });
|
||
} else if (chunkJson.type === 'plain') {
|
||
const chainType = chunkJson.chain_type || 'normal';
|
||
|
||
if (chainType === 'tool_call') {
|
||
let toolCallData: any;
|
||
try {
|
||
toolCallData = JSON.parse(String(chunkJson.data || '{}'));
|
||
} catch {
|
||
return;
|
||
}
|
||
if (isHiddenToolCall(toolCallData)) {
|
||
return;
|
||
}
|
||
|
||
const toolCall: ToolCall = {
|
||
id: toolCallData.id,
|
||
name: toolCallData.name,
|
||
args: toolCallData.args,
|
||
ts: toolCallData.ts
|
||
};
|
||
|
||
if (!inStreaming) {
|
||
messageObj = reactive<MessageContent>({
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'tool_call',
|
||
tool_calls: [toolCall]
|
||
}]
|
||
});
|
||
messages.value.push({ content: messageObj });
|
||
inStreaming = true;
|
||
} else {
|
||
const lastPart = messageObj!.message[messageObj!.message.length - 1];
|
||
if (lastPart?.type === 'tool_call') {
|
||
const existingIndex = lastPart.tool_calls!.findIndex((tc: ToolCall) => tc.id === toolCall.id);
|
||
if (existingIndex === -1) {
|
||
lastPart.tool_calls!.push(toolCall);
|
||
}
|
||
} else {
|
||
messageObj!.message.push({
|
||
type: 'tool_call',
|
||
tool_calls: [toolCall]
|
||
});
|
||
}
|
||
}
|
||
} else if (chainType === 'tool_call_result') {
|
||
let resultData: any;
|
||
try {
|
||
resultData = JSON.parse(String(chunkJson.data || '{}'));
|
||
} catch {
|
||
return;
|
||
}
|
||
if (isHiddenToolCall(resultData)) {
|
||
return;
|
||
}
|
||
|
||
if (messageObj) {
|
||
for (const part of messageObj.message) {
|
||
if (part.type === 'tool_call' && part.tool_calls) {
|
||
const toolCall = part.tool_calls.find((tc: ToolCall) => tc.id === resultData.id);
|
||
if (toolCall) {
|
||
toolCall.result = resultData.result;
|
||
toolCall.finished_ts = resultData.ts;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (chainType === 'reasoning') {
|
||
if (!inStreaming) {
|
||
messageObj = reactive<MessageContent>({
|
||
type: 'bot',
|
||
message: [],
|
||
reasoning: String(chunkJson.data || '')
|
||
});
|
||
messages.value.push({ content: messageObj });
|
||
inStreaming = true;
|
||
} else {
|
||
messageObj!.reasoning = (messageObj!.reasoning || '') + String(chunkJson.data || '');
|
||
}
|
||
} else {
|
||
if (!inStreaming) {
|
||
messageObj = reactive<MessageContent>({
|
||
type: 'bot',
|
||
message: [{
|
||
type: 'plain',
|
||
text: String(chunkJson.data || '')
|
||
}]
|
||
});
|
||
messages.value.push({ content: messageObj });
|
||
inStreaming = true;
|
||
} else {
|
||
const lastPart = messageObj!.message[messageObj!.message.length - 1];
|
||
if (lastPart?.type === 'plain') {
|
||
lastPart.text = (lastPart.text || '') + String(chunkJson.data || '');
|
||
} else {
|
||
messageObj!.message.push({
|
||
type: 'plain',
|
||
text: String(chunkJson.data || '')
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else if (chunkJson.type === 'update_title') {
|
||
if (chunkJson.session_id) {
|
||
updateSessionTitle(chunkJson.session_id, chunkJson.data);
|
||
}
|
||
} else if (chunkJson.type === 'message_saved') {
|
||
const lastBotMsg = messages.value[messages.value.length - 1];
|
||
if (lastBotMsg && lastBotMsg.content?.type === 'bot') {
|
||
lastBotMsg.id = chunkJson.data?.id;
|
||
lastBotMsg.created_at = chunkJson.data?.created_at;
|
||
}
|
||
} else if (chunkJson.type === 'agent_stats') {
|
||
if (messageObj) {
|
||
messageObj.agentStats = chunkJson.data;
|
||
}
|
||
}
|
||
|
||
if (typeof chunkJson.streaming === 'boolean') {
|
||
if ((chunkJson.type === 'break' && chunkJson.streaming) || !chunkJson.streaming) {
|
||
inStreaming = false;
|
||
if (!chunkJson.streaming) {
|
||
isStreaming.value = false;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
// 获取 attachment 文件并返回 blob URL
|
||
async function getAttachment(attachmentId: string): Promise<string> {
|
||
if (attachmentCache.has(attachmentId)) {
|
||
return attachmentCache.get(attachmentId)!;
|
||
}
|
||
try {
|
||
const response = await axios.get(`/api/chat/get_attachment?attachment_id=${attachmentId}`, {
|
||
responseType: 'blob'
|
||
});
|
||
const blobUrl = URL.createObjectURL(response.data);
|
||
attachmentCache.set(attachmentId, blobUrl);
|
||
return blobUrl;
|
||
} catch (err) {
|
||
console.error('Failed to get attachment:', attachmentId, err);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// 解析消息内容,填充 embedded 字段 (保持原始顺序)
|
||
async function parseMessageContent(content: any): Promise<void> {
|
||
const message = content.message;
|
||
|
||
// 如果 message 是字符串 (旧格式),转换为数组格式
|
||
if (typeof message === 'string') {
|
||
const parts: MessagePart[] = [];
|
||
let text = message;
|
||
|
||
// 处理旧格式的特殊标记
|
||
if (text.startsWith('[IMAGE]')) {
|
||
const img = text.replace('[IMAGE]', '');
|
||
const imageUrl = await getMediaFile(img);
|
||
parts.push({
|
||
type: 'image',
|
||
embedded_url: imageUrl
|
||
});
|
||
} else if (text.startsWith('[RECORD]')) {
|
||
const audio = text.replace('[RECORD]', '');
|
||
const audioUrl = await getMediaFile(audio);
|
||
parts.push({
|
||
type: 'record',
|
||
embedded_url: audioUrl
|
||
});
|
||
} else if (text) {
|
||
parts.push({
|
||
type: 'plain',
|
||
text: text
|
||
});
|
||
}
|
||
|
||
content.message = parts;
|
||
return;
|
||
}
|
||
|
||
// 如果 message 是数组 (新格式),遍历并填充 embedded 字段
|
||
if (Array.isArray(message)) {
|
||
const filteredMessage: MessagePart[] = [];
|
||
for (const part of message as MessagePart[]) {
|
||
if (part.type === 'tool_call' && Array.isArray(part.tool_calls)) {
|
||
const visibleToolCalls = part.tool_calls.filter(
|
||
(toolCall) => !isHiddenToolCall(toolCall),
|
||
);
|
||
if (!visibleToolCalls.length) {
|
||
continue;
|
||
}
|
||
part.tool_calls = visibleToolCalls;
|
||
}
|
||
|
||
if (part.type === 'image' && part.attachment_id) {
|
||
part.embedded_url = await getAttachment(part.attachment_id);
|
||
} else if (part.type === 'record' && part.attachment_id) {
|
||
part.embedded_url = await getAttachment(part.attachment_id);
|
||
} else if (part.type === 'file' && part.attachment_id) {
|
||
// file 类型不预加载,保留 attachment_id 以便点击时下载
|
||
part.embedded_file = {
|
||
attachment_id: part.attachment_id,
|
||
filename: part.filename || 'file'
|
||
};
|
||
}
|
||
// plain, reply, tool_call, video 保持原样
|
||
filteredMessage.push(part);
|
||
}
|
||
content.message = filteredMessage;
|
||
}
|
||
|
||
// 处理 agent_stats (snake_case -> camelCase)
|
||
if (content.agent_stats) {
|
||
content.agentStats = content.agent_stats;
|
||
delete content.agent_stats;
|
||
}
|
||
}
|
||
|
||
async function getSessionMessages(sessionId: string) {
|
||
if (!sessionId) return;
|
||
|
||
try {
|
||
if (transportMode.value === 'websocket') {
|
||
try {
|
||
await bindSessionToWebSocket(sessionId);
|
||
} catch (err) {
|
||
console.error('进入会话时建立 WebSocket 连接失败:', err);
|
||
}
|
||
}
|
||
|
||
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
|
||
isConvRunning.value = response.data.data.is_running || false;
|
||
let history = response.data.data.history;
|
||
|
||
// 保存项目信息(如果存在)
|
||
currentSessionProject.value = response.data.data.project || null;
|
||
|
||
if (isConvRunning.value) {
|
||
if (!isToastedRunningInfo.value) {
|
||
useToast().info('该会话正在运行中。', { timeout: 5000 });
|
||
isToastedRunningInfo.value = true;
|
||
}
|
||
|
||
// 如果会话还在运行,3秒后重新获取消息
|
||
setTimeout(() => {
|
||
getSessionMessages(currSessionId.value);
|
||
}, 3000);
|
||
}
|
||
|
||
// 处理历史消息
|
||
for (let i = 0; i < history.length; i++) {
|
||
let content = history[i].content;
|
||
await parseMessageContent(content);
|
||
}
|
||
|
||
messages.value = history;
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function buildBackendMessageParts(
|
||
prompt: string,
|
||
stagedFiles: { attachment_id: string; url: string; original_name: string; type: string }[],
|
||
audioAttachmentId: string,
|
||
replyTo: ReplyInfo | null
|
||
): MessagePart[] {
|
||
const parts: MessagePart[] = [];
|
||
|
||
if (replyTo) {
|
||
parts.push({
|
||
type: 'reply',
|
||
message_id: replyTo.messageId,
|
||
selected_text: replyTo.selectedText
|
||
});
|
||
}
|
||
|
||
if (prompt) {
|
||
parts.push({
|
||
type: 'plain',
|
||
text: prompt
|
||
});
|
||
}
|
||
|
||
for (const f of stagedFiles) {
|
||
const partType = f.type === 'image' ? 'image' :
|
||
f.type === 'record' ? 'record' : 'file';
|
||
parts.push({
|
||
type: partType as 'image' | 'record' | 'file',
|
||
attachment_id: f.attachment_id
|
||
});
|
||
}
|
||
|
||
if (audioAttachmentId) {
|
||
parts.push({
|
||
type: 'record',
|
||
attachment_id: audioAttachmentId
|
||
});
|
||
}
|
||
|
||
return parts;
|
||
}
|
||
|
||
async function sendMessageViaSSE(
|
||
messageToSend: string | MessagePart[],
|
||
selectedProviderId: string,
|
||
selectedModelName: string
|
||
) {
|
||
const controller = new AbortController();
|
||
currentRequestController.value = controller;
|
||
|
||
const response = await fetch('/api/chat/send', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||
},
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
message: messageToSend,
|
||
session_id: currSessionId.value,
|
||
selected_provider: selectedProviderId,
|
||
selected_model: selectedModelName,
|
||
enable_streaming: enableStreaming.value
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body!.getReader();
|
||
currentReader.value = reader;
|
||
const decoder = new TextDecoder();
|
||
const processChunk = createStreamChunkProcessor();
|
||
|
||
isStreaming.value = true;
|
||
|
||
while (true) {
|
||
try {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
if (currSessionId.value) {
|
||
await getSessionMessages(currSessionId.value);
|
||
}
|
||
break;
|
||
}
|
||
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
const lines = chunk.split('\n\n');
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
let line = lines[i].trim();
|
||
if (!line) continue;
|
||
|
||
let chunkJson: StreamChunk;
|
||
try {
|
||
chunkJson = JSON.parse(line.replace('data: ', ''));
|
||
} catch (parseError) {
|
||
console.warn('JSON解析失败:', line, parseError);
|
||
continue;
|
||
}
|
||
|
||
await processChunk(chunkJson);
|
||
}
|
||
} catch (readError) {
|
||
if (!userStopRequested.value) {
|
||
console.error('SSE读取错误:', readError);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function sendMessageViaWebSocket(
|
||
messageParts: MessagePart[],
|
||
selectedProviderId: string,
|
||
selectedModelName: string
|
||
) {
|
||
await bindSessionToWebSocket(currSessionId.value);
|
||
const ws = await ensureChatWebSocket();
|
||
const messageId = generateMessageId();
|
||
currentWsMessageId.value = messageId;
|
||
|
||
const processChunk = createStreamChunkProcessor();
|
||
|
||
isStreaming.value = true;
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
let finished = false;
|
||
|
||
const finish = (err?: unknown) => {
|
||
if (finished) {
|
||
return;
|
||
}
|
||
finished = true;
|
||
wsContexts.delete(messageId);
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve();
|
||
}
|
||
};
|
||
|
||
wsContexts.set(messageId, {
|
||
handleChunk: processChunk,
|
||
finish
|
||
});
|
||
|
||
try {
|
||
ws.send(JSON.stringify({
|
||
ct: 'chat',
|
||
t: 'send',
|
||
message_id: messageId,
|
||
session_id: currSessionId.value,
|
||
message: messageParts,
|
||
selected_provider: selectedProviderId,
|
||
selected_model: selectedModelName,
|
||
enable_streaming: enableStreaming.value
|
||
}));
|
||
} catch (err) {
|
||
finish(err);
|
||
}
|
||
});
|
||
|
||
if (currSessionId.value) {
|
||
await getSessionMessages(currSessionId.value);
|
||
}
|
||
}
|
||
|
||
async function sendMessage(
|
||
prompt: string,
|
||
stagedFiles: { attachment_id: string; url: string; original_name: string; type: string }[],
|
||
audioAttachmentId: string,
|
||
selectedProviderId: string,
|
||
selectedModelName: string,
|
||
replyTo: ReplyInfo | null = null
|
||
) {
|
||
const userMessageParts: MessagePart[] = [];
|
||
|
||
if (replyTo) {
|
||
userMessageParts.push({
|
||
type: 'reply',
|
||
message_id: replyTo.messageId,
|
||
selected_text: replyTo.selectedText
|
||
});
|
||
}
|
||
|
||
if (prompt) {
|
||
userMessageParts.push({
|
||
type: 'plain',
|
||
text: prompt
|
||
});
|
||
}
|
||
|
||
for (const f of stagedFiles) {
|
||
const partType = f.type === 'image' ? 'image' :
|
||
f.type === 'record' ? 'record' : 'file';
|
||
|
||
const embeddedUrl = await getAttachment(f.attachment_id);
|
||
|
||
userMessageParts.push({
|
||
type: partType as 'image' | 'record' | 'file',
|
||
attachment_id: f.attachment_id,
|
||
filename: f.original_name,
|
||
embedded_url: partType !== 'file' ? embeddedUrl : undefined,
|
||
embedded_file: partType === 'file' ? {
|
||
attachment_id: f.attachment_id,
|
||
filename: f.original_name
|
||
} : undefined
|
||
});
|
||
}
|
||
|
||
if (audioAttachmentId) {
|
||
const embeddedUrl = await getAttachment(audioAttachmentId);
|
||
userMessageParts.push({
|
||
type: 'record',
|
||
attachment_id: audioAttachmentId,
|
||
embedded_url: embeddedUrl
|
||
});
|
||
}
|
||
|
||
const userMessage: MessageContent = {
|
||
type: 'user',
|
||
message: userMessageParts
|
||
};
|
||
|
||
messages.value.push({ content: userMessage });
|
||
|
||
const loadingMessage = reactive<MessageContent>({
|
||
type: 'bot',
|
||
message: [],
|
||
reasoning: '',
|
||
isLoading: true
|
||
});
|
||
messages.value.push({ content: loadingMessage });
|
||
|
||
try {
|
||
activeStreamCount.value++;
|
||
if (activeStreamCount.value === 1) {
|
||
isConvRunning.value = true;
|
||
}
|
||
|
||
userStopRequested.value = false;
|
||
currentRunningSessionId.value = currSessionId.value;
|
||
|
||
const backendMessageParts = buildBackendMessageParts(
|
||
prompt,
|
||
stagedFiles,
|
||
audioAttachmentId,
|
||
replyTo
|
||
);
|
||
const hasAttachmentOrReply = stagedFiles.length > 0 || !!audioAttachmentId || !!replyTo;
|
||
|
||
if (transportMode.value === 'websocket') {
|
||
await sendMessageViaWebSocket(
|
||
backendMessageParts,
|
||
selectedProviderId,
|
||
selectedModelName
|
||
);
|
||
} else {
|
||
const messageToSend: string | MessagePart[] = hasAttachmentOrReply
|
||
? backendMessageParts
|
||
: prompt;
|
||
await sendMessageViaSSE(
|
||
messageToSend,
|
||
selectedProviderId,
|
||
selectedModelName
|
||
);
|
||
}
|
||
|
||
onSessionsUpdate();
|
||
|
||
} catch (err) {
|
||
if (!userStopRequested.value) {
|
||
console.error('发送消息失败:', err);
|
||
}
|
||
const lastMsg = messages.value[messages.value.length - 1];
|
||
if (lastMsg?.content?.isLoading) {
|
||
messages.value.pop();
|
||
}
|
||
} finally {
|
||
isStreaming.value = false;
|
||
currentReader.value = null;
|
||
currentRequestController.value = null;
|
||
currentRunningSessionId.value = '';
|
||
currentWsMessageId.value = '';
|
||
userStopRequested.value = false;
|
||
activeStreamCount.value--;
|
||
if (activeStreamCount.value === 0) {
|
||
isConvRunning.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function stopMessage() {
|
||
const sessionId = currentRunningSessionId.value || currSessionId.value;
|
||
if (!sessionId) {
|
||
return;
|
||
}
|
||
|
||
userStopRequested.value = true;
|
||
|
||
try {
|
||
await axios.post('/api/chat/stop', {
|
||
session_id: sessionId
|
||
});
|
||
} catch (err) {
|
||
console.error('停止会话失败:', err);
|
||
}
|
||
|
||
if (transportMode.value === 'websocket' && currentWebSocket.value?.readyState === WebSocket.OPEN) {
|
||
try {
|
||
currentWebSocket.value.send(JSON.stringify({
|
||
ct: 'chat',
|
||
t: 'interrupt',
|
||
session_id: sessionId,
|
||
message_id: currentWsMessageId.value || undefined
|
||
}));
|
||
} catch (err) {
|
||
console.error('发送 websocket interrupt 失败:', err);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await currentReader.value?.cancel();
|
||
} catch {
|
||
// ignore reader cancel failures
|
||
}
|
||
currentReader.value = null;
|
||
currentRequestController.value?.abort();
|
||
currentRequestController.value = null;
|
||
|
||
isStreaming.value = false;
|
||
}
|
||
|
||
function cleanupTransport() {
|
||
closeChatWebSocket();
|
||
}
|
||
|
||
return {
|
||
messages,
|
||
isStreaming,
|
||
isConvRunning,
|
||
enableStreaming,
|
||
transportMode,
|
||
currentSessionProject,
|
||
getSessionMessages,
|
||
sendMessage,
|
||
stopMessage,
|
||
toggleStreaming,
|
||
setTransportMode,
|
||
cleanupTransport,
|
||
getAttachment
|
||
};
|
||
}
|