PicoBot/webui/src/pages/ChatPage.svelte

570 lines
25 KiB
Svelte

<script>
import { onMount, tick } from "svelte";
import { Tooltip } from "bits-ui";
import { clientId, formatTime, randomId } from "../lib/api.js";
import { chat } from "../lib/chat.svelte.js";
import Markdown from "../lib/Markdown.svelte";
import ToolCallCard from "../lib/ToolCallCard.svelte";
import TurnView from "../lib/TurnView.svelte";
import Icon from "../lib/Icon.svelte";
let { notify } = $props();
let sessions = $state([]);
let currentId = $state(null);
let messages = $state([]);
let search = $state("");
let draft = $state("");
let commands = $state([]);
let selectedCommand = $state(0);
let commandMenuDismissed = $state(false);
let thinking = $state(false);
let activeTurn = $state(null);
let historyRevision = $state(0);
let pendingUploads = $state([]);
let fileInput;
let plansBySession = $state({});
let unseenPlanSessions = $state({});
let todoOpen = $state(false);
let messageBox;
let input;
const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
const currentPlan = $derived(currentId ? plansBySession[currentId] || null : null);
const completedItems = $derived(currentPlan?.items?.filter((item) => item.status === "completed").length || 0);
const filteredSessions = $derived(sessions.filter((item) => item.title.toLowerCase().includes(search.toLowerCase())));
const commandQuery = $derived(
!commandMenuDismissed && draft.startsWith("/") && !/[\s]/.test(draft)
? draft.toLowerCase()
: null
);
const commandSuggestions = $derived.by(() => {
if (commandQuery === null) return [];
const term = commandQuery.slice(1);
return commands.flatMap((command) => {
const aliases = command.aliases?.length ? command.aliases : [`/${command.name}`];
return aliases
.filter((alias) => alias.toLowerCase().startsWith(commandQuery)
|| command.name.toLowerCase().startsWith(term)
|| command.description.toLowerCase().includes(term))
.map((alias) => ({ ...command, alias }));
});
});
function send(frame) {
if (!chat.send(frame)) notify("聊天连接尚未就绪", true);
}
function handleFrame(frame) {
switch (frame.type) {
case "session_established":
currentId = frame.session_id;
chat.currentSessionId = currentId;
activeTurn = null;
historyRevision = 0;
send({ type: "get_session_stats", session_id: currentId });
break;
case "session_list":
sessions = frame.sessions || [];
if (frame.current_session_id) currentId = frame.current_session_id;
chat.currentSessionId = currentId;
if (currentId && messages.length === 0) loadSession(currentId);
break;
case "session_created":
currentId = frame.session_id;
chat.currentSessionId = currentId;
messages = [];
activeTurn = null;
historyRevision = 0;
send({ type: "list_sessions", include_archived: false });
break;
case "session_loaded":
currentId = frame.session_id;
chat.currentSessionId = currentId;
activeTurn = null;
historyRevision = 0;
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
send({ type: "get_session_plan", session_id: currentId });
send({ type: "get_session_stats", session_id: currentId });
break;
case "session_history":
if (frame.session_id === currentId) {
messages = frame.messages || [];
historyRevision = Math.max(0, ...messages.map((message) => message.seq || 0));
if (activeTurn?.status !== "running" && messages.some((message) => message.id === activeTurn?.message_id)) activeTurn = null;
scrollToBottom();
}
break;
case "session_plan":
if (frame.plan) {
const previous = plansBySession[frame.session_id];
if (frame.plan.id !== previous?.id || frame.plan.version >= (previous?.version || 0)) {
plansBySession[frame.session_id] = frame.plan;
}
} else if (plansBySession[frame.session_id]?.status !== "active") {
plansBySession[frame.session_id] = null;
}
break;
case "plan_updated": {
const previousPlan = plansBySession[frame.session_id];
const previousVersion = previousPlan?.version || 0;
const nextVersion = frame.plan?.version || 0;
if (frame.plan?.id === previousPlan?.id && nextVersion && nextVersion <= previousVersion) break;
plansBySession[frame.session_id] = frame.plan;
if (frame.session_id === currentId) {
todoOpen = true;
unseenPlanSessions[frame.session_id] = false;
} else {
unseenPlanSessions[frame.session_id] = true;
}
break;
}
case "slash_commands_list":
commands = frame.commands || [];
selectedCommand = 0;
break;
case "assistant_response":
thinking = false;
if (!frame.session_id || frame.session_id === currentId) {
appendMessage(frame.role || "assistant", frame.content, frame.attachments || [], frame.id);
if (currentId) send({ type: "get_session_history", session_id: currentId, limit: 1000 });
}
send({ type: "list_sessions", include_archived: false });
if (currentId) send({ type: "get_session_stats", session_id: currentId });
break;
case "turn_updated": {
const next = frame.snapshot;
if (!next || next.session_id !== currentId) break;
if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break;
const firstSnapshotForTurn = activeTurn?.id !== next.id;
activeTurn = next;
thinking = next.status === "running";
if (firstSnapshotForTurn) send({ type: "get_session_stats", session_id: currentId });
if (next.status !== "running" && messages.some((message) => message.id === next.message_id)) {
activeTurn = null;
}
scrollToBottom();
if (next.status !== "running") {
if (next.status !== "completed") {
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
}
send({ type: "list_sessions", include_archived: false });
}
break;
}
case "turn_committed": {
if (frame.session_id !== currentId || frame.history_revision <= historyRevision) break;
const committed = frame.messages || [];
const committedIds = new Set(committed.map((message) => message.id));
const firstOptimisticMatch = messages.findIndex((message) => committedIds.has(message.id));
const retained = messages.filter((message) => !committedIds.has(message.id));
const insertionIndex = firstOptimisticMatch < 0
? retained.length
: messages.slice(0, firstOptimisticMatch)
.filter((message) => !committedIds.has(message.id)).length;
// Replace optimistic steering at its original position with the whole
// durable batch. A Map#set replacement would keep only the user's
// old slot and incorrectly place earlier tool messages after it.
messages = [
...retained.slice(0, insertionIndex),
...committed,
...retained.slice(insertionIndex)
];
historyRevision = frame.history_revision;
if (activeTurn?.status !== "running"
&& (frame.messages || []).some((message) => message.id === activeTurn?.message_id)) {
activeTurn = null;
}
scrollToBottom();
send({ type: "get_session_stats", session_id: currentId });
break;
}
case "session_stats": break;
case "system_notification":
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
break;
case "command_executed":
thinking = false;
appendMessage("assistant", frame.message);
if (currentId) send({ type: "get_session_stats", session_id: currentId });
break;
case "error": thinking = false; notify(frame.message || frame.code, true); break;
}
}
async function scrollToBottom() {
await tick();
if (messageBox) messageBox.scrollTop = messageBox.scrollHeight;
}
function appendMessage(role, content, attachments = [], id = randomId()) {
messages = [...messages, { id, role, content, attachments }];
scrollToBottom();
}
function loadSession(id) {
if (!id) return;
currentId = id;
chat.currentSessionId = id;
historyRevision = 0;
clearPendingUploads();
messages = [];
activeTurn = null;
thinking = false;
todoOpen = Boolean(unseenPlanSessions[id]);
unseenPlanSessions[id] = false;
send({ type: "load_session", session_id: id });
}
function itemIcon(status) {
if (status === "completed") return "✓";
if (status === "in_progress") return "●";
if (status === "blocked") return "!";
return "○";
}
function toolResult(callId) {
return messages.find((message) => message.role === "tool" && message.tool_call_id === callId) || null;
}
function submit() {
const content = draft.trim();
const ready = pendingUploads.filter((upload) => upload.status === "ready");
if ((!content && !ready.length) || !chat.connected || pendingUploads.some((upload) => upload.status === "uploading")) return;
const clientMessageId = randomId();
appendMessage("user", content, ready.map((upload, index) => ({
index,
name: upload.name,
media_type: upload.media_type,
mime_type: upload.mime_type,
local_url: upload.localUrl
})), clientMessageId);
thinking = true;
send({
type: "user_input",
content,
upload_ids: ready.map((upload) => upload.upload_id),
client_message_id: clientMessageId
});
draft = "";
pendingUploads = [];
commandMenuDismissed = false;
selectedCommand = 0;
if (input) input.style.height = "auto";
}
function attachmentUrl(message, attachment, inline = false) {
if (attachment.local_url) return attachment.local_url;
if (!currentId || !message.id) return "";
const base = `/api/chat/${encodeURIComponent(clientId())}/sessions/${encodeURIComponent(currentId)}/messages/${encodeURIComponent(message.id)}/attachments/${attachment.index}`;
return inline ? `${base}?disposition=inline` : base;
}
function formatBytes(value) {
if (!Number.isFinite(value)) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
function canPreview(attachment) {
return ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/avif", "image/x-icon"].includes(attachment.mime_type);
}
function addFiles(files) {
for (const file of Array.from(files || [])) uploadFile(file);
}
function uploadFile(file) {
if (!chat.connected) return notify("聊天连接尚未就绪", true);
const localId = randomId();
const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
pendingUploads = [...pendingUploads, {
localId, name: file.name, size: file.size, progress: 0, status: "uploading", localUrl
}];
const body = new FormData();
body.append("file", file, file.name);
const request = new XMLHttpRequest();
request.open("POST", `/api/chat/${encodeURIComponent(clientId())}/uploads`);
request.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
pendingUploads = pendingUploads.map((item) => item.localId === localId
? { ...item, progress: Math.round(event.loaded / event.total * 100) }
: item);
};
request.onload = () => {
let response = {};
try { response = JSON.parse(request.responseText || "{}"); } catch {}
if (request.status >= 200 && request.status < 300) {
pendingUploads = pendingUploads.map((item) => item.localId === localId
? { ...item, ...response, status: "ready", progress: 100 }
: item);
} else {
pendingUploads = pendingUploads.map((item) => item.localId === localId
? { ...item, status: "error", error: response.error || `上传失败 (${request.status})` }
: item);
}
};
request.onerror = () => {
pendingUploads = pendingUploads.map((item) => item.localId === localId
? { ...item, status: "error", error: "网络错误" }
: item);
};
request.send(body);
}
function removeUpload(localId) {
const upload = pendingUploads.find((item) => item.localId === localId);
if (upload?.localUrl) URL.revokeObjectURL(upload.localUrl);
pendingUploads = pendingUploads.filter((item) => item.localId !== localId);
}
function clearPendingUploads() {
for (const upload of pendingUploads) if (upload.localUrl) URL.revokeObjectURL(upload.localUrl);
pendingUploads = [];
}
function dropFiles(event) {
event.preventDefault();
addFiles(event.dataTransfer?.files);
}
function pasteFiles(event) {
const files = Array.from(event.clipboardData?.items || [])
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter(Boolean);
if (files.length) addFiles(files);
}
async function moveCommandSelection(offset) {
const length = commandSuggestions.length;
if (!length) return;
selectedCommand = (selectedCommand + offset + length) % length;
await tick();
document.getElementById(`slash-command-${selectedCommand}`)?.scrollIntoView({ block: "nearest" });
}
async function completeCommand(index = selectedCommand) {
const command = commandSuggestions[index];
if (!command) return;
draft = `${command.alias} `;
selectedCommand = 0;
commandMenuDismissed = true;
await tick();
input?.focus();
if (input) input.style.height = "auto";
}
function keydown(event) {
if (event.isComposing) return;
if (commandSuggestions.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
moveCommandSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveCommandSelection(-1);
return;
}
if (event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) {
event.preventDefault();
completeCommand();
return;
}
if (event.key === "Escape") {
event.preventDefault();
commandMenuDismissed = true;
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}
function resize(event) {
event.currentTarget.style.height = "auto";
event.currentTarget.style.height = `${Math.min(event.currentTarget.scrollHeight, 180)}px`;
}
function inputChanged(event) {
commandMenuDismissed = false;
selectedCommand = 0;
resize(event);
}
onMount(() => {
const unsubscribe = chat.subscribe(handleFrame);
const onOpen = (frame) => {
if (frame.type !== "_open") return;
plansBySession = {};
unseenPlanSessions = {};
todoOpen = false;
send({ type: "list_sessions", include_archived: false });
send({ type: "get_slash_commands" });
};
const unsubOpen = chat.subscribe(onOpen);
if (chat.connected) onOpen({ type: "_open" });
return () => {
unsubscribe();
unsubOpen();
clearPendingUploads();
};
});
</script>
<section class:todo-open={todoOpen && currentPlan} class="page active chat-layout">
<aside class="sessions-panel">
<button class="primary full" onclick={() => send({ type: "create_session", title: null })}><Icon name="add" size={16} />新建对话</button>
<label class="search"><Icon name="search" size={16} /><input bind:value={search} placeholder="搜索对话" /></label>
<div class="session-list-head"><span class="label-caps">最近对话</span><span class="session-count">{filteredSessions.length}</span></div>
<div class="session-list">
{#each filteredSessions as session (session.session_id)}
<button class:active={session.session_id === currentId} class="session-item" onclick={() => loadSession(session.session_id)}>
<strong>{session.title}{#if unseenPlanSessions[session.session_id]}<i class="plan-unread" aria-label="任务计划有更新"></i>{/if}</strong><small><span>{session.message_count} 条消息</span><span>{formatTime(session.last_active_at).split(" ")[0]}</span></small>
</button>
{:else}<div class="empty-card compact">暂无对话</div>{/each}
</div>
</aside>
<div class="chat-panel">
<div class="chat-heading">
<div><strong>{currentSession?.title || "新对话"}</strong><small>WebUI 会话</small></div>
<div class="chat-heading-actions">
{#if currentPlan}
<button class:active={todoOpen} class="todo-toggle" onclick={() => todoOpen = !todoOpen} aria-expanded={todoOpen}>
<Icon name="panel" size={14} />任务 {completedItems}/{currentPlan.items.length}
</button>
{/if}
<Tooltip.Root>
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) { send({ type: "get_session_plan", session_id: currentId }); send({ type: "get_session_stats", session_id: currentId }); } }}><Icon name="refresh" size={18} /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</div>
</div>
<div class="messages" bind:this={messageBox}>
{#if messages.length === 0 && !activeTurn}
<div class="empty"><div class="empty-logo"><Icon name="bot" size={26} /></div><h2>今天想做些什么?</h2><p>消息与 CLI 客户端使用同一套会话、记忆和工具能力。</p></div>
{/if}
{#each messages as message (message.id)}
{#if message.role !== "tool" && (message.content || message.tool_calls?.length || message.attachments?.length)}
<div class:user={message.role === "user"} class:assistant={message.role !== "user"} class:has-tools={message.tool_calls?.length} class="message">
<div class="avatar">{#if message.role === "user"}{:else}<Icon name="bot" size={15} />{/if}</div>
<div class="message-content">
{#if message.reasoning_content}
<details class="reasoning-block historical">
<summary>思考过程</summary>
<div class="reasoning-content"><Markdown content={message.reasoning_content} /></div>
</details>
{/if}
{#if message.content}<div class="bubble"><Markdown content={message.content} /></div>{/if}
{#if message.completion_status && message.completion_status !== "completed"}
<small class="completion-status">{message.completion_status === "cancelled" ? "已停止" : "回复中断"}</small>
{/if}
{#if message.attachments?.length}
<div class="message-attachments">
{#each message.attachments as attachment (`${message.id}:${attachment.index}`)}
{#if canPreview(attachment)}
<article class="image-attachment">
<a class="image-preview-link" href={attachmentUrl(message, attachment)} target="_blank" rel="noreferrer" aria-label={`查看原图 ${attachment.name}`}>
<img class="image-preview" src={attachmentUrl(message, attachment, true)} alt={attachment.name} />
</a>
<div class="attachment-meta"><div><strong>{attachment.name}</strong><small>{attachment.mime_type || attachment.media_type}</small></div><a href={attachmentUrl(message, attachment)} download={attachment.name} aria-label={`下载 ${attachment.name}`}><Icon name="download" size={16} /></a></div>
</article>
{:else}
<article class="attachment-card">
<span class="attachment-icon"><Icon name="document" size={18} /></span>
<div><strong>{attachment.name}</strong><small>{attachment.mime_type || attachment.media_type}</small></div>
<a href={attachmentUrl(message, attachment)} download={attachment.name} aria-label={`下载 ${attachment.name}`}><Icon name="download" size={16} /></a>
</article>
{/if}
{/each}
</div>
{/if}
{#if message.tool_calls?.length}
<div class="tool-calls">
{#each message.tool_calls as call (call.id)}
<ToolCallCard {call} result={toolResult(call.id)} />
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/each}
{#if activeTurn}<TurnView turn={activeTurn} />
{:else if thinking}<div class="message assistant typing"><div class="avatar"><Icon name="bot" size={15} /></div><div class="bubble"><span class="pulse"></span>正在思考…</div></div>{/if}
</div>
<form class="composer" onsubmit={(event) => { event.preventDefault(); submit(); }} ondragover={(event) => event.preventDefault()} ondrop={dropFiles}>
{#if commandSuggestions.length}
<div class="command-menu" id="slash-command-menu" role="listbox" aria-label="斜杠命令">
<div class="command-menu-heading"><span>斜杠命令</span><kbd>↑↓ 选择 · Tab/Enter 补全 · Esc 关闭</kbd></div>
{#each commandSuggestions as command, index (`${command.name}:${command.alias}`)}
<button
id={`slash-command-${index}`}
type="button"
role="option"
aria-selected={index === selectedCommand}
class:selected={index === selectedCommand}
onmouseenter={() => selectedCommand = index}
onclick={() => completeCommand(index)}
>
<code>{command.alias}</code><span>{command.description}</span>
</button>
{/each}
</div>
{/if}
{#if pendingUploads.length}
<div class="pending-uploads">
{#each pendingUploads as upload (upload.localId)}
<div class:error={upload.status === "error"} class="pending-upload">
<Icon name="document" size={16} /><div><strong>{upload.name}</strong><small>{upload.status === "uploading" ? `上传中 ${upload.progress}%` : upload.status === "error" ? upload.error : formatBytes(upload.size)}</small></div>
<button type="button" aria-label={`移除 ${upload.name}`} onclick={() => removeUpload(upload.localId)}><Icon name="dismiss" size={14} /></button>
</div>
{/each}
</div>
{/if}
<input class="file-input" bind:this={fileInput} type="file" multiple onchange={(event) => { addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} />
<button class="attach" type="button" aria-label="添加附件" onclick={() => fileInput?.click()}><Icon name="attach" size={18} /></button>
<textarea
bind:this={input}
bind:value={draft}
onkeydown={keydown}
oninput={inputChanged}
onpaste={pasteFiles}
rows="1"
role="combobox"
aria-autocomplete="list"
aria-controls="slash-command-menu"
aria-expanded={commandSuggestions.length > 0}
aria-activedescendant={commandSuggestions.length ? `slash-command-${selectedCommand}` : undefined}
placeholder="输入消息,输入 / 查看命令"
></textarea>
<button class="send" type="submit" aria-label="发送" disabled={!chat.connected || (!draft.trim() && !pendingUploads.some((upload) => upload.status === "ready")) || pendingUploads.some((upload) => upload.status === "uploading")}><Icon name="send" size={18} /></button>
<small><span class:online={chat.connected}>{chat.connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
</form>
</div>
{#if currentPlan}
<aside class="todo-panel" aria-label="当前任务计划">
<div class="todo-heading">
<div><small>当前计划</small><strong>{currentPlan.objective}</strong></div>
<button class="icon-button" aria-label="关闭任务侧栏" onclick={() => todoOpen = false}><Icon name="dismiss" size={18} /></button>
</div>
<div class="todo-progress"><span style={`width:${currentPlan.items.length ? completedItems / currentPlan.items.length * 100 : 0}%`}></span></div>
<div class="todo-summary"><span>{completedItems}/{currentPlan.items.length} 已完成</span><span>v{currentPlan.version}</span></div>
<div class="todo-items">
{#each currentPlan.items as item (item.id)}
<article class:blocked={item.status === "blocked"} class:running={item.status === "in_progress"} class:done={item.status === "completed"} class="todo-item">
<span class="todo-icon">{itemIcon(item.status)}</span>
<div><strong>{item.id} · {item.title}</strong><small>{item.executor_kind === "sub_agent" ? "子 Agent" : item.status === "in_progress" ? "主 Agent" : item.status}</small>
{#if item.result_summary}<p>{item.result_summary}</p>{/if}
{#if item.error}<p class="error-text">{item.error}</p>{/if}
</div>
</article>
{/each}
</div>
</aside>
{/if}
</section>