feat(frontend): 合并消息列表栈并保留 YAML 物化

This commit is contained in:
肖应宇 2026-03-28 23:40:39 +08:00
parent 2501dee2ba
commit d0be23b8fb
2 changed files with 64 additions and 10 deletions

View File

@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import { useParams } from "next/navigation";
import { memo, useMemo, type ImgHTMLAttributes } from "react";
import { memo, useMemo, useState, type ImgHTMLAttributes } from "react";
import rehypeKatex from "rehype-katex";
import { Loader } from "@/components/ai-elements/loader";
@ -18,6 +18,7 @@ import {
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
@ -28,6 +29,7 @@ import {
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
@ -262,6 +264,11 @@ function isImageFile(filename: string): boolean {
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
}
function isYamlFile(filename: string): boolean {
const ext = getFileExt(filename);
return ext === "yaml" || ext === "yml";
}
/**
* Format bytes to human-readable size string
*/
@ -312,6 +319,11 @@ function RichFileCard({
const { t } = useI18n();
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
const isYaml = isYamlFile(file.filename);
const [isMaterializing, setIsMaterializing] = useState(false);
const [materializeMessage, setMaterializeMessage] = useState<string | null>(
null,
);
if (isUploading) {
return (
@ -344,6 +356,28 @@ function RichFileCard({
const fileUrl = resolveArtifactURL(file.path, threadId);
const handleMaterializeYaml = async () => {
if (!isYaml || isMaterializing) return;
setIsMaterializing(true);
setMaterializeMessage(null);
try {
const result = await materializeSkillYaml({
thread_id: threadId,
path: file.path!,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
setMaterializeMessage(
`已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`,
);
} catch (error) {
const message = error instanceof Error ? error.message : "解析失败";
setMaterializeMessage(`失败: ${message}`);
} finally {
setIsMaterializing(false);
}
};
if (isImage) {
return (
<a
@ -383,6 +417,26 @@ function RichFileCard({
{formatBytes(file.size)}
</span>
</div>
{isYaml && (
<div className="mt-1 flex flex-col gap-1">
<Button
size="sm"
variant="secondary"
className="h-7 text-xs"
onClick={() => {
void handleMaterializeYaml();
}}
disabled={isMaterializing}
>
{isMaterializing ? "解析中..." : "一键导入为 Skill 目录"}
</Button>
{materializeMessage && (
<span className="text-muted-foreground text-[10px] leading-tight">
{materializeMessage}
</span>
)}
</div>
)}
</div>
);
}

View File

@ -50,23 +50,23 @@ export function MessageList({
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = messagesOverride ?? thread.messages;
if (thread.isThreadLoading && !suppressThreadLoading) {
if (thread.isThreadLoading && !suppressThreadLoading && messages.length === 0) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="w-full gap-8 px-[20px]">
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return (
return group.messages.map((msg) => (
<MessageListItem
key={group.id}
message={group.messages[0]!}
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={thread.isLoading}
/>
);
));
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
@ -172,9 +172,9 @@ export function MessageList({
{t.subtasks.executing(tasks.size)}
</div>,
);
const taskIds = message.tool_calls?.map(
(toolCall) => toolCall.id,
);
const taskIds = message.tool_calls
?.filter((toolCall) => toolCall.name === "task")
.map((toolCall) => toolCall.id);
for (const taskId of taskIds ?? []) {
results.push(
<SubtaskCard