484 lines
20 KiB
Svelte
484 lines
20 KiB
Svelte
<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>
|