.
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
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, 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, 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";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
import { UserMessageContent } from "./MessageContent.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { useFeatures } from "../lib/features.ts";
|
import { useFeatures } from "../lib/features.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
@@ -34,8 +35,8 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg
|
|||||||
{isUser ? t("chat.you") : "KisAssistant"}
|
{isUser ? t("chat.you") : "KisAssistant"}
|
||||||
</div>
|
</div>
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950">
|
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950">
|
||||||
{msg.content}
|
<UserMessageContent content={msg.content} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
@@ -60,12 +61,13 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportDialog({ open, onClose, conversationId, messageIndex, content, model }: {
|
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
messageIndex: number;
|
messageIndex: number;
|
||||||
content: string;
|
content: string;
|
||||||
|
prompt: string;
|
||||||
model: string;
|
model: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
@@ -74,6 +76,7 @@ function ReportDialog({ open, onClose, conversationId, messageIndex, content, mo
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
|
const [duplicate, setDuplicate] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -81,6 +84,7 @@ function ReportDialog({ open, onClose, conversationId, messageIndex, content, mo
|
|||||||
setDetails("");
|
setDetails("");
|
||||||
setError("");
|
setError("");
|
||||||
setSent(false);
|
setSent(false);
|
||||||
|
setDuplicate(false);
|
||||||
}
|
}
|
||||||
}, [open, messageIndex]);
|
}, [open, messageIndex]);
|
||||||
|
|
||||||
@@ -96,11 +100,16 @@ function ReportDialog({ open, onClose, conversationId, messageIndex, content, mo
|
|||||||
reason: reason as ReportReason,
|
reason: reason as ReportReason,
|
||||||
details: details.trim(),
|
details: details.trim(),
|
||||||
content,
|
content,
|
||||||
|
prompt,
|
||||||
model,
|
model,
|
||||||
});
|
});
|
||||||
setSent(true);
|
setSent(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setDuplicate(true);
|
||||||
|
} else {
|
||||||
setError(err instanceof Error ? err.message : t("report.failed"));
|
setError(err instanceof Error ? err.message : t("report.failed"));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +117,16 @@ function ReportDialog({ open, onClose, conversationId, messageIndex, content, mo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title={t("report.title")} icon={Flag}>
|
<Modal open={open} onClose={onClose} title={t("report.title")} icon={Flag}>
|
||||||
{sent ? (
|
{sent || duplicate ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="flex items-center gap-2 rounded-2xl bg-emerald-600/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Check size={16} /> {duplicate ? t("report.alreadyReported") : t("report.sent")}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="secondary" onClick={onClose}>{t("common.close")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="flex items-center gap-2 rounded-2xl bg-emerald-600/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400">
|
<p className="flex items-center gap-2 rounded-2xl bg-emerald-600/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
<Check size={16} /> {t("report.sent")}
|
<Check size={16} /> {t("report.sent")}
|
||||||
@@ -429,6 +447,7 @@ export function Chat({
|
|||||||
{(() => {
|
{(() => {
|
||||||
const shown = messages.filter((m) => m.role !== "system");
|
const shown = messages.filter((m) => m.role !== "system");
|
||||||
const target = reportIndex !== null ? shown[reportIndex] : undefined;
|
const target = reportIndex !== null ? shown[reportIndex] : undefined;
|
||||||
|
const prev = reportIndex !== null && reportIndex > 0 ? shown[reportIndex - 1] : undefined;
|
||||||
return (
|
return (
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
open={reportIndex !== null}
|
open={reportIndex !== null}
|
||||||
@@ -436,6 +455,7 @@ export function Chat({
|
|||||||
conversationId={conv?.id ?? null}
|
conversationId={conv?.id ?? null}
|
||||||
messageIndex={reportIndex ?? 0}
|
messageIndex={reportIndex ?? 0}
|
||||||
content={target?.content ?? ""}
|
content={target?.content ?? ""}
|
||||||
|
prompt={prev && prev.role === "user" ? prev.content : ""}
|
||||||
model={conv?.model || model}
|
model={conv?.model || model}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ import { cn } from "../lib/cn.ts";
|
|||||||
*/
|
*/
|
||||||
const ATTACH_RE = /^(\[.*?\])\n```text\n([\s\S]*?)\n```/gm;
|
const ATTACH_RE = /^(\[.*?\])\n```text\n([\s\S]*?)\n```/gm;
|
||||||
|
|
||||||
function AttachmentBlock({ header, body }: { header: string; body: string }) {
|
function AttachmentBlock({ header, body, tone }: { header: string; body: string; tone: "accent" | "plain" }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const label = header.startsWith("[") && header.endsWith("]") ? header.slice(1, -1) : header;
|
const label = header.startsWith("[") && header.endsWith("]") ? header.slice(1, -1) : header;
|
||||||
return (
|
return (
|
||||||
<span className="mt-2 block overflow-hidden rounded-2xl bg-black/25 text-left dark:bg-black/30">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 block overflow-hidden rounded-2xl text-left",
|
||||||
|
tone === "accent" ? "bg-black/25 dark:bg-black/30" : "bg-stone-200/70 dark:bg-zinc-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -35,7 +40,12 @@ function AttachmentBlock({ header, body }: { header: string; body: string }) {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words border-t border-white/10 px-3 py-2 font-mono text-[12.5px] leading-relaxed">
|
<pre
|
||||||
|
className={cn(
|
||||||
|
"max-h-72 overflow-auto whitespace-pre-wrap break-words border-t px-3 py-2 font-mono text-[12.5px] leading-relaxed",
|
||||||
|
tone === "accent" ? "border-white/10" : "border-stone-300/70 dark:border-zinc-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{body}
|
{body}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -43,7 +53,7 @@ function AttachmentBlock({ header, body }: { header: string; body: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessageContent({ content }: { content: string }) {
|
export function UserMessageContent({ content, tone = "accent" }: { content: string; tone?: "accent" | "plain" }) {
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
let last = 0;
|
let last = 0;
|
||||||
let key = 0;
|
let key = 0;
|
||||||
@@ -51,7 +61,7 @@ export function UserMessageContent({ content }: { content: string }) {
|
|||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = ATTACH_RE.exec(content)) !== null) {
|
while ((m = ATTACH_RE.exec(content)) !== null) {
|
||||||
if (m.index > last) parts.push(<span key={key++}>{content.slice(last, m.index)}</span>);
|
if (m.index > last) parts.push(<span key={key++}>{content.slice(last, m.index)}</span>);
|
||||||
parts.push(<AttachmentBlock key={key++} header={m[1]!} body={m[2] ?? ""} />);
|
parts.push(<AttachmentBlock key={key++} header={m[1]!} body={m[2] ?? ""} tone={tone} />);
|
||||||
last = m.index + m[0].length;
|
last = m.index + m[0].length;
|
||||||
}
|
}
|
||||||
if (last < content.length) parts.push(<span key={key++}>{content.slice(last)}</span>);
|
if (last < content.length) parts.push(<span key={key++}>{content.slice(last)}</span>);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CheckCircle2, Clock, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
|
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
|
||||||
import { api, type Report, type ReportStatus } from "../lib/api.ts";
|
import { api, type Report, type ReportStatus } from "../lib/api.ts";
|
||||||
import { Button, Field, Input, Spinner } from "./ui.tsx";
|
import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx";
|
||||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
@@ -118,6 +118,11 @@ export function ReportsPanel() {
|
|||||||
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
|
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
|
||||||
{t(`reports.status.${r.status}` as StringKey)}
|
{t(`reports.status.${r.status}` as StringKey)}
|
||||||
</span>
|
</span>
|
||||||
|
{r.reporter_shadowbanned === 1 && (
|
||||||
|
<span className="shrink-0 rounded-full bg-stone-500/10 px-2.5 py-1 text-xs font-medium opacity-70">
|
||||||
|
{t("reports.unreliable")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
|
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from "../lib/api.ts";
|
|||||||
import type { SharedChat } from "../lib/types.ts";
|
import type { SharedChat } from "../lib/types.ts";
|
||||||
import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx";
|
import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
import { UserMessageContent } from "./MessageContent.tsx";
|
||||||
import { useT } from "../lib/i18n.ts";
|
import { useT } from "../lib/i18n.ts";
|
||||||
|
|
||||||
export function SharePage({ publicId }: { publicId: string }) {
|
export function SharePage({ publicId }: { publicId: string }) {
|
||||||
@@ -80,7 +81,7 @@ export function SharePage({ publicId }: { publicId: string }) {
|
|||||||
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
||||||
</div>
|
</div>
|
||||||
{m.role === "user" ? (
|
{m.role === "user" ? (
|
||||||
<div className="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">{m.content}</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
<Markdown text={m.content} />
|
<Markdown text={m.content} />
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ 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" }),
|
||||||
|
adminShadowban: (id: string, shadowbanned: boolean) =>
|
||||||
|
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ shadowbanned }),
|
||||||
|
}),
|
||||||
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||||
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user