Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4cb62b490 | ||
|
|
59fc78332d | ||
|
|
5c616a9d03 |
+68
-16
@@ -58,6 +58,9 @@ function reviewIdFromPath(): string | null {
|
|||||||
* actually plays even when the session restores instantly. */
|
* actually plays even when the session restores instantly. */
|
||||||
const MIN_SPLASH_MS = 1100;
|
const MIN_SPLASH_MS = 1100;
|
||||||
|
|
||||||
|
/** Max footer-arrow retries for the same failed model prompt. */
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
function applyTheme(theme: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const dark =
|
const dark =
|
||||||
@@ -102,6 +105,10 @@ export function App() {
|
|||||||
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
|
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
|
||||||
const [previewBusy, setPreviewBusy] = useState(false);
|
const [previewBusy, setPreviewBusy] = useState(false);
|
||||||
const [ocrAvailable, setOcrAvailable] = useState(false);
|
const [ocrAvailable, setOcrAvailable] = useState(false);
|
||||||
|
// Model-error retry: which trailing message is the error bubble (if any),
|
||||||
|
// its conversation, and how many times this prompt was already retried.
|
||||||
|
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
|
||||||
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
||||||
@@ -199,6 +206,8 @@ export function App() {
|
|||||||
|
|
||||||
// Load messages for active conversation
|
// Load messages for active conversation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
return;
|
return;
|
||||||
@@ -248,6 +257,8 @@ export function App() {
|
|||||||
setChecking(false);
|
setChecking(false);
|
||||||
setConvs([]);
|
setConvs([]);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
@@ -265,6 +276,24 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stream one assistant reply for an exact history; resolves its text. */
|
||||||
|
async function streamReply(history: ChatMessage[], convId: string): Promise<string> {
|
||||||
|
// "Thinking" feature flag: decorate the outgoing payload only — the
|
||||||
|
// system message is never shown nor persisted client-side.
|
||||||
|
const outMessages = featureEnabled("thinking")
|
||||||
|
? [
|
||||||
|
{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage,
|
||||||
|
...history,
|
||||||
|
]
|
||||||
|
: history;
|
||||||
|
let full = "";
|
||||||
|
await api.chatStream({ model, messages: outMessages, conversationId: convId }, (t) => {
|
||||||
|
full += t;
|
||||||
|
setStreaming(full);
|
||||||
|
});
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
async function send(text: string) {
|
async function send(text: string) {
|
||||||
if (sending || !model || archivedConv) return;
|
if (sending || !model || archivedConv) return;
|
||||||
let convId = activeId;
|
let convId = activeId;
|
||||||
@@ -291,25 +320,12 @@ export function App() {
|
|||||||
const history = [...messages, userMsg];
|
const history = [...messages, userMsg];
|
||||||
setMessages(history);
|
setMessages(history);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
let full = "";
|
|
||||||
try {
|
try {
|
||||||
// "Thinking" feature flag: decorate the outgoing payload only — the
|
const full = await streamReply(history, convId!);
|
||||||
// system message is never shown nor persisted client-side.
|
|
||||||
const outMessages = featureEnabled("thinking")
|
|
||||||
? [
|
|
||||||
{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage,
|
|
||||||
...history,
|
|
||||||
]
|
|
||||||
: history;
|
|
||||||
await api.chatStream(
|
|
||||||
{ model, messages: outMessages, conversationId: convId! },
|
|
||||||
(t) => {
|
|
||||||
full += t;
|
|
||||||
setStreaming(full);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setMessages([...history, { role: "assistant", content: full }]);
|
setMessages([...history, { role: "assistant", content: full }]);
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
await refreshConvs();
|
await refreshConvs();
|
||||||
@@ -318,6 +334,38 @@ export function App() {
|
|||||||
...history,
|
...history,
|
||||||
{ role: "assistant", content: t("attach.chatError") },
|
{ role: "assistant", content: t("attach.chatError") },
|
||||||
]);
|
]);
|
||||||
|
setFailed({ index: history.length, convId: convId! });
|
||||||
|
setStreaming("");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run the same prompt after a model error (footer retry arrow on the
|
||||||
|
* error bubble). The trailing error message is dropped and the untouched
|
||||||
|
* history is streamed again — at most MAX_RETRIES times per prompt.
|
||||||
|
*/
|
||||||
|
async function retryFailed() {
|
||||||
|
if (sending || !failed || retryCount >= MAX_RETRIES || !model || archivedConv) return;
|
||||||
|
const history = messages.slice(0, failed.index);
|
||||||
|
const convId = failed.convId;
|
||||||
|
setMessages(history);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount((c) => c + 1);
|
||||||
|
setSending(true);
|
||||||
|
setStreaming("");
|
||||||
|
try {
|
||||||
|
const full = await streamReply(history, convId);
|
||||||
|
setMessages([...history, { role: "assistant", content: full }]);
|
||||||
|
setRetryCount(0);
|
||||||
|
await refreshConvs();
|
||||||
|
} catch {
|
||||||
|
setMessages([
|
||||||
|
...history,
|
||||||
|
{ role: "assistant", content: t("attach.chatError") },
|
||||||
|
]);
|
||||||
|
setFailed({ index: history.length, convId });
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
@@ -539,6 +587,10 @@ export function App() {
|
|||||||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||||
}
|
}
|
||||||
onPickFile={handlePickFile}
|
onPickFile={handlePickFile}
|
||||||
|
failedIndex={failed?.index ?? null}
|
||||||
|
retryCount={retryCount}
|
||||||
|
maxRetries={MAX_RETRIES}
|
||||||
|
onRetry={readOnly ? undefined : retryFailed}
|
||||||
/>
|
/>
|
||||||
<FilePreviewModal
|
<FilePreviewModal
|
||||||
preview={filePreview}
|
preview={filePreview}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
||||||
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||||
import { api, ApiError, type ReportReason } from "../lib/api.ts";
|
import { api, ApiError, type ReportReason } from "../lib/api.ts";
|
||||||
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||||
@@ -11,7 +11,7 @@ import { useT, type StringKey } from "../lib/i18n.ts";
|
|||||||
|
|
||||||
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
||||||
|
|
||||||
function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void }) {
|
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const isUser = msg.role === "user";
|
const isUser = msg.role === "user";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -43,8 +43,10 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg
|
|||||||
<Markdown text={msg.content} />
|
<Markdown text={msg.content} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isUser && onReport && (
|
{!isUser && (onReport || (isFailed && onRetry)) && (
|
||||||
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
||||||
|
{onReport && (
|
||||||
|
<>
|
||||||
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
|
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
|
||||||
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
|
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -54,6 +56,17 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg
|
|||||||
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
|
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
|
||||||
<Flag size={14} />
|
<Flag size={14} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isFailed && onRetry && (
|
||||||
|
<IconButton
|
||||||
|
title={`${t("msg.retry")} (${Math.min(retryCount ?? 0, maxRetries ?? 0)}/${maxRetries ?? 0})`}
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retryDisabled}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,6 +196,10 @@ export function Chat({
|
|||||||
ocrAvailable,
|
ocrAvailable,
|
||||||
onRemoveAttachment,
|
onRemoveAttachment,
|
||||||
onPickFile,
|
onPickFile,
|
||||||
|
failedIndex,
|
||||||
|
retryCount,
|
||||||
|
maxRetries,
|
||||||
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
conv: Conversation | null;
|
conv: Conversation | null;
|
||||||
@@ -205,6 +222,11 @@ export function Chat({
|
|||||||
ocrAvailable: boolean;
|
ocrAvailable: boolean;
|
||||||
onRemoveAttachment: (id: string) => void;
|
onRemoveAttachment: (id: string) => void;
|
||||||
onPickFile: (kind: "ocr" | "text", file: File) => void;
|
onPickFile: (kind: "ocr" | "text", file: File) => void;
|
||||||
|
/** Trailing model-error bubble index (retry arrow), if any. */
|
||||||
|
failedIndex: number | null;
|
||||||
|
retryCount: number;
|
||||||
|
maxRetries: number;
|
||||||
|
onRetry?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [attachOpen, setAttachOpen] = useState(false);
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
@@ -298,7 +320,7 @@ export function Chat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
||||||
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} />
|
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} />
|
||||||
))}
|
))}
|
||||||
{streaming !== "" && (
|
{streaming !== "" && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ const en = {
|
|||||||
"msg.copy": "Copy message",
|
"msg.copy": "Copy message",
|
||||||
"msg.search": "Search the web",
|
"msg.search": "Search the web",
|
||||||
"msg.report": "Report response",
|
"msg.report": "Report response",
|
||||||
|
"msg.retry": "Retry",
|
||||||
|
|
||||||
"report.title": "Report this response",
|
"report.title": "Report this response",
|
||||||
"report.reason": "Reason",
|
"report.reason": "Reason",
|
||||||
@@ -930,6 +931,7 @@ const es: Dict = {
|
|||||||
"msg.copy": "Copiar mensaje",
|
"msg.copy": "Copiar mensaje",
|
||||||
"msg.search": "Buscar en la web",
|
"msg.search": "Buscar en la web",
|
||||||
"msg.report": "Denunciar respuesta",
|
"msg.report": "Denunciar respuesta",
|
||||||
|
"msg.retry": "Reintentar",
|
||||||
"report.title": "Denunciar esta respuesta",
|
"report.title": "Denunciar esta respuesta",
|
||||||
"report.reason": "Motivo",
|
"report.reason": "Motivo",
|
||||||
"report.reasonPh": "Selecciona un motivo…",
|
"report.reasonPh": "Selecciona un motivo…",
|
||||||
@@ -1430,6 +1432,7 @@ const fr: Dict = {
|
|||||||
"msg.copy": "Copier le message",
|
"msg.copy": "Copier le message",
|
||||||
"msg.search": "Rechercher sur le web",
|
"msg.search": "Rechercher sur le web",
|
||||||
"msg.report": "Signaler la réponse",
|
"msg.report": "Signaler la réponse",
|
||||||
|
"msg.retry": "Réessayer",
|
||||||
"report.title": "Signaler cette réponse",
|
"report.title": "Signaler cette réponse",
|
||||||
"report.reason": "Motif",
|
"report.reason": "Motif",
|
||||||
"report.reasonPh": "Choisir un motif…",
|
"report.reasonPh": "Choisir un motif…",
|
||||||
@@ -1918,6 +1921,7 @@ const ru: Dict = {
|
|||||||
"msg.copy": "Скопировать сообщение",
|
"msg.copy": "Скопировать сообщение",
|
||||||
"msg.search": "Искать в интернете",
|
"msg.search": "Искать в интернете",
|
||||||
"msg.report": "Пожаловаться на ответ",
|
"msg.report": "Пожаловаться на ответ",
|
||||||
|
"msg.retry": "Повторить",
|
||||||
"report.title": "Пожаловаться на этот ответ",
|
"report.title": "Пожаловаться на этот ответ",
|
||||||
"report.reason": "Причина",
|
"report.reason": "Причина",
|
||||||
"report.reasonPh": "Выберите причину…",
|
"report.reasonPh": "Выберите причину…",
|
||||||
@@ -2411,6 +2415,7 @@ const it: Dict = {
|
|||||||
"msg.copy": "Copia messaggio",
|
"msg.copy": "Copia messaggio",
|
||||||
"msg.search": "Cerca nel web",
|
"msg.search": "Cerca nel web",
|
||||||
"msg.report": "Segnala risposta",
|
"msg.report": "Segnala risposta",
|
||||||
|
"msg.retry": "Riprova",
|
||||||
"report.title": "Segnala questa risposta",
|
"report.title": "Segnala questa risposta",
|
||||||
"report.reason": "Motivo",
|
"report.reason": "Motivo",
|
||||||
"report.reasonPh": "Seleziona un motivo…",
|
"report.reasonPh": "Seleziona un motivo…",
|
||||||
@@ -2895,6 +2900,7 @@ const pt: Dict = {
|
|||||||
"msg.copy": "Copiar mensagem",
|
"msg.copy": "Copiar mensagem",
|
||||||
"msg.search": "Pesquisar na web",
|
"msg.search": "Pesquisar na web",
|
||||||
"msg.report": "Denunciar resposta",
|
"msg.report": "Denunciar resposta",
|
||||||
|
"msg.retry": "Tentar novamente",
|
||||||
"report.title": "Denunciar esta resposta",
|
"report.title": "Denunciar esta resposta",
|
||||||
"report.reason": "Motivo",
|
"report.reason": "Motivo",
|
||||||
"report.reasonPh": "Selecione um motivo…",
|
"report.reasonPh": "Selecione um motivo…",
|
||||||
|
|||||||
Reference in New Issue
Block a user