PicoBot/webui/src/pages/ChatPage.svelte

484 lines
20 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 Markdown from "../lib/Markdown.svelte";
import ToolCallCard from "../lib/ToolCallCard.svelte";
let { notify } = $props();
let socket = $state(null);
let connected = $state(false);
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 pendingUploads = $state([]);
let fileInput;
let plansBySession = $state({});
let unseenPlanSessions = $state({});
let todoOpen = $state(false);
let messageBox;
let input;
let reconnectTimer;
let stopped = false;
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 (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
else notify("聊天连接尚未就绪", true);
}
function connect() {
const scheme = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${scheme}://${location.host}/ws?client_id=${encodeURIComponent(clientId())}`);
socket = ws;
ws.onopen = () => {
connected = true;
plansBySession = {};
unseenPlanSessions = {};
todoOpen = false;
send({ type: "list_sessions", include_archived: false });
send({ type: "get_slash_commands" });
};
ws.onerror = () => ws.close();
ws.onclose = () => {
connected = false;
if (!stopped) reconnectTimer = setTimeout(connect, 1800);
};
ws.onmessage = (event) => handleFrame(JSON.parse(event.data));
}
function handleFrame(frame) {
switch (frame.type) {
case "session_established": currentId = frame.session_id; 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 = [];
send({ type: "list_sessions", include_archived: false });
break;
case "session_loaded":
currentId = frame.session_id;
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 || [];
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 "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;
clearPendingUploads();
messages = [];
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) || !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"].includes(attachment.mime_type);
}
function addFiles(files) {
for (const file of Array.from(files || [])) uploadFile(file);
}
function uploadFile(file) {
if (!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(() => {
connect();
return () => {
stopped = true;
clearTimeout(reconnectTimer);
socket?.close();
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}
<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.content}<div class="bubble"><Markdown content={message.content} /></div>{/if}
{#if message.attachments?.length}
<div class="message-attachments">
{#each message.attachments as attachment (`${message.id}:${attachment.index}`)}
<article class="attachment-card">
{#if canPreview(attachment)}
<img src={attachmentUrl(message, attachment, true)} alt={attachment.name} />
{:else}<span class="attachment-icon">▧</span>{/if}
<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>
{/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 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={!connected || (!draft.trim() && !pendingUploads.some((upload) => upload.status === "ready")) || pendingUploads.some((upload) => upload.status === "uploading")}>↑</button>
<small><span class:online={connected}>{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>