This commit is contained in:
k
2026-09-24 11:48:46 +02:00
parent ac04c674a6
commit 6ebd26b60e
13 changed files with 127 additions and 15 deletions
+3 -3
View File
@@ -432,7 +432,7 @@ export function App() {
} else {
setFlight(convId);
}
const userMsg: ChatMessage = { role: "user", content: text };
const userMsg: ChatMessage = { role: "user", content: text, model };
// Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map(
(a) =>
@@ -460,7 +460,7 @@ export function App() {
// switched chats mid-stream, the reply is already persisted
// server-side and the other view reloads it on selection.
if (activeIdRef.current === convId) {
setMessages([...history, { role: "assistant", content: full }]);
setMessages([...history, { role: "assistant", content: full, model }]);
}
setStreaming("");
clearFailedEntry(convId!);
@@ -505,7 +505,7 @@ export function App() {
try {
const full = await streamReply(history, convId);
if (activeIdRef.current === convId) {
setMessages([...history, { role: "assistant", content: full }]);
setMessages([...history, { role: "assistant", content: full, model }]);
}
setRetryCount(0);
clearFailedEntry(convId);
+34 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type FC, type ReactNode } from "react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Mail, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts";
import type { DriverModel, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
@@ -190,6 +190,8 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null);
const [resetTarget, setResetTarget] = useState<User | null>(null);
const [resetSent, setResetSent] = useState<{ username: string; email: string } | null>(null);
// Listing: search + sort + filter + pagination (server-side).
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
@@ -270,6 +272,17 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
}
}
async function sendResetLink(u: User) {
setError("");
setResetSent(null);
try {
const res = await api.adminResetLink(u.id);
setResetSent({ username: u.username, email: res.email });
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.resetFailed"));
}
}
async function remove(u: User) {
try {
await api.adminDelete(u.id);
@@ -303,6 +316,12 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
</div>
)}
{resetSent && (
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
<p className="flex items-center gap-2 text-sm font-medium"><Mail size={15} /> {t("admin.resetSent", { user: resetSent.username, email: resetSent.email })}</p>
</div>
)}
{!showCreate ? (
<div className="mb-5 flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
@@ -372,6 +391,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
<div className="flex shrink-0 gap-1">
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
<button title={t("admin.resetLink")} onClick={() => setResetTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Mail size={15} /></button>
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
</div>
)}
@@ -410,6 +430,19 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
onConfirm={() => deleteTarget && remove(deleteTarget)}
/>
<ConfirmDialog
open={resetTarget !== null}
onClose={() => setResetTarget(null)}
title={t("admin.resetTitle", { user: resetTarget?.username ?? "" })}
message={t("admin.resetMsg", { email: resetTarget?.email || t("admin.noEmail") })}
confirmLabel={t("admin.resetConfirm")}
onConfirm={() => {
const u = resetTarget;
setResetTarget(null);
if (u) void sendResetLink(u);
}}
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
</>
);
+15 -2
View File
@@ -44,8 +44,16 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
<Markdown text={msg.content} />
</div>
)}
{!isUser && (onReport || onToggleSpeak || (isFailed && onRetry)) && (
{!isUser && (msg.model || onReport || onToggleSpeak || (isFailed && onRetry)) && (
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
{msg.model ? (
<span
title={msg.model}
className="mr-1 max-w-40 truncate rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-[11px] opacity-80 dark:bg-zinc-800"
>
{msg.model}
</span>
) : null}
{onToggleSpeak && (
<IconButton title={speaking ? t("voice.stopSpeak") : t("voice.speak")} onClick={onToggleSpeak}>
{speaking ? <VolumeX size={14} className="speaking-orb text-accent-500" /> : <Volume2 size={14} />}
@@ -534,7 +542,12 @@ export function Chat({
<div className="flex gap-2 sm:gap-3">
<AssistantAvatar size={32} dynamic />
<div className="min-w-0 max-w-[88%] sm:max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
<span>KisAssistant</span>
{model ? (
<span title={model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{model}</span>
) : null}
</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900">
<div className="typing-cursor"><Markdown text={streaming} /></div>
</div>
+3
View File
@@ -79,6 +79,9 @@ export function SharePage({ publicId }: { publicId: string }) {
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
{m.role === "user" ? chat.authorName : "KisAssistant"}
{m.role !== "user" && m.model ? (
<span title={m.model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{m.model}</span>
) : null}
</div>
{m.role === "user" ? (
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900"><UserMessageContent content={m.content} tone="plain" /></div>
+1
View File
@@ -267,6 +267,7 @@ export const api = {
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
adminResetLink: (id: string) => req<{ ok: boolean; email: string }>(`/api/admin/users/${id}/reset-link`, { method: "POST" }),
adminShadowban: (id: string, shadowbanned: boolean) =>
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
method: "POST",
+36
View File
@@ -244,6 +244,12 @@ const en = {
"admin.email": "Email (recovery)",
"admin.createFailed": "Create failed",
"admin.regenFailed": "Regenerate failed",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Delete failed",
"admin.loadFailed": "Load failed",
"admin.searchPh": "Search accounts…",
@@ -957,6 +963,12 @@ const es: Dict = {
"admin.email": "Email (recuperación)",
"admin.createFailed": "No se pudo crear",
"admin.regenFailed": "No se pudo regenerar",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "No se pudo eliminar",
"admin.loadFailed": "No se pudo cargar",
"admin.searchPh": "Buscar cuentas…",
@@ -1662,6 +1674,12 @@ const fr: Dict = {
"admin.email": "Email (récupération)",
"admin.createFailed": "Création impossible",
"admin.regenFailed": "Régénération impossible",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Suppression impossible",
"admin.loadFailed": "Chargement impossible",
"admin.searchPh": "Rechercher des comptes…",
@@ -2361,6 +2379,12 @@ const ru: Dict = {
"admin.email": "Email (восстановление)",
"admin.createFailed": "Не создалось",
"admin.regenFailed": "Не сменилось",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Не удалилось",
"admin.loadFailed": "Не загрузилось",
"admin.searchPh": "Поиск аккаунтов…",
@@ -3055,6 +3079,12 @@ const it: Dict = {
"admin.email": "Email (recupero)",
"admin.createFailed": "Creazione fallita",
"admin.regenFailed": "Rigenerazione fallita",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Eliminazione fallita",
"admin.loadFailed": "Caricamento fallito",
"admin.searchPh": "Cerca account…",
@@ -3750,6 +3780,12 @@ const pt: Dict = {
"admin.email": "Email (recuperação)",
"admin.createFailed": "Falha ao criar",
"admin.regenFailed": "Falha ao regenerar",
"admin.resetLink": "Email a password reset link",
"admin.resetTitle": "Email a reset link to @{user}?",
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
"admin.resetConfirm": "Send link",
"admin.resetSent": "Reset link sent to @{user} ({email}).",
"admin.resetFailed": "Could not send the reset link",
"admin.deleteFailed": "Falha ao eliminar",
"admin.loadFailed": "Falha ao carregar",
"admin.searchPh": "Pesquisar contas…",
+2
View File
@@ -23,6 +23,8 @@ export interface Conversation {
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
/** Fully-qualified model id that produced this message (assistant only, "" = unknown). */
model?: string;
}
export interface DriverModel {