Files
AstrBot/dashboard/src/composables/useMessages.ts
Soulter 224287e170 feat: add audio input support across providers and chatui recording issue fix (#7378)
* 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]"
2026-04-06 14:58:29 +08:00

1110 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}