feat: 增加AI回复手动重试

This commit is contained in:
2026-08-03 17:55:02 +08:00
parent 8e6da99dcb
commit 1954f461af
10 changed files with 234 additions and 32 deletions

View File

@@ -268,7 +268,13 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
waitingCount=queue_request.waiting_count, waitingCount=queue_request.waiting_count,
reasoningVisible=reasoning_visible, reasoningVisible=reasoning_visible,
) )
chunks = ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message) chunks = ChatStreamService.stream_answer_async(
db,
current_user,
payload.sessionId,
payload.message,
retry_failed_question=payload.retry,
)
async for segment in ReasoningPolicyService.iter_segments(chunks): async for segment in ReasoningPolicyService.iter_segments(chunks):
if segment.kind == "content": if segment.kind == "content":
yield _sse_event("content", content=segment.content) yield _sse_event("content", content=segment.content)

View File

@@ -37,6 +37,7 @@ class UpdateSessionTitleRequest(BaseModel):
class ChatCompletionRequest(BaseModel): class ChatCompletionRequest(BaseModel):
sessionId: int sessionId: int
message: str = Field(min_length=1, max_length=4000) message: str = Field(min_length=1, max_length=4000)
retry: bool = False
class StopChatRequest(BaseModel): class StopChatRequest(BaseModel):

View File

@@ -10,7 +10,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.chat import ChatMessage from app.models.chat import ChatMessage, TopicSession
from app.models.knowledge import KnowledgeRetrievalLog from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService from app.services.ai_request_log_service import AiRequestLogService
@@ -212,7 +212,14 @@ class ChatStreamService:
db.commit() db.commit()
@staticmethod @staticmethod
async def stream_answer_async(db: Session, user: User, session_id: int, question: str) -> AsyncIterator[str]: async def stream_answer_async(
db: Session,
user: User,
session_id: int,
question: str,
*,
retry_failed_question: bool = False,
) -> AsyncIterator[str]:
user = ChatService.prepare_daily_quota(db, user) user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id) session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user) ChatService._ensure_quota(user)
@@ -225,25 +232,37 @@ class ChatStreamService:
now = _now() now = _now()
normalized_question = question.strip() normalized_question = question.strip()
topic = TopicSessionService.get_or_create_active( user_message = (
db, _retryable_user_message(
user=user, db,
session=session, session_id=session.id,
question=normalized_question, user_id=user.id,
deduct_quota=entitlement.deduct_quota, question=normalized_question,
)
if retry_failed_question
else None
) )
user_message = ChatMessage( topic = db.get(TopicSession, user_message.topic_session_id) if user_message and user_message.topic_session_id else None
session_id=session.id, if user_message is None or topic is None:
topic_session_id=topic.id, topic = TopicSessionService.get_or_create_active(
user_id=user.id, db,
role="user", user=user,
content=normalized_question, session=session,
message_status="FINISHED", question=normalized_question,
created_at=now, deduct_quota=entitlement.deduct_quota,
) )
db.add(user_message) user_message = ChatMessage(
TopicSessionService.attach_user_message(user_message, topic) session_id=session.id,
db.flush() topic_session_id=topic.id,
user_id=user.id,
role="user",
content=normalized_question,
message_status="FINISHED",
created_at=now,
)
db.add(user_message)
TopicSessionService.attach_user_message(user_message, topic)
db.flush()
history = list( history = list(
db.scalars( db.scalars(
@@ -362,6 +381,27 @@ def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None) return datetime.now(UTC).replace(tzinfo=None)
def _retryable_user_message(
db: Session,
*,
session_id: int,
user_id: int,
question: str,
) -> ChatMessage | None:
"""Reuse only the latest unanswered user turn to avoid duplicate retry messages."""
latest = db.scalar(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.user_id == user_id)
.order_by(ChatMessage.id.desc())
.limit(1)
)
if latest is None or latest.role != "user":
return None
if latest.content.strip() != question.strip():
return None
return latest
def _rough_token_count(text: str) -> int: def _rough_token_count(text: str) -> int:
return max(1, len(text.strip()) // 2) return max(1, len(text.strip()) // 2)

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.chat import ChatMessage, ChatSession
from app.services.chat_stream_service import _retryable_user_message
def _db() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
return Session(engine)
def test_retry_reuses_only_latest_unanswered_matching_user_message():
with _db() as db:
session = ChatSession(id=1, user_id=7, title="重试测试", message_count=0, is_deleted=0)
user_message = ChatMessage(
id=1,
session_id=1,
user_id=7,
role="user",
content="原来的问题",
message_status="FINISHED",
)
db.add_all([session, user_message])
db.commit()
retried = _retryable_user_message(
db,
session_id=1,
user_id=7,
question=" 原来的问题 ",
)
assert retried is not None
assert retried.id == user_message.id
assert _retryable_user_message(db, session_id=1, user_id=7, question="另一个问题") is None
db.add(
ChatMessage(
id=2,
session_id=1,
user_id=7,
role="assistant",
content="已经回答",
message_status="FINISHED",
)
)
db.commit()
assert _retryable_user_message(db, session_id=1, user_id=7, question="原来的问题") is None

View File

@@ -127,7 +127,7 @@ def test_queued_chat_reports_position_and_completes(monkeypatch):
monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer) monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer)
monkeypatch.setattr(chat.ReasoningPolicyService, "is_visible", lambda _db: False) monkeypatch.setattr(chat.ReasoningPolicyService, "is_visible", lambda _db: False)
payload = type("Payload", (), {"sessionId": 1, "message": "问题"})() payload = type("Payload", (), {"sessionId": 1, "message": "问题", "retry": False})()
async def collect_events(): async def collect_events():
return [item async for item in chat._chat_stream(payload, object(), object())] return [item async for item in chat._chat_stream(payload, object(), object())]

View File

@@ -137,7 +137,8 @@ async function selectSession(sessionId: number, force = false) {
} }
async function send(message: string, complete: (success: boolean) => void) { async function send(message: string, complete: (success: boolean) => void) {
if (!activeSessionId.value || sending.value) { const sessionId = activeSessionId.value;
if (!sessionId || sending.value) {
complete(false); complete(false);
return; return;
} }
@@ -152,15 +153,35 @@ async function send(message: string, complete: (success: boolean) => void) {
}; };
messages.value.push(userMessage, assistantMessage); messages.value.push(userMessage, assistantMessage);
const assistantIndex = messages.value.length - 1; const assistantIndex = messages.value.length - 1;
complete(true);
await messageList.value?.scrollToMessage(userMessage.id);
await runGeneration(sessionId, message, assistantIndex, false);
}
async function retryMessage(messageId: string) {
const sessionId = activeSessionId.value;
if (!sessionId || sending.value) return;
const assistantIndex = messages.value.findIndex((item) => item.id === messageId);
const assistantMessage = messages.value[assistantIndex];
if (!assistantMessage?.retryQuestion) return;
await messageList.value?.scrollToMessage(messageId);
await runGeneration(sessionId, assistantMessage.retryQuestion, assistantIndex, true);
}
async function runGeneration(sessionId: number, message: string, assistantIndex: number, retry: boolean) {
const currentAssistant = () => messages.value[assistantIndex]; const currentAssistant = () => messages.value[assistantIndex];
currentAssistant().content = "";
currentAssistant().reasoning = "";
currentAssistant().showReasoning = false;
currentAssistant().errorMessage = undefined;
currentAssistant().streaming = true;
sending.value = true; sending.value = true;
followingOutput.value = true; followingOutput.value = true;
activeAbortController.value = new AbortController(); activeAbortController.value = new AbortController();
let hasContent = false; let hasContent = false;
await messageList.value?.scrollToMessage(userMessage.id);
try { try {
await streamChat( await streamChat(
activeSessionId.value, sessionId,
message, message,
async (chunk) => { async (chunk) => {
if (!hasContent) { if (!hasContent) {
@@ -183,21 +204,22 @@ async function send(message: string, complete: (success: boolean) => void) {
if (followingOutput.value) await messageList.value?.scrollToBottom(); if (followingOutput.value) await messageList.value?.scrollToBottom();
}, },
activeAbortController.value.signal, activeAbortController.value.signal,
retry,
); );
currentAssistant().streaming = false; currentAssistant().streaming = false;
currentAssistant().retryQuestion = undefined;
currentAssistant().createdAt = new Date().toISOString(); currentAssistant().createdAt = new Date().toISOString();
complete(true);
await refreshSessionList(); await refreshSessionList();
await refreshProfile(); await refreshProfile();
} catch (error) { } catch (error) {
currentAssistant().streaming = false; currentAssistant().streaming = false;
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
currentAssistant().content = currentAssistant().content || "已停止生成"; currentAssistant().content = currentAssistant().content || "已停止生成";
complete(true); currentAssistant().retryQuestion = undefined;
} else { } else {
currentAssistant().content = currentAssistant().content || "回答生成失败,请稍后重试。"; currentAssistant().errorMessage = chatErrorMessage(error);
complete(false); currentAssistant().retryQuestion = message;
handleError(error, "AI 回复失败"); showToast("本次回答未完成,可以点击重新生成");
} }
} finally { } finally {
sending.value = false; sending.value = false;
@@ -205,6 +227,14 @@ async function send(message: string, complete: (success: boolean) => void) {
} }
} }
function chatErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "";
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多AI 服务暂时繁忙。";
if (/timeout|超时/i.test(message)) return "等待 AI 回复超时,请稍后重新生成。";
if (/network|网络|连接/i.test(message)) return "网络连接不稳定,本次回答没有完成。";
return "AI 回答生成失败,本次不会扣除成功回答额度。";
}
async function stop() { async function stop() {
if (!activeSessionId.value || !sending.value) return; if (!activeSessionId.value || !sending.value) return;
activeAbortController.value?.abort(); activeAbortController.value?.abort();
@@ -551,6 +581,7 @@ async function copyText(text: string) {
:messages="messages" :messages="messages"
:loading-session="loadingSession" :loading-session="loadingSession"
@follow-change="followingOutput = $event" @follow-change="followingOutput = $event"
@retry="retryMessage"
/> />
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" /> <ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<SessionDrawer <SessionDrawer

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Bot } from "@lucide/vue"; import { Bot, RotateCcw } from "@lucide/vue";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { computed } from "vue"; import { computed } from "vue";
@@ -11,6 +11,12 @@ const props = defineProps<{
showReasoning?: boolean; showReasoning?: boolean;
createdAt: string; createdAt: string;
streaming?: boolean; streaming?: boolean;
errorMessage?: string;
canRetry?: boolean;
}>();
const emit = defineEmits<{
retry: [];
}>(); }>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true }); const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
@@ -95,6 +101,13 @@ const displayTime = computed(() => {
<span></span><span></span><span></span> <span></span><span></span><span></span>
思考中 思考中
</div> </div>
<div v-if="errorMessage" class="message-error-state" role="alert">
<span>{{ errorMessage }}</span>
<button v-if="canRetry" type="button" :disabled="streaming" @click="emit('retry')">
<RotateCcw :size="15" aria-hidden="true" />
重新生成
</button>
</div>
</template> </template>
<div v-else class="message-content">{{ renderedContent }}</div> <div v-else class="message-content">{{ renderedContent }}</div>
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time> <time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>

View File

@@ -11,6 +11,8 @@ export interface DisplayMessage {
showReasoning?: boolean; showReasoning?: boolean;
createdAt: string; createdAt: string;
streaming?: boolean; streaming?: boolean;
errorMessage?: string;
retryQuestion?: string;
} }
defineProps<{ defineProps<{
@@ -20,6 +22,7 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
followChange: [following: boolean]; followChange: [following: boolean];
retry: [messageId: string];
}>(); }>();
const scroller = ref<HTMLElement | null>(null); const scroller = ref<HTMLElement | null>(null);
@@ -63,6 +66,9 @@ defineExpose({ scrollToBottom, scrollToMessage });
:show-reasoning="message.showReasoning" :show-reasoning="message.showReasoning"
:created-at="message.createdAt" :created-at="message.createdAt"
:streaming="message.streaming" :streaming="message.streaming"
:error-message="message.errorMessage"
:can-retry="Boolean(message.retryQuestion)"
@retry="emit('retry', message.id)"
/> />
</template> </template>
</section> </section>

View File

@@ -123,6 +123,7 @@ export async function streamChat(
onReasoning?: (chunk: string) => void, onReasoning?: (chunk: string) => void,
onStatus?: (message: string, type: string, reasoningVisible: boolean) => void, onStatus?: (message: string, type: string, reasoningVisible: boolean) => void,
signal?: AbortSignal, signal?: AbortSignal,
retry = false,
) { ) {
const token = getToken(); const token = getToken();
const response = await fetch(`${API_BASE}/chat/completions`, { const response = await fetch(`${API_BASE}/chat/completions`, {
@@ -131,7 +132,7 @@ export async function streamChat(
"Content-Type": "application/json", "Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ sessionId, message }), body: JSON.stringify({ sessionId, message, retry }),
signal, signal,
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {

View File

@@ -1400,6 +1400,51 @@ textarea:focus-visible {
.generation-state span:nth-child(2) { animation-delay: 0.14s; } .generation-state span:nth-child(2) { animation-delay: 0.14s; }
.generation-state span:nth-child(3) { animation-delay: 0.28s; margin-right: 4px; } .generation-state span:nth-child(3) { animation-delay: 0.28s; margin-right: 4px; }
.message-error-state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9px 12px;
margin-top: 10px;
padding: 10px 11px;
border: 1px solid #ead5cf;
border-radius: 11px;
background: #fff8f5;
color: #8a5043;
font-size: 13px;
line-height: 1.5;
}
.message-error-state span {
flex: 1 1 190px;
}
.message-error-state button {
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 11px;
border: 1px solid #bf7765;
border-radius: 9px;
background: #ffffff;
color: #8a4434;
font: inherit;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.message-error-state button:hover {
background: #fff0eb;
}
.message-error-state button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@keyframes generation-pulse { @keyframes generation-pulse {
0%, 70%, 100% { opacity: 0.25; transform: translateY(0); } 0%, 70%, 100% { opacity: 0.25; transform: translateY(0); }
35% { opacity: 1; transform: translateY(-2px); } 35% { opacity: 1; transform: translateY(-2px); }