import van, { type State } from "vanjs-core"; import type { AppController } from "../../app/app-controller"; import "./conversation-pane.css"; import { ProjectPanel } from "../projects/project-panel"; import { InteractionCard } from "../interactions/interaction-card"; import { RunStatus } from "../runs/run-status"; import { FilePicker } from "../files/file-picker"; const { article, button, div, h1, header, main, p, span, textarea } = van.tags; export function ConversationPane(controller: AppController): HTMLElement { const draft = van.state(""); const attachments = van.state([]); const attachmentContext = van.state(""); const pickerOpen = van.state(false); async function submit() { const content = draft.val; const selected = attachmentContext.val === contextKey(controller) ? attachments.val : []; if (!content.trim() && selected.length === 0) return; if (await controller.send(content, selected)) { draft.val = ""; attachments.val = []; } } return main( { class: "main-pane" }, header({ class: "mobile-header" }, "Great Agent 2"), div({ class: "pane-content" }, () => controller.selection.val.kind.startsWith("project-") ? ProjectPanel(controller) : controller.selection.val.kind === "conversation" && controller.active.val ? div( { class: "conversation-view" }, h1({ class: "conversation-title" }, controller.active.val.title), div( { class: "message-list" }, controller.active.val.messages.map((message) => div( { class: "timeline-entry" }, article( { class: `message ${message.role}` }, span( { class: `message-role ${message.role}-role` }, message.role === "user" ? "你" : "G", ), message.content ? p(message.content) : div(), message.attachments.length ? div( { class: "message-attachments" }, message.attachments.map((attachment) => span( { class: `attachment-chip ${attachment.status}`, title: attachment.reason ?? attachment.path, }, "▤ ", attachment.name, attachment.status === "unavailable" ? " · 不可用" : attachment.status === "unsupported" ? " · 不支持读取" : "", ), ), ) : null, ), controller.interactions.val .filter( (interaction) => interaction.messageId === message.id, ) .map((interaction) => InteractionCard(controller, interaction), ), ), ), () => controller.assistantDraft.val ? article( { class: "message assistant streaming" }, span({ class: "message-role assistant-role" }, "G"), p(controller.assistantDraft), ) : null, () => div( { class: "tool-activities" }, ...controller.toolActivities.val.map((tool) => div( { class: `tool-activity ${tool.status}` }, span( tool.status === "running" ? "◌" : tool.status === "completed" ? "✓" : "!", ), span(toolName(tool.name)), span(tool.detail), ), ), ), ), () => RunStatus(controller), ) : div( { class: "onboarding" }, h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"), p("从一个问题、想法或具体任务开始。"), ), ), div( { class: "composer-slot", hidden: () => !canCompose(controller) }, Composer( controller, draft, attachments, attachmentContext, pickerOpen, submit, ), ), div({ class: "file-picker-host", hidden: () => !pickerOpen.val }, () => pickerOpen.val ? FilePicker(controller.selection.val, attachments, () => { pickerOpen.val = false; }) : div(), ), ); } function Composer( controller: AppController, draft: State, attachments: State, attachmentContext: State, pickerOpen: State, submit: () => Promise, ): HTMLElement { return div( { class: "composer-wrap" }, div( { class: "error-banner", role: "alert", hidden: () => !controller.error.val, }, () => controller.error.val, ), div( { class: "composer" }, div( { class: "selected-attachments", hidden: () => attachmentContext.val !== contextKey(controller) || attachments.val.length === 0, }, () => div( ...attachments.val.map((path) => button( { onclick: () => { attachments.val = attachments.val.filter( (item) => item !== path, ); }, title: path, }, `▤ ${path.split("/").at(-1) ?? path} ×`, ), ), ), ), textarea({ "aria-label": "输入消息", placeholder: "我能帮你做些什么?", value: draft, disabled: controller.sending, oninput: (event: Event) => { draft.val = (event.target as HTMLTextAreaElement).value; }, onkeydown: (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void submit(); } }, }), div( { class: "composer-actions" }, button( { class: "attach", disabled: () => !workspaceAvailable(controller) || controller.sending.val, onclick: () => { const key = contextKey(controller); if (attachmentContext.val !== key) attachments.val = []; attachmentContext.val = key; pickerOpen.val = true; }, "aria-label": "添加附件", title: () => workspaceAvailable(controller) ? "添加工作区文件" : "当前工作区不可用", }, "+", ), button( { class: "send", disabled: () => !controller.sending.val && !draft.val.trim() && !( attachmentContext.val === contextKey(controller) && attachments.val.length ), onclick: () => controller.sending.val ? void controller.stopGenerating() : void submit(), "aria-label": () => controller.sending.val ? "停止生成" : "发送消息", }, () => (controller.sending.val ? "■" : "↑"), ), ), ), div( { class: "composer-meta" }, button({ disabled: true }, () => isProjectConversation(controller) ? "项目对话" : "普通对话", ), span("DeepSeek"), ), ); } function contextKey(controller: AppController): string { const selection = controller.selection.val; if (selection.kind === "conversation") return `conversation:${selection.id}`; if (selection.kind === "project-draft") return `project:${selection.projectId}`; return "ordinary"; } function workspaceAvailable(controller: AppController): boolean { const selection = controller.selection.val; const projectId = selection.kind === "project-draft" ? selection.projectId : selection.kind === "conversation" ? controller.active.val?.projectId : null; if (!projectId) return true; return ( controller.projects.val.find((project) => project.id === projectId) ?.workspaceAvailable ?? false ); } function toolName(name: string): string { return ( ( { list_directory: "浏览文件", search_files: "搜索文件", read_text_file: "读取文件", } as Record )[name] ?? name ); } function isProjectConversation(controller: AppController): boolean { const selection = controller.selection.val; return ( selection.kind === "project-draft" || (selection.kind === "conversation" && controller.active.val?.projectId !== null) ); } function canCompose(controller: AppController): boolean { const selection = controller.selection.val; if (selection.kind === "conversation") return !controller.interactions.val.some( (interaction) => interaction.status === "pending", ); if (selection.kind === "ordinary-draft") return true; if (selection.kind === "project-draft") { return ( controller.projects.val.find( (project) => project.id === selection.projectId, )?.workspaceAvailable ?? false ); } return selection.kind === "none"; }