feat(frontend): 合并消息列表栈并保留 YAML 物化
This commit is contained in:
parent
2501dee2ba
commit
d0be23b8fb
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { FileIcon, Loader2Icon } from "lucide-react";
|
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
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 rehypeKatex from "rehype-katex";
|
||||||
|
|
||||||
import { Loader } from "@/components/ai-elements/loader";
|
import { Loader } from "@/components/ai-elements/loader";
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from "@/components/ai-elements/reasoning";
|
} from "@/components/ai-elements/reasoning";
|
||||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,6 +29,7 @@ import {
|
||||||
type FileInMessage,
|
type FileInMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import { materializeSkillYaml } from "@/core/skills";
|
||||||
import { humanMessagePlugins } from "@/core/streamdown";
|
import { humanMessagePlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -262,6 +264,11 @@ function isImageFile(filename: string): boolean {
|
||||||
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
|
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
|
* Format bytes to human-readable size string
|
||||||
*/
|
*/
|
||||||
|
|
@ -312,6 +319,11 @@ function RichFileCard({
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isUploading = file.status === "uploading";
|
const isUploading = file.status === "uploading";
|
||||||
const isImage = isImageFile(file.filename);
|
const isImage = isImageFile(file.filename);
|
||||||
|
const isYaml = isYamlFile(file.filename);
|
||||||
|
const [isMaterializing, setIsMaterializing] = useState(false);
|
||||||
|
const [materializeMessage, setMaterializeMessage] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -344,6 +356,28 @@ function RichFileCard({
|
||||||
|
|
||||||
const fileUrl = resolveArtifactURL(file.path, threadId);
|
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) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|
@ -383,6 +417,26 @@ function RichFileCard({
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,23 +50,23 @@ export function MessageList({
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
const messages = messagesOverride ?? thread.messages;
|
const messages = messagesOverride ?? thread.messages;
|
||||||
if (thread.isThreadLoading && !suppressThreadLoading) {
|
if (thread.isThreadLoading && !suppressThreadLoading && messages.length === 0) {
|
||||||
return <MessageListSkeleton />;
|
return <MessageListSkeleton />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Conversation
|
<Conversation
|
||||||
className={cn("flex size-full flex-col justify-center", className)}
|
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) => {
|
{groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return (
|
return group.messages.map((msg) => (
|
||||||
<MessageListItem
|
<MessageListItem
|
||||||
key={group.id}
|
key={`${group.id}/${msg.id}`}
|
||||||
message={group.messages[0]!}
|
message={msg}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
} else if (group.type === "assistant:clarification") {
|
} else if (group.type === "assistant:clarification") {
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
|
|
@ -172,9 +172,9 @@ export function MessageList({
|
||||||
{t.subtasks.executing(tasks.size)}
|
{t.subtasks.executing(tasks.size)}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
const taskIds = message.tool_calls?.map(
|
const taskIds = message.tool_calls
|
||||||
(toolCall) => toolCall.id,
|
?.filter((toolCall) => toolCall.name === "task")
|
||||||
);
|
.map((toolCall) => toolCall.id);
|
||||||
for (const taskId of taskIds ?? []) {
|
for (const taskId of taskIds ?? []) {
|
||||||
results.push(
|
results.push(
|
||||||
<SubtaskCard
|
<SubtaskCard
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue