PicoBot/webui/src/pages/ChatPage.svelte

529 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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";
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; activeTurn = null; historyRevision = 0; break;
case "session_list":
sessions = frame.sessions || [];
if (frame.current_session_id) currentId = frame.current_session_id;
if (currentId && messages.length === 0) loadSession(currentId);
break;
case "session_created":
currentId = frame.session_id;
messages = [];
activeTurn = null;
historyRevision = 0;
send({ type: "list_sessions", include_archived: false });
break;
case "session_loaded":
currentId = frame.session_id;
activeTurn = null;
historyRevision = 0;
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
send({ type: "get_session_plan", 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 });
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;
activeTurn = next;
thinking = next.status === "running";
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 byId = new Map(messages.map((message) => [message.id, message]));
for (const message of frame.messages || []) byId.set(message.id, message);
messages = [...byId.values()];
historyRevision = frame.history_revision;
if (activeTurn?.status !== "running"
&& (frame.messages || []).some((message) => message.id === activeTurn?.message_id)) {
activeTurn = null;
}
scrollToBottom();
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); 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;
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;
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
})));
thinking = true;
send({ type: "user_input", content, upload_ids: ready.map((upload) => upload.upload_id) });
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 })}> 新建对话</button>
<label class="search"><span></span><input bind:value={search} placeholder="搜索对话" /></label>
<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}>
任务 {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 }); }}>↻</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">P</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">{message.role === "user" ? "你" : "P"}</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}`}>↓</a></div>
</article>
{:else}
<article class="attachment-card">
<span class="attachment-icon">▧</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}`}>↓</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">P</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">
<span>▧</span><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)}>×</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()}></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")}>↑</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}>×</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>