2026-08-14 15:41:59 +08:00

309 lines
10 KiB
TypeScript
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.

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<string[]>([]);
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<string>,
attachments: State<string[]>,
attachmentContext: State<string>,
pickerOpen: State<boolean>,
submit: () => Promise<void>,
): 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<string, string>
)[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";
}