309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
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";
|
||
}
|