feat: chat stats, regenerate with version history, edit & resubmit, image/file attachments

This commit is contained in:
Xianren Studio
2026-08-13 18:18:51 +08:00
parent 4718e41433
commit cd77f75066
12 changed files with 1156 additions and 199 deletions
+40
View File
@@ -71,6 +71,9 @@ export interface Message {
content: string;
tokens_in: number | null;
tokens_out: number | null;
elapsed_ms: number | null;
first_token_ms: number | null;
images: string[];
created_at: string;
}
@@ -87,6 +90,7 @@ export interface ChatSendPayload {
model_id: string;
content: string;
params: ChatParams;
images: string[];
}
export interface ChatTokenEvent {
@@ -100,7 +104,9 @@ export interface ChatDoneEvent {
content: string;
tokens_in: number | null;
tokens_out: number | null;
tokens_estimated: boolean;
elapsed_ms: number;
first_token_ms: number | null;
}
export interface ChatErrorEvent {
@@ -108,6 +114,22 @@ export interface ChatErrorEvent {
message: string;
}
export interface ChatMessageUpdatedEvent {
conversation_id: string;
message: Message;
}
export interface MessageVersion {
id: string;
message_id: string;
content: string;
tokens_out: number | null;
elapsed_ms: number | null;
first_token_ms: number | null;
seq: number;
created_at: string;
}
export interface EngineDeployEvent {
model_id: string;
file_name: string;
@@ -167,6 +189,24 @@ export const api = {
engineStop: () => invoke<void>("engine_stop"),
engineStatus: () => invoke<EngineStatus>("engine_status"),
chatSend: (payload: ChatSendPayload) => invoke<string>("chat_send", { payload }),
regenerateMessage: (
conversationId: string,
messageId: string,
params: ChatParams,
) => invoke<void>("regenerate_message", { conversationId, messageId, params }),
editMessage: (
conversationId: string,
messageId: string,
content: string,
params: ChatParams,
) => invoke<void>("edit_message", { conversationId, messageId, content, params }),
listMessageVersions: (messageId: string) =>
invoke<MessageVersion[]>("list_message_versions", { messageId }),
applyMessageVersion: (
conversationId: string,
messageId: string,
versionId: string,
) => invoke<void>("apply_message_version", { conversationId, messageId, versionId }),
downloadEnqueue: (
url: string,
fileName: string,
+3 -1
View File
@@ -32,6 +32,9 @@ const paths: Record<string, ReactNode> = {
),
"chevron-left": <path d="M15 18l-6-6 6-6" />,
"chevron-right": <path d="M9 18l6-6-6-6" />,
paperclip: (
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
),
};
export default function Icon({
@@ -56,4 +59,3 @@ export default function Icon({
</svg>
);
}
+440 -47
View File
@@ -5,18 +5,35 @@ import {
api,
ChatDoneEvent,
ChatErrorEvent,
ChatMessageUpdatedEvent,
ChatParams,
ChatTokenEvent,
MessageVersion,
onEvent,
} from "../api";
import { useStore } from "../store";
import Icon from "../components/Icon";
interface Attachment {
id: string;
kind: "image" | "text";
name: string;
size: number;
dataUrl?: string;
text?: string;
}
interface LocalMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
images?: string[];
tokensIn?: number | null;
tokensOut?: number | null;
tokensEstimated?: boolean;
elapsedMs?: number | null;
firstTokenMs?: number | null;
}
const defaultParams: ChatParams = {
@@ -27,10 +44,22 @@ const defaultParams: ChatParams = {
ngl: 99,
};
const TEXT_EXTENSIONS = new Set([
"txt", "md", "markdown", "json", "csv", "log", "py", "js", "ts", "tsx", "jsx",
"rs", "c", "cpp", "h", "hpp", "go", "java", "kt", "html", "css", "xml", "yaml",
"yml", "toml", "ini", "sh", "ps1", "bat", "sql", "env", "gitignore",
]);
const MAX_IMAGE_SIZE = 3 * 1024 * 1024;
const MAX_TEXT_SIZE = 100 * 1024;
export default function ChatPage() {
const models = useStore((s) => s.models);
const conversations = useStore((s) => s.conversations);
const refreshConversations = useStore((s) => s.refreshConversations);
const engine = useStore((s) => s.engine);
const deployStates = useStore((s) => s.deployStates);
const deployProgress = useStore((s) => s.deployProgress);
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [messages, setMessages] = useState<LocalMessage[]>([]);
@@ -42,7 +71,24 @@ export default function ChatPage() {
const [rightOpen, setRightOpen] = useState(
() => localStorage.getItem("xianren-right-open") !== "0",
);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingDraft, setEditingDraft] = useState("");
const [versionsFor, setVersionsFor] = useState<Record<string, MessageVersion[]>>({});
const [attachments, setAttachments] = useState<Attachment[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const engineRunning = engine?.running ?? false;
const engineModel = engine?.model ?? null;
const selectedIsRemote = useMemo(
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
[models, selectedModelId],
);
const engineLabel = useMemo(() => {
if (!models.length) return "无可用模型";
const m = models.find((x) => x.id === selectedModelId);
return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型";
}, [models, selectedModelId]);
function toggleRight() {
setRightOpen((v) => {
@@ -92,6 +138,11 @@ export default function ChatPage() {
id: e.message_id,
role: "assistant",
content: e.content,
tokensIn: e.tokens_in,
tokensOut: e.tokens_out,
tokensEstimated: e.tokens_estimated,
elapsedMs: e.elapsed_ms,
firstTokenMs: e.first_token_ms,
};
}
return updated;
@@ -104,26 +155,65 @@ export default function ChatPage() {
setStreaming(false);
setError(e.message);
});
const un4 = onEvent<ChatMessageUpdatedEvent>("chat://message-updated", (e) => {
if (e.conversation_id !== activeConvId) return;
const m = e.message;
setMessages((prev) =>
prev.map((x) =>
x.id === m.id
? {
...x,
content: m.content,
tokensIn: m.tokens_in,
tokensOut: m.tokens_out,
elapsedMs: m.elapsed_ms,
firstTokenMs: m.first_token_ms,
images: m.images,
}
: x,
),
);
});
return () => {
un1.then((f) => f());
un2.then((f) => f());
un3.then((f) => f());
un4.then((f) => f());
};
}, [activeConvId, refreshConversations]);
function appendStreamingPlaceholder() {
setMessages((prev) => [
...prev,
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
]);
}
async function handleSend() {
const content = input.trim();
if (!content || streaming) return;
const textParts = attachments.filter((a) => a.kind === "text");
let content = input.trim();
if (textParts.length > 0) {
const blocks = textParts.map(
(a) => `\n\n--- 附件:${a.name} ---\n\`\`\`\n${a.text}\n\`\`\``,
);
content += blocks.join("\n");
}
const images = attachments
.filter((a) => a.kind === "image" && a.dataUrl)
.map((a) => a.dataUrl!);
if (!content.trim() && images.length === 0) return;
if (streaming) return;
if (!selectedModelId) {
setError("请先在模型库导入一个模型");
setError("请先在模型管理添加一个模型");
return;
}
setError(null);
setInput("");
setAttachments([]);
let convId = activeConvId;
if (!convId) {
const conv = await api.createConversation(content.slice(0, 30));
const conv = await api.createConversation(content.slice(0, 30) || "新会话");
convId = conv.id;
setActiveConvId(convId);
await refreshConversations();
@@ -131,7 +221,7 @@ export default function ChatPage() {
setMessages((prev) => [
...prev,
{ id: `user-${Date.now()}`, role: "user", content },
{ id: `user-${Date.now()}`, role: "user", content, images },
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
]);
setStreaming(true);
@@ -142,6 +232,7 @@ export default function ChatPage() {
model_id: selectedModelId,
content,
params,
images,
});
} catch (e) {
setStreaming(false);
@@ -149,11 +240,96 @@ export default function ChatPage() {
}
}
async function handleRegenerate(msg: LocalMessage) {
if (!activeConvId || streaming) return;
setError(null);
const idx = messages.findIndex((m) => m.id === msg.id);
if (idx < 0) return;
setMessages((prev) => [...prev.slice(0, idx)]);
appendStreamingPlaceholder();
setStreaming(true);
try {
await api.regenerateMessage(activeConvId, msg.id, params);
} catch (e) {
setStreaming(false);
setError(String(e));
}
}
async function handleEditSave(msg: LocalMessage) {
if (!activeConvId || streaming) return;
const content = editingDraft.trim();
if (!content) return;
setError(null);
setEditingId(null);
const idx = messages.findIndex((m) => m.id === msg.id);
setMessages((prev) =>
prev.map((m, i) => (i === idx ? { ...m, content } : m)).slice(0, idx + 1),
);
appendStreamingPlaceholder();
setStreaming(true);
try {
await api.editMessage(activeConvId, msg.id, content, params);
} catch (e) {
setStreaming(false);
setError(String(e));
}
}
async function handleToggleVersions(msg: LocalMessage) {
try {
const list = await api.listMessageVersions(msg.id);
setVersionsFor((prev) => ({ ...prev, [msg.id]: list }));
} catch (e) {
alert(String(e));
}
}
async function handleApplyVersion(msg: LocalMessage, version: MessageVersion) {
if (!activeConvId) return;
try {
await api.applyMessageVersion(activeConvId, msg.id, version.id);
setVersionsFor((prev) => ({ ...prev, [msg.id]: [] }));
} catch (e) {
alert(String(e));
}
}
async function handleFiles(files: FileList | File[]) {
for (const file of Array.from(files)) {
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext)) {
if (file.size > MAX_IMAGE_SIZE) {
setError(`图片 ${file.name} 超过 3MB,已跳过`);
continue;
}
const dataUrl = await readAsDataUrl(file);
setAttachments((prev) => [
...prev,
{ id: crypto.randomUUID(), kind: "image", name: file.name, size: file.size, dataUrl },
]);
} else if (file.type.startsWith("text/") || TEXT_EXTENSIONS.has(ext)) {
if (file.size > MAX_TEXT_SIZE) {
setError(`文件 ${file.name} 超过 100KB,已跳过`);
continue;
}
const text = await file.text();
setAttachments((prev) => [
...prev,
{ id: crypto.randomUUID(), kind: "text", name: file.name, size: file.size, text },
]);
} else {
setError(`不支持的文件类型:${file.name}(支持图片与文本类文件)`);
}
}
}
async function handleNewConversation() {
const conv = await api.createConversation("新会话");
setActiveConvId(conv.id);
setMessages([]);
setError(null);
setVersionsFor({});
await refreshConversations();
}
@@ -165,9 +341,15 @@ export default function ChatPage() {
id: m.id,
role: m.role as "user" | "assistant",
content: m.content,
images: m.images,
tokensIn: m.tokens_in,
tokensOut: m.tokens_out,
elapsedMs: m.elapsed_ms,
firstTokenMs: m.first_token_ms,
})),
);
setError(null);
setVersionsFor({});
}
async function handleDeleteConversation(id: string) {
@@ -179,20 +361,6 @@ export default function ChatPage() {
await refreshConversations();
}
const engineRunning = useStore((s) => s.engine?.running ?? false);
const engineModel = useStore((s) => s.engine?.model ?? null);
const deployStates = useStore((s) => s.deployStates);
const deployProgress = useStore((s) => s.deployProgress);
const selectedIsRemote = useMemo(
() => models.find((m) => m.id === selectedModelId)?.kind === "remote",
[models, selectedModelId],
);
const engineLabel = useMemo(() => {
if (!models.length) return "无可用模型";
const m = models.find((x) => x.id === selectedModelId);
return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型";
}, [models, selectedModelId]);
return (
<div className="flex h-full">
<div className="flex w-60 flex-col border-r border-border bg-panel p-3">
@@ -204,7 +372,9 @@ export default function ChatPage() {
<div
key={c.id}
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm ${
activeConvId === c.id ? "bg-panel-2 text-white" : "text-slate-300 hover:bg-panel-2/60"
activeConvId === c.id
? "bg-panel-2 text-white"
: "text-slate-300 hover:bg-panel-2/60"
}`}
onClick={() => handleLoadConversation(c.id)}
>
@@ -230,31 +400,29 @@ export default function ChatPage() {
<div className="mb-2 text-3xl">💬</div>
<div className="mt-1 text-xs text-slate-600">
llama-server
/
</div>
</div>
) : null}
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed ${
m.role === "user"
? "bg-gradient-to-br from-accent to-accent-2 text-white"
: "markdown-body border border-border bg-panel"
}`}
>
{m.role === "user" ? (
m.content
) : m.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
) : (
<span className="inline-flex items-center gap-1 text-slate-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
</span>
)}
</div>
</div>
<MessageBubble
key={m.id}
message={m}
streaming={streaming}
editingId={editingId}
editingDraft={editingDraft}
versions={versionsFor[m.id] ?? null}
onEditStart={(msg) => {
setEditingId(msg.id);
setEditingDraft(msg.content);
}}
onEditDraftChange={setEditingDraft}
onEditCancel={() => setEditingId(null)}
onEditSave={handleEditSave}
onRegenerate={handleRegenerate}
onToggleVersions={handleToggleVersions}
onApplyVersion={handleApplyVersion}
/>
))}
{error ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
@@ -265,10 +433,53 @@ export default function ChatPage() {
</div>
<div className="border-t border-border bg-panel p-4">
{attachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<div
key={a.id}
className="flex items-center gap-1.5 rounded-lg border border-border bg-panel-2 px-2 py-1 text-xs"
>
{a.kind === "image" ? (
<img src={a.dataUrl} className="h-6 w-6 rounded object-cover" alt={a.name} />
) : (
<Icon name="paperclip" className="h-3.5 w-3.5 text-slate-400" />
)}
<span className="max-w-[160px] truncate">{a.name}</span>
<button
className="text-slate-500 hover:text-red-400"
onClick={() =>
setAttachments((prev) => prev.filter((x) => x.id !== a.id))
}
>
×
</button>
</div>
))}
</div>
) : null}
<div className="flex items-end gap-3">
<button
className="mb-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-panel-2 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
onClick={() => fileInputRef.current?.click()}
title="上传图片或文本文件"
>
<Icon name="paperclip" className="h-4 w-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept="image/*,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.rs,.c,.cpp,.h,.hpp,.go,.java,.html,.css,.xml,.yaml,.yml,.toml,.ini,.sh,.ps1,.bat,.sql"
onChange={(e) => {
if (e.target.files) handleFiles(e.target.files);
e.target.value = "";
}}
/>
<textarea
className="input min-h-[72px] flex-1 resize-none"
placeholder={`本地模型提问…(当前 ${engineRunning ? "引擎运行中" : "引擎未启动,发送时将自动启动"}`}
placeholder={`向模型提问…(可粘贴图片/文件`}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
@@ -277,11 +488,18 @@ export default function ChatPage() {
handleSend();
}
}}
onPaste={(e) => {
const files = e.clipboardData?.files;
if (files && files.length > 0) {
e.preventDefault();
handleFiles(files);
}
}}
/>
<button
className="btn-primary h-[72px] px-6"
onClick={handleSend}
disabled={streaming || !input.trim()}
disabled={streaming || (!input.trim() && attachments.length === 0)}
>
{streaming ? "生成中…" : "发送"}
</button>
@@ -289,7 +507,6 @@ export default function ChatPage() {
</div>
</div>
{/* 右侧边栏:当前模型 + 部署/请求参数(可收起) */}
{rightOpen ? (
<div className="flex w-72 flex-col overflow-y-auto border-l border-border bg-panel p-3">
<div className="mb-2 flex items-center justify-between">
@@ -345,9 +562,7 @@ export default function ChatPage() {
<div className="h-1.5 overflow-hidden rounded-full bg-panel-2">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-300"
style={{
width: `${deployProgress[selectedModelId]?.percent ?? 8}%`,
}}
style={{ width: `${deployProgress[selectedModelId]?.percent ?? 8}%` }}
/>
</div>
<div className="mt-1 text-[10px] text-slate-400">
@@ -424,6 +639,184 @@ export default function ChatPage() {
);
}
function MessageBubble({
message,
streaming,
editingId,
editingDraft,
versions,
onEditStart,
onEditDraftChange,
onEditCancel,
onEditSave,
onRegenerate,
onToggleVersions,
onApplyVersion,
}: {
message: LocalMessage;
streaming: boolean;
editingId: string | null;
editingDraft: string;
versions: MessageVersion[] | null;
onEditStart: (m: LocalMessage) => void;
onEditDraftChange: (v: string) => void;
onEditCancel: () => void;
onEditSave: (m: LocalMessage) => void;
onRegenerate: (m: LocalMessage) => void;
onToggleVersions: (m: LocalMessage) => void;
onApplyVersion: (m: LocalMessage, v: MessageVersion) => void;
}) {
const isUser = message.role === "user";
const editing = editingId === message.id;
return (
<div className={`group relative flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed ${
isUser
? "bg-gradient-to-br from-accent to-accent-2 text-white"
: "markdown-body border border-border bg-panel"
}`}
>
{message.images && message.images.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{message.images.map((src, i) => (
<img
key={i}
src={src}
alt={`attachment-${i}`}
className="max-h-40 rounded-lg border border-white/20 object-cover"
/>
))}
</div>
) : null}
{isUser && editing ? (
<div className="w-[420px]">
<textarea
className="input min-h-[100px] resize-y bg-white/10 text-white"
value={editingDraft}
autoFocus
onChange={(e) => onEditDraftChange(e.target.value)}
/>
<div className="mt-2 flex gap-2">
<button
className="rounded-lg bg-white/20 px-3 py-1 text-xs hover:bg-white/30"
onClick={() => onEditSave(message)}
disabled={streaming || !editingDraft.trim()}
>
</button>
<button
className="rounded-lg bg-white/10 px-3 py-1 text-xs hover:bg-white/20"
onClick={onEditCancel}
>
</button>
</div>
</div>
) : isUser ? (
<>
<div className="whitespace-pre-wrap">{message.content}</div>
{!streaming ? (
<button
className="absolute -left-8 top-2 hidden rounded p-1 text-slate-500 hover:text-slate-200 group-hover:block"
onClick={() => onEditStart(message)}
title="编辑并重新提交"
>
</button>
) : null}
</>
) : message.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
) : (
<span className="inline-flex items-center gap-1 text-slate-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
</span>
)}
{!isUser && !message.streaming && message.content ? (
<>
<MessageStats message={message} />
<div className="mt-2 flex items-center gap-3 text-xs text-slate-500">
<button
className="hover:text-slate-200"
onClick={() => onRegenerate(message)}
disabled={streaming}
>
</button>
<button className="hover:text-slate-200" onClick={() => onToggleVersions(message)}>
{versions && versions.length > 0 ? `${versions.length}` : ""}
</button>
</div>
{versions && versions.length > 0 ? (
<div className="mt-2 space-y-1 rounded-lg border border-border bg-panel-2/70 p-2">
{versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 hover:bg-panel-2"
>
<span className="text-xs text-slate-400">
{v.seq} · {v.created_at}
{v.tokens_out != null ? ` · ${v.tokens_out} tokens` : ""}
</span>
<button
className="text-xs text-slate-300 hover:text-white"
onClick={() => onApplyVersion(message, v)}
>
</button>
</div>
))}
</div>
) : null}
</>
) : null}
</div>
</div>
);
}
function MessageStats({ message }: { message: LocalMessage }) {
const items: string[] = [];
if (message.firstTokenMs != null) items.push(`首字 ${fmtMs(message.firstTokenMs)}`);
if (message.tokensOut != null) {
items.push(`${message.tokensEstimated ? "≈ " : ""}${message.tokensOut} tokens`);
}
if (
message.tokensOut != null &&
message.elapsedMs != null &&
message.elapsedMs > 0
) {
items.push(`${((message.tokensOut / (message.elapsedMs / 1000))).toFixed(1)} tok/s`);
}
if (message.elapsedMs != null) items.push(`${fmtMs(message.elapsedMs)}`);
if (items.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-500">
{items.map((item) => (
<span key={item}>{item}</span>
))}
</div>
);
}
function fmtMs(ms: number) {
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`;
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function ParamSlider({
label,
value,