feat: chat stats, regenerate with version history, edit & resubmit, image/file attachments
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user