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,
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):
if segment.kind == "content":
yield _sse_event("content", content=segment.content)

View File

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

View File

@@ -10,7 +10,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select
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.user import User
from app.services.ai_request_log_service import AiRequestLogService
@@ -212,7 +212,14 @@ class ChatStreamService:
db.commit()
@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)
session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user)
@@ -225,25 +232,37 @@ class ChatStreamService:
now = _now()
normalized_question = question.strip()
topic = TopicSessionService.get_or_create_active(
db,
user=user,
session=session,
question=normalized_question,
deduct_quota=entitlement.deduct_quota,
user_message = (
_retryable_user_message(
db,
session_id=session.id,
user_id=user.id,
question=normalized_question,
)
if retry_failed_question
else None
)
user_message = ChatMessage(
session_id=session.id,
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()
topic = db.get(TopicSession, user_message.topic_session_id) if user_message and user_message.topic_session_id else None
if user_message is None or topic is None:
topic = TopicSessionService.get_or_create_active(
db,
user=user,
session=session,
question=normalized_question,
deduct_quota=entitlement.deduct_quota,
)
user_message = ChatMessage(
session_id=session.id,
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(
db.scalars(
@@ -362,6 +381,27 @@ def _now() -> datetime:
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:
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.ReasoningPolicyService, "is_visible", lambda _db: False)
payload = type("Payload", (), {"sessionId": 1, "message": "问题"})()
payload = type("Payload", (), {"sessionId": 1, "message": "问题", "retry": False})()
async def collect_events():
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) {
if (!activeSessionId.value || sending.value) {
const sessionId = activeSessionId.value;
if (!sessionId || sending.value) {
complete(false);
return;
}
@@ -152,15 +153,35 @@ async function send(message: string, complete: (success: boolean) => void) {
};
messages.value.push(userMessage, assistantMessage);
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];
currentAssistant().content = "";
currentAssistant().reasoning = "";
currentAssistant().showReasoning = false;
currentAssistant().errorMessage = undefined;
currentAssistant().streaming = true;
sending.value = true;
followingOutput.value = true;
activeAbortController.value = new AbortController();
let hasContent = false;
await messageList.value?.scrollToMessage(userMessage.id);
try {
await streamChat(
activeSessionId.value,
sessionId,
message,
async (chunk) => {
if (!hasContent) {
@@ -183,21 +204,22 @@ async function send(message: string, complete: (success: boolean) => void) {
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
activeAbortController.value.signal,
retry,
);
currentAssistant().streaming = false;
currentAssistant().retryQuestion = undefined;
currentAssistant().createdAt = new Date().toISOString();
complete(true);
await refreshSessionList();
await refreshProfile();
} catch (error) {
currentAssistant().streaming = false;
if (error instanceof DOMException && error.name === "AbortError") {
currentAssistant().content = currentAssistant().content || "已停止生成";
complete(true);
currentAssistant().retryQuestion = undefined;
} else {
currentAssistant().content = currentAssistant().content || "回答生成失败,请稍后重试。";
complete(false);
handleError(error, "AI 回复失败");
currentAssistant().errorMessage = chatErrorMessage(error);
currentAssistant().retryQuestion = message;
showToast("本次回答未完成,可以点击重新生成");
}
} finally {
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() {
if (!activeSessionId.value || !sending.value) return;
activeAbortController.value?.abort();
@@ -551,6 +581,7 @@ async function copyText(text: string) {
:messages="messages"
:loading-session="loadingSession"
@follow-change="followingOutput = $event"
@retry="retryMessage"
/>
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<SessionDrawer

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Bot } from "@lucide/vue";
import { Bot, RotateCcw } from "@lucide/vue";
import MarkdownIt from "markdown-it";
import { computed } from "vue";
@@ -11,6 +11,12 @@ const props = defineProps<{
showReasoning?: boolean;
createdAt: string;
streaming?: boolean;
errorMessage?: string;
canRetry?: boolean;
}>();
const emit = defineEmits<{
retry: [];
}>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
@@ -95,6 +101,13 @@ const displayTime = computed(() => {
<span></span><span></span><span></span>
思考中
</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>
<div v-else class="message-content">{{ renderedContent }}</div>
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>

View File

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

View File

@@ -123,6 +123,7 @@ export async function streamChat(
onReasoning?: (chunk: string) => void,
onStatus?: (message: string, type: string, reasoningVisible: boolean) => void,
signal?: AbortSignal,
retry = false,
) {
const token = getToken();
const response = await fetch(`${API_BASE}/chat/completions`, {
@@ -131,7 +132,7 @@ export async function streamChat(
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ sessionId, message }),
body: JSON.stringify({ sessionId, message, retry }),
signal,
});
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(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 {
0%, 70%, 100% { opacity: 0.25; transform: translateY(0); }
35% { opacity: 1; transform: translateY(-2px); }