Implement chat queue fallback

This commit is contained in:
2026-07-08 16:57:40 +08:00
parent 10b6de0622
commit 6f3bbd3803
10 changed files with 367 additions and 31 deletions

View File

@@ -84,12 +84,30 @@ async function send(message: string) {
messages.value.push(userMessage, assistantMessage);
loading.value = true;
activeAbortController.value = new AbortController();
let hasContent = false;
await scrollToBottom();
try {
await streamChat(activeSessionId.value, message, async (chunk) => {
assistantMessage.content += chunk;
await scrollToBottom();
}, activeAbortController.value.signal);
await streamChat(
activeSessionId.value,
message,
async (chunk) => {
if (!hasContent) {
assistantMessage.content = "";
hasContent = true;
}
assistantMessage.content += chunk;
await scrollToBottom();
},
async (statusMessage, statusType) => {
if (statusType === "queued") {
assistantMessage.content = statusMessage || "当前请求较多,正在排队中。";
} else if (statusType === "generating" && !hasContent) {
assistantMessage.content = "";
}
await scrollToBottom();
},
activeAbortController.value.signal,
);
assistantMessage.streaming = false;
await refreshSessions(activeSessionId.value);
user.value = await api.profile();

View File

@@ -18,24 +18,36 @@ const assistantParts = computed(() => {
if (props.role === "user") {
return { reasoning: "", answer: props.content };
}
const matched = props.content.match(/<think>([\s\S]*?)<\/think>/i);
if (matched) {
return {
reasoning: matched[1].trim(),
answer: props.content.replace(matched[0], "").trim(),
};
}
const opening = props.content.match(/<think>([\s\S]*)$/i);
if (opening) {
return {
reasoning: opening[1].trim(),
answer: props.content.slice(0, opening.index).trim(),
};
}
return { reasoning: "", answer: props.content };
});
const renderedReasoning = computed(() => markdown.render(assistantParts.value.reasoning || ""));
const content = props.content;
let reasoning = "";
let answer = content;
// 匹配所有完整的 <think>...</think>(全局匹配,支持多个)
const thinkRegex = /<think>([\s\S]*?)<\/think>/gi;
const matches = Array.from(answer.matchAll(thinkRegex));
if (matches.length > 0) {
reasoning = matches.map((m) => m[1].trim()).join("\n\n");
answer = answer.replace(thinkRegex, "").trim();
}
// 处理未闭合的 <think>(流式输出时可能还没收到 </think>
const openThink = answer.match(/<think>([\s\S]*)$/i);
if (openThink) {
reasoning += (reasoning ? "\n\n" : "") + openThink[1].trim();
answer = answer.slice(0, openThink.index).trim();
}
// 兜底:如果 answer 为空但 reasoning 有内容,说明模型可能把全部内容放在 think 里了
// 此时把 reasoning 当 answer 显示,不显示 thinking 面板
if (!answer && reasoning) {
answer = reasoning;
reasoning = "";
}
return { reasoning, answer };
});
const renderedContent = computed(() => {
if (props.role === "user") return props.content;
@@ -48,10 +60,9 @@ const renderedContent = computed(() => {
<div class="avatar">{{ role === "assistant" ? "AI" : "我" }}</div>
<div class="bubble">
<template v-if="role === 'assistant'">
<details v-if="assistantParts.reasoning" class="reasoning-panel" :open="streaming">
<summary>{{ streaming ? "思考中" : "思考过程" }}</summary>
<div class="markdown-content reasoning-content" v-html="renderedReasoning"></div>
</details>
<div v-if="streaming && assistantParts.reasoning" class="thinking-indicator">
<span class="thinking-dots">思考中</span>
</div>
<div class="content markdown-content" v-html="renderedContent"></div>
</template>
<div v-else class="content">{{ renderedContent }}</div>

View File

@@ -49,6 +49,7 @@ export async function streamChat(
sessionId: number,
message: string,
onChunk: (chunk: string) => void,
onStatus?: (message: string, type: string) => void,
signal?: AbortSignal,
) {
const token = getToken();
@@ -79,7 +80,14 @@ export async function streamChat(
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
const parsed = JSON.parse(payload) as { content?: string };
const parsed = JSON.parse(payload) as { type?: string; content?: string; message?: string };
if (parsed.type === "queued" || parsed.type === "generating") {
onStatus?.(parsed.message || "", parsed.type);
continue;
}
if (parsed.type === "error") {
throw new Error(parsed.message || "AI 回复失败");
}
if (parsed.content) onChunk(parsed.content);
}
}

View File

@@ -393,6 +393,31 @@ button:disabled {
font-size: 12px;
}
.thinking-indicator {
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 8px;
background: #f2f7f5;
color: #5d7169;
font-size: 13px;
}
.thinking-dots::after {
content: "";
display: inline-block;
width: 3px;
height: 3px;
margin-left: 4px;
border-radius: 50%;
background: #0f8b6f;
animation: thinking-dot 1.2s infinite;
}
@keyframes thinking-dot {
0%, 100% { opacity: 0.2; }
50% { opacity: 1; }
}
.composer {
position: absolute;
left: 0;