Compare commits

...
3 Commits
Author SHA1 Message Date
k b4cb62b490 ;p 2026-09-21 22:04:07 +02:00
k 59fc78332d Update App.tsx 2026-09-21 22:00:57 +02:00
k 5c616a9d03 Update App.tsx 2026-09-21 22:00:24 +02:00
3 changed files with 109 additions and 29 deletions
+68 -16
View File
@@ -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}
+35 -13
View File
@@ -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,17 +43,30 @@ 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">
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}> {onReport && (
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />} <>
</IconButton> <IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
<IconButton title={t("msg.search")} onClick={searchWeb}> {copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
<Search size={14} /> </IconButton>
</IconButton> <IconButton title={t("msg.search")} onClick={searchWeb}>
<IconButton title={t("msg.report")} onClick={() => onReport(index)}> <Search size={14} />
<Flag size={14} /> </IconButton>
</IconButton> <IconButton title={t("msg.report")} onClick={() => onReport(index)}>
<Flag size={14} />
</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">
+6
View File
@@ -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…",