feat(webui): refine output timeline and model controls (#4108)

* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
This commit is contained in:
Xubin Ren
2026-05-30 23:45:26 +08:00
committed by GitHub
parent b2e43955e3
commit 3dcf511c84
65 changed files with 4526 additions and 1428 deletions
+59 -1
View File
@@ -27,6 +27,11 @@ export interface AttachedImage {
error?: AttachmentError;
}
export interface RestoredReadyImage {
dataUrl: string;
name?: string;
}
/** Machine-readable rejection reasons surfaced as inline chip errors.
*
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */
@@ -48,6 +53,27 @@ const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
"image/gif",
]);
function dataUrlMime(dataUrl: string): string {
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
return match?.[1] || "image/png";
}
function dataUrlToFile(dataUrl: string, name?: string): File {
const mime = dataUrlMime(dataUrl);
const fallbackName = `image.${mime.split("/")[1] || "png"}`;
try {
const [, base64 = ""] = dataUrl.split(",", 2);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], name || fallbackName, { type: mime });
} catch {
return new File([], name || fallbackName, { type: mime });
}
}
function uuid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return (crypto as Crypto).randomUUID();
@@ -84,6 +110,10 @@ export interface UseAttachedImagesApi {
* successful submit — the optimistic bubble holds onto an independent
* ``data:`` URL so tearing down blob previews here is safe. */
clear: () => void;
/** Restore already-encoded images, e.g. a queued composer draft moving back
* into the input. These entries are immediately sendable and use their
* ``data:`` URL as a stable preview. */
restoreReadyImages: (images: RestoredReadyImage[]) => void;
/** ``true`` when at least one image is still encoding — Send should wait. */
encoding: boolean;
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
@@ -211,6 +241,34 @@ export function useAttachedImages(): UseAttachedImagesApi {
});
}, []);
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => {
const toRestore = restored
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl)))
.slice(0, MAX_IMAGES_PER_MESSAGE)
.map((img): AttachedImage => {
const file = dataUrlToFile(img.dataUrl, img.name);
return {
id: uuid(),
file,
previewUrl: img.dataUrl,
status: "ready",
dataUrl: img.dataUrl,
encodedBytes: file.size,
};
});
setImages((prev) => {
for (const img of prev) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
}
}
imagesRef.current = toRestore;
return toRestore;
});
}, []);
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
// under StrictMode double-invoke because revoked blob URLs are only
// referenced from in-hook chip state, which is rebuilt on remount.
@@ -229,5 +287,5 @@ export function useAttachedImages(): UseAttachedImagesApi {
const encoding = images.some((img) => img.status === "encoding");
const full = images.length >= MAX_IMAGES_PER_MESSAGE;
return { images, enqueue, remove, clear, encoding, full };
return { images, enqueue, remove, clear, restoreReadyImages, encoding, full };
}