863 lines
29 KiB
TypeScript
863 lines
29 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import ReactMarkdown from "react-markdown";
|
||
import remarkGfm from "remark-gfm";
|
||
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 = {
|
||
temperature: 0.7,
|
||
top_p: 0.9,
|
||
max_tokens: 2048,
|
||
ctx_size: 4096,
|
||
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[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [streaming, setStreaming] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [params, setParams] = useState<ChatParams>(defaultParams);
|
||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||
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) => {
|
||
const next = !v;
|
||
localStorage.setItem("xianren-right-open", next ? "1" : "0");
|
||
return next;
|
||
});
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (models.length > 0 && !selectedModelId) {
|
||
setSelectedModelId(models[0].id);
|
||
}
|
||
}, [models, selectedModelId]);
|
||
|
||
useEffect(() => {
|
||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages]);
|
||
|
||
useEffect(() => {
|
||
const un1 = onEvent<ChatTokenEvent>("chat://token", (e) => {
|
||
if (e.conversation_id !== activeConvId) return;
|
||
setMessages((prev) => {
|
||
const last = prev[prev.length - 1];
|
||
if (!last || last.role !== "assistant") {
|
||
return [
|
||
...prev,
|
||
{ id: `tmp-${Date.now()}`, role: "assistant", content: e.text, streaming: true },
|
||
];
|
||
}
|
||
const updated = [...prev];
|
||
updated[updated.length - 1] = {
|
||
...last,
|
||
content: last.content + e.text,
|
||
streaming: true,
|
||
};
|
||
return updated;
|
||
});
|
||
});
|
||
const un2 = onEvent<ChatDoneEvent>("chat://done", (e) => {
|
||
if (e.conversation_id !== activeConvId) return;
|
||
setMessages((prev) => {
|
||
const updated = [...prev];
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.role === "assistant" && last.streaming) {
|
||
updated[updated.length - 1] = {
|
||
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;
|
||
});
|
||
setStreaming(false);
|
||
refreshConversations();
|
||
});
|
||
const un3 = onEvent<ChatErrorEvent>("chat://error", (e) => {
|
||
if (e.conversation_id !== activeConvId) return;
|
||
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 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("请先在模型管理添加一个模型");
|
||
return;
|
||
}
|
||
setError(null);
|
||
setInput("");
|
||
setAttachments([]);
|
||
|
||
let convId = activeConvId;
|
||
if (!convId) {
|
||
const conv = await api.createConversation(content.slice(0, 30) || "新会话");
|
||
convId = conv.id;
|
||
setActiveConvId(convId);
|
||
await refreshConversations();
|
||
}
|
||
|
||
const tempUserId = `user-${Date.now()}`;
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ id: tempUserId, role: "user", content, images },
|
||
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
|
||
]);
|
||
setStreaming(true);
|
||
|
||
try {
|
||
const result = await api.chatSend({
|
||
conversation_id: convId,
|
||
model_id: selectedModelId,
|
||
content,
|
||
params,
|
||
images,
|
||
});
|
||
if (result?.message_id) {
|
||
setMessages((prev) =>
|
||
prev.map((m) => (m.id === tempUserId ? { ...m, id: result.message_id } : m)),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
setStreaming(false);
|
||
setError(String(e));
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function handleLoadConversation(id: string) {
|
||
setActiveConvId(id);
|
||
const msgs = await api.getMessages(id);
|
||
setMessages(
|
||
msgs.map((m) => ({
|
||
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) {
|
||
await api.deleteConversation(id);
|
||
if (activeConvId === id) {
|
||
setActiveConvId(null);
|
||
setMessages([]);
|
||
}
|
||
await refreshConversations();
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full">
|
||
<div className="flex w-60 flex-col border-r border-border bg-panel p-3">
|
||
<button className="btn-primary mb-3 w-full" onClick={handleNewConversation}>
|
||
+ 新会话
|
||
</button>
|
||
<div className="flex-1 space-y-1 overflow-auto">
|
||
{conversations.map((c) => (
|
||
<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"
|
||
}`}
|
||
onClick={() => handleLoadConversation(c.id)}
|
||
>
|
||
<span className="truncate">{c.title}</span>
|
||
<button
|
||
className="ml-1 hidden text-xs text-slate-500 hover:text-red-400 group-hover:block"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteConversation(c.id);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col">
|
||
<div className="flex-1 space-y-4 overflow-auto p-6">
|
||
{messages.length === 0 && !error ? (
|
||
<div className="mt-24 text-center text-sm text-slate-500">
|
||
<div className="mb-2 text-3xl">💬</div>
|
||
选择左侧会话,或直接开始新的对话。
|
||
<div className="mt-1 text-xs text-slate-600">
|
||
支持上传/粘贴图片与文本文件(本地文本模型仅支持文本文件)
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{messages.map((m) => (
|
||
<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">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
<div ref={bottomRef} />
|
||
</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={`向模型提问…(可粘贴图片/文件)`}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
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() && attachments.length === 0)}
|
||
>
|
||
{streaming ? "生成中…" : "发送"}
|
||
</button>
|
||
</div>
|
||
</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">
|
||
<span className="text-xs uppercase text-slate-400">参数面板</span>
|
||
<button
|
||
className="rounded p-1 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
|
||
onClick={toggleRight}
|
||
title="收起参数面板"
|
||
>
|
||
<Icon name="chevron-right" />
|
||
</button>
|
||
</div>
|
||
<div className="mb-1 text-xs text-slate-400">当前模型</div>
|
||
<select
|
||
className="input w-full"
|
||
value={selectedModelId}
|
||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||
>
|
||
{models.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.file_name}
|
||
{m.kind === "remote" ? "(API)" : ""}
|
||
{engineRunning && engineModel === m.file_name ? " ●已部署" : ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{selectedIsRemote ? (
|
||
<div className="mt-2 rounded-lg border border-sky-500/30 bg-sky-500/10 px-3 py-1.5 text-xs text-sky-300">
|
||
在线 API 模型,无需本地引擎
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 flex gap-2">
|
||
<button
|
||
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
|
||
onClick={() =>
|
||
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
|
||
}
|
||
disabled={engineRunning}
|
||
>
|
||
启动引擎
|
||
</button>
|
||
<button
|
||
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
|
||
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
|
||
disabled={!engineRunning}
|
||
>
|
||
停止引擎
|
||
</button>
|
||
</div>
|
||
)}
|
||
{deployStates[selectedModelId] === "loading" ? (
|
||
<div className="mt-2">
|
||
<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}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 text-[10px] text-slate-400">
|
||
{deployProgress[selectedModelId]?.stage ?? "启动引擎进程"}{" "}
|
||
{deployProgress[selectedModelId]?.percent ?? 8}%
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div className="mt-1 truncate text-xs text-slate-500">{engineLabel}</div>
|
||
|
||
<div className="mt-4 border-t border-border pt-3">
|
||
<div className="mb-3 text-xs uppercase text-slate-400">部署参数</div>
|
||
<ParamSlider
|
||
label="上下文长度 ctx_size"
|
||
value={params.ctx_size}
|
||
min={512}
|
||
max={32768}
|
||
step={512}
|
||
onChange={(v) => setParams({ ...params, ctx_size: v })}
|
||
/>
|
||
<ParamSlider
|
||
label="GPU 层数 ngl(99=全部)"
|
||
value={params.ngl}
|
||
min={0}
|
||
max={99}
|
||
step={1}
|
||
onChange={(v) => setParams({ ...params, ngl: v })}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-4 border-t border-border pt-3">
|
||
<div className="mb-3 text-xs uppercase text-slate-400">请求参数</div>
|
||
<ParamSlider
|
||
label="temperature"
|
||
value={params.temperature}
|
||
min={0}
|
||
max={2}
|
||
step={0.05}
|
||
onChange={(v) => setParams({ ...params, temperature: v })}
|
||
/>
|
||
<ParamSlider
|
||
label="top_p"
|
||
value={params.top_p}
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
onChange={(v) => setParams({ ...params, top_p: v })}
|
||
/>
|
||
<ParamSlider
|
||
label="max_tokens"
|
||
value={params.max_tokens}
|
||
min={64}
|
||
max={8192}
|
||
step={64}
|
||
onChange={(v) => setParams({ ...params, max_tokens: v })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex w-9 flex-col items-center border-l border-border bg-panel py-2">
|
||
<button
|
||
className="rounded p-1.5 text-slate-400 hover:bg-panel-2/60 hover:text-slate-200"
|
||
onClick={toggleRight}
|
||
title="展开参数面板"
|
||
>
|
||
<Icon name="chevron-left" />
|
||
</button>
|
||
<span className="mt-3 whitespace-nowrap text-[10px] text-slate-500 [writing-mode:vertical-rl]">
|
||
参数
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<div
|
||
className={`mt-1 flex justify-end transition-opacity ${
|
||
streaming ? "opacity-0" : "opacity-0 group-hover:opacity-100"
|
||
}`}
|
||
>
|
||
<button
|
||
className="rounded px-1.5 py-0.5 text-[10px] text-white/60 hover:bg-white/10 hover:text-white"
|
||
onClick={() => onEditStart(message)}
|
||
title="编辑并重新提交(清空之后的生成)"
|
||
>
|
||
编辑
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : 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,
|
||
min,
|
||
max,
|
||
step,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<label className="mb-3 block">
|
||
<div className="mb-1 flex items-center justify-between text-xs text-slate-400">
|
||
<span>{label}</span>
|
||
<span className="font-mono text-slate-300">{value}</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
className="w-full accent-indigo-500"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|