updates
This commit is contained in:
@@ -58,6 +58,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
|||||||
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
|
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
|
||||||
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
|
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
|
||||||
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
|
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
|
||||||
|
| POST | `/api/admin/users/:id/reset-link` | admin | email a one-time password-reset link (needs user email + SMTP) |
|
||||||
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
||||||
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
||||||
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
|||||||
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
|
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
|
||||||
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||||
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
||||||
|
- Assistant bubbles show the producing model (`<model>` chip in the footer,
|
||||||
|
stored per message in `messages.model` — survives model switches; same
|
||||||
|
chip on the streaming bubble and shared pages).
|
||||||
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
||||||
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||||
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
||||||
|
|||||||
+4
-1
@@ -126,7 +126,10 @@ dedicated per-IP envelope (separate from login brute-force protection):
|
|||||||
every hit counts, successes included (recovery answers are always-ok by
|
every hit counts, successes included (recovery answers are always-ok by
|
||||||
design, so enumeration is impossible). Over-limit answers 429 with
|
design, so enumeration is impossible). Over-limit answers 429 with
|
||||||
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
|
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
|
||||||
Admin Center → Mail → Password recovery limits, no restart.
|
Admin Center → Mail → Password recovery limits, no restart. Admins can also
|
||||||
|
email a one-time reset link to any user with an address from the Accounts
|
||||||
|
tab (`POST /api/admin/users/:id/reset-link` — 409 without email, 501
|
||||||
|
without SMTP).
|
||||||
|
|
||||||
## Model access policy (kill-switch + rate limits)
|
## Model access policy (kill-switch + rate limits)
|
||||||
|
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export function App() {
|
|||||||
} else {
|
} else {
|
||||||
setFlight(convId);
|
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.
|
// Attachments travel as reviewed text blocks appended to the message.
|
||||||
const blocks = attachments.map(
|
const blocks = attachments.map(
|
||||||
(a) =>
|
(a) =>
|
||||||
@@ -460,7 +460,7 @@ export function App() {
|
|||||||
// switched chats mid-stream, the reply is already persisted
|
// switched chats mid-stream, the reply is already persisted
|
||||||
// server-side and the other view reloads it on selection.
|
// server-side and the other view reloads it on selection.
|
||||||
if (activeIdRef.current === convId) {
|
if (activeIdRef.current === convId) {
|
||||||
setMessages([...history, { role: "assistant", content: full }]);
|
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||||
}
|
}
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
clearFailedEntry(convId!);
|
clearFailedEntry(convId!);
|
||||||
@@ -505,7 +505,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const full = await streamReply(history, convId);
|
const full = await streamReply(history, convId);
|
||||||
if (activeIdRef.current === convId) {
|
if (activeIdRef.current === convId) {
|
||||||
setMessages([...history, { role: "assistant", content: full }]);
|
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||||
}
|
}
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
clearFailedEntry(convId);
|
clearFailedEntry(convId);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type FC, type ReactNode } from "react";
|
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 { api } from "../lib/api.ts";
|
||||||
import type { DriverModel, User } from "../lib/types.ts";
|
import type { DriverModel, User } from "../lib/types.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.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 [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||||
const [editTarget, setEditTarget] = 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).
|
// Listing: search + sort + filter + pagination (server-side).
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [debouncedQ, setDebouncedQ] = 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) {
|
async function remove(u: User) {
|
||||||
try {
|
try {
|
||||||
await api.adminDelete(u.id);
|
await api.adminDelete(u.id);
|
||||||
@@ -303,6 +316,12 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
</div>
|
</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 ? (
|
{!showCreate ? (
|
||||||
<div className="mb-5 flex flex-wrap gap-2">
|
<div className="mb-5 flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
|
<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">
|
<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.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.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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -410,6 +430,19 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
onConfirm={() => deleteTarget && remove(deleteTarget)}
|
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} />
|
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,8 +44,16 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
|
|||||||
<Markdown text={msg.content} />
|
<Markdown text={msg.content} />
|
||||||
</div>
|
</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">
|
<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 && (
|
{onToggleSpeak && (
|
||||||
<IconButton title={speaking ? t("voice.stopSpeak") : t("voice.speak")} onClick={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} />}
|
{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">
|
<div className="flex gap-2 sm:gap-3">
|
||||||
<AssistantAvatar size={32} dynamic />
|
<AssistantAvatar size={32} dynamic />
|
||||||
<div className="min-w-0 max-w-[88%] sm:max-w-[85%]">
|
<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="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 className="typing-cursor"><Markdown text={streaming} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export function SharePage({ publicId }: { publicId: string }) {
|
|||||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
<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" ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||||
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
{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>
|
</div>
|
||||||
{m.role === "user" ? (
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export const api = {
|
|||||||
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
|
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" }),
|
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" }),
|
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) =>
|
adminShadowban: (id: string, shadowbanned: boolean) =>
|
||||||
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
|
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -244,6 +244,12 @@ const en = {
|
|||||||
"admin.email": "Email (recovery)",
|
"admin.email": "Email (recovery)",
|
||||||
"admin.createFailed": "Create failed",
|
"admin.createFailed": "Create failed",
|
||||||
"admin.regenFailed": "Regenerate 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.deleteFailed": "Delete failed",
|
||||||
"admin.loadFailed": "Load failed",
|
"admin.loadFailed": "Load failed",
|
||||||
"admin.searchPh": "Search accounts…",
|
"admin.searchPh": "Search accounts…",
|
||||||
@@ -957,6 +963,12 @@ const es: Dict = {
|
|||||||
"admin.email": "Email (recuperación)",
|
"admin.email": "Email (recuperación)",
|
||||||
"admin.createFailed": "No se pudo crear",
|
"admin.createFailed": "No se pudo crear",
|
||||||
"admin.regenFailed": "No se pudo regenerar",
|
"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.deleteFailed": "No se pudo eliminar",
|
||||||
"admin.loadFailed": "No se pudo cargar",
|
"admin.loadFailed": "No se pudo cargar",
|
||||||
"admin.searchPh": "Buscar cuentas…",
|
"admin.searchPh": "Buscar cuentas…",
|
||||||
@@ -1662,6 +1674,12 @@ const fr: Dict = {
|
|||||||
"admin.email": "Email (récupération)",
|
"admin.email": "Email (récupération)",
|
||||||
"admin.createFailed": "Création impossible",
|
"admin.createFailed": "Création impossible",
|
||||||
"admin.regenFailed": "Régénération 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.deleteFailed": "Suppression impossible",
|
||||||
"admin.loadFailed": "Chargement impossible",
|
"admin.loadFailed": "Chargement impossible",
|
||||||
"admin.searchPh": "Rechercher des comptes…",
|
"admin.searchPh": "Rechercher des comptes…",
|
||||||
@@ -2361,6 +2379,12 @@ const ru: Dict = {
|
|||||||
"admin.email": "Email (восстановление)",
|
"admin.email": "Email (восстановление)",
|
||||||
"admin.createFailed": "Не создалось",
|
"admin.createFailed": "Не создалось",
|
||||||
"admin.regenFailed": "Не сменилось",
|
"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.deleteFailed": "Не удалилось",
|
||||||
"admin.loadFailed": "Не загрузилось",
|
"admin.loadFailed": "Не загрузилось",
|
||||||
"admin.searchPh": "Поиск аккаунтов…",
|
"admin.searchPh": "Поиск аккаунтов…",
|
||||||
@@ -3055,6 +3079,12 @@ const it: Dict = {
|
|||||||
"admin.email": "Email (recupero)",
|
"admin.email": "Email (recupero)",
|
||||||
"admin.createFailed": "Creazione fallita",
|
"admin.createFailed": "Creazione fallita",
|
||||||
"admin.regenFailed": "Rigenerazione 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.deleteFailed": "Eliminazione fallita",
|
||||||
"admin.loadFailed": "Caricamento fallito",
|
"admin.loadFailed": "Caricamento fallito",
|
||||||
"admin.searchPh": "Cerca account…",
|
"admin.searchPh": "Cerca account…",
|
||||||
@@ -3750,6 +3780,12 @@ const pt: Dict = {
|
|||||||
"admin.email": "Email (recuperação)",
|
"admin.email": "Email (recuperação)",
|
||||||
"admin.createFailed": "Falha ao criar",
|
"admin.createFailed": "Falha ao criar",
|
||||||
"admin.regenFailed": "Falha ao regenerar",
|
"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.deleteFailed": "Falha ao eliminar",
|
||||||
"admin.loadFailed": "Falha ao carregar",
|
"admin.loadFailed": "Falha ao carregar",
|
||||||
"admin.searchPh": "Pesquisar contas…",
|
"admin.searchPh": "Pesquisar contas…",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface Conversation {
|
|||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Fully-qualified model id that produced this message (assistant only, "" = unknown). */
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriverModel {
|
export interface DriverModel {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface MessageRow {
|
|||||||
conv_id: string;
|
conv_id: string;
|
||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Fully-qualified model id that produced this message ("" = unknown/pre-migration). */
|
||||||
|
model: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +238,10 @@ export async function deleteConversation(id: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMessage(convId: string, role: string, content: string): Promise<void> {
|
export async function addMessage(convId: string, role: string, content: string, model = ""): Promise<void> {
|
||||||
await db().run(
|
await db().run(
|
||||||
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO messages (conv_id, role, content, model, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
convId, role, content, Date.now(),
|
convId, role, content, model.slice(0, 160), Date.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ const LATER_COLUMNS = [
|
|||||||
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
|
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
|
||||||
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
|
||||||
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Split a multi-statement DDL string into single statements. */
|
/** Split a multi-statement DDL string into single statements. */
|
||||||
@@ -430,6 +431,10 @@ async function initSqliteSchema(c: DbClient): Promise<void> {
|
|||||||
if (!convCols.some((col) => col.name === "archived_at")) {
|
if (!convCols.some((col) => col.name === "archived_at")) {
|
||||||
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
||||||
}
|
}
|
||||||
|
const msgCols = await c.all<{ name: string }>("PRAGMA table_info(messages)");
|
||||||
|
if (!msgCols.some((col) => col.name === "model")) {
|
||||||
|
await c.exec("ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
await ensureAdminRow(c);
|
await ensureAdminRow(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-5
@@ -453,7 +453,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
authorAvatarUrl: s.authorAvatarUrl,
|
authorAvatarUrl: s.authorAvatarUrl,
|
||||||
archived: s.archived,
|
archived: s.archived,
|
||||||
sharedAt: s.sharedAt,
|
sharedAt: s.sharedAt,
|
||||||
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
|
messages: s.messages.map((m) => ({ role: m.role, content: m.content, model: m.model || undefined })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +980,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
return json({ user: toPublicUser(created), key }, 201);
|
return json({ user: toPublicUser(created), key }, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
|
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban|\/reset-link)?$/);
|
||||||
if (adminUserMatch) {
|
if (adminUserMatch) {
|
||||||
if (!admin) return json({ error: "forbidden" }, 403);
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
const target = await getUserById(adminUserMatch[1]!);
|
const target = await getUserById(adminUserMatch[1]!);
|
||||||
@@ -1002,6 +1002,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
}
|
}
|
||||||
return json({ shadowbanned: (await setReportShadowbanned(target.id, body.shadowbanned)) === 1 });
|
return json({ shadowbanned: (await setReportShadowbanned(target.id, body.shadowbanned)) === 1 });
|
||||||
}
|
}
|
||||||
|
// Admin-initiated key recovery: email a one-time reset link to the
|
||||||
|
// user (same flow as self-service /api/auth/recover, but targeted).
|
||||||
|
if (req.method === "POST" && adminUserMatch[2] === "/reset-link") {
|
||||||
|
if (!target.email) return json({ error: "user has no email address — key recovery needs one" }, 409);
|
||||||
|
if (!mailEnabled()) return json({ error: "email is not configured on this platform" }, 501);
|
||||||
|
const token = "rt_" + randomBytes(24).toString("base64url");
|
||||||
|
await createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
|
||||||
|
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
|
||||||
|
return json({ ok: true, email: target.email });
|
||||||
|
}
|
||||||
if (req.method === "PATCH" && !adminUserMatch[2]) {
|
if (req.method === "PATCH" && !adminUserMatch[2]) {
|
||||||
const { ok, body } = await readJson(req);
|
const { ok, body } = await readJson(req);
|
||||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||||
@@ -1931,7 +1941,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
const stored = await getMessages(conv.id);
|
const stored = await getMessages(conv.id);
|
||||||
const lastStored = stored[stored.length - 1];
|
const lastStored = stored[stored.length - 1];
|
||||||
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
|
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
|
||||||
await addMessage(conv.id, "user", lastUser.content);
|
await addMessage(conv.id, "user", lastUser.content, body.model as string);
|
||||||
if (conv.title === "New chat") {
|
if (conv.title === "New chat") {
|
||||||
await updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
|
await updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
|
||||||
}
|
}
|
||||||
@@ -1941,7 +1951,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
|
|
||||||
const persistReply = async (text: string): Promise<void> => {
|
const persistReply = async (text: string): Promise<void> => {
|
||||||
if (conv && text) {
|
if (conv && text) {
|
||||||
await addMessage(conv.id, "assistant", text);
|
await addMessage(conv.id, "assistant", text, body.model as string);
|
||||||
await touchConversation(conv.id, body.model as string);
|
await touchConversation(conv.id, body.model as string);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1950,7 +1960,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
try {
|
try {
|
||||||
const text = await driver.chat(messages, { model, signal: req.signal });
|
const text = await driver.chat(messages, { model, signal: req.signal });
|
||||||
await persistReply(text);
|
await persistReply(text);
|
||||||
return json({ model: body.model, message: { role: "assistant", content: text } });
|
return json({ model: body.model, message: { role: "assistant", content: text, model: body.model } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json({ error: (e as Error).message }, 502);
|
return json({ error: (e as Error).message }, 502);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user