570 lines
25 KiB
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>
|