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) |
|
||||
| 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/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/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
||||
| 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
|
||||
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||
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/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||
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
|
||||
design, so enumeration is impossible). Over-limit answers 429 with
|
||||
`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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface MessageRow {
|
||||
conv_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
/** Fully-qualified model id that produced this message ("" = unknown/pre-migration). */
|
||||
model: string;
|
||||
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(
|
||||
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
convId, role, content, Date.now(),
|
||||
"INSERT INTO messages (conv_id, role, content, model, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
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 report_shadowbanned 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. */
|
||||
@@ -430,6 +431,10 @@ async function initSqliteSchema(c: DbClient): Promise<void> {
|
||||
if (!convCols.some((col) => col.name === "archived_at")) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -453,7 +453,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
authorAvatarUrl: s.authorAvatarUrl,
|
||||
archived: s.archived,
|
||||
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);
|
||||
}
|
||||
|
||||
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 (!admin) return json({ error: "forbidden" }, 403);
|
||||
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 });
|
||||
}
|
||||
// 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]) {
|
||||
const { ok, body } = await readJson(req);
|
||||
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 lastStored = stored[stored.length - 1];
|
||||
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") {
|
||||
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> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1950,7 +1960,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
try {
|
||||
const text = await driver.chat(messages, { model, signal: req.signal });
|
||||
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) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user