Compare commits
4 Commits
37f309931c
...
917f0ef591
| Author | SHA1 | Date |
|---|---|---|
|
|
917f0ef591 | |
|
|
b5b1cd6ad8 | |
|
|
83511dee5f | |
|
|
7342cc08d3 |
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ListTodoIcon } from "lucide-react";
|
import { ListTodoIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -28,26 +30,161 @@ import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
|
// [移植自 main 分支 ef9a071] 导入 skill 初始化 API
|
||||||
|
import { bootstrapRemoteSkill } from "@/core/skills";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
|
console.log("[ChatPage] ========== COMPONENT RENDER ==========");
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [settings, setSettings] = useLocalSettings();
|
const [settings, setSettings] = useLocalSettings();
|
||||||
const [showExitDialog, setShowExitDialog] = useState(false);
|
const [showExitDialog, setShowExitDialog] = useState(false);
|
||||||
|
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const { fullscreen } = useArtifacts();
|
const { fullscreen } = useArtifacts();
|
||||||
|
|
||||||
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
|
// [移植自 main 分支 4119fdc, e2fdfa7, ef9a071] 解构扩展的返回值
|
||||||
|
const {
|
||||||
|
threadId,
|
||||||
|
isNewThread,
|
||||||
|
setIsNewThread,
|
||||||
|
isMock,
|
||||||
|
uploadTarget,
|
||||||
|
createNewSession,
|
||||||
|
skillBootstrap,
|
||||||
|
} = useThreadChat();
|
||||||
|
|
||||||
|
console.log("[ChatPage] useThreadChat result:");
|
||||||
|
console.log("[ChatPage] threadId:", threadId);
|
||||||
|
console.log("[ChatPage] isNewThread:", isNewThread);
|
||||||
|
console.log("[ChatPage] isMock:", isMock);
|
||||||
|
console.log("[ChatPage] uploadTarget:", uploadTarget);
|
||||||
|
console.log("[ChatPage] createNewSession:", createNewSession);
|
||||||
|
console.log("[ChatPage] skillBootstrap:", skillBootstrap);
|
||||||
|
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] skill 初始化状态
|
||||||
|
const [isSkillBootstrapping, setIsSkillBootstrapping] = useState(false);
|
||||||
|
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] skill 初始化 effect
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[ChatPage] skillBootstrap effect triggered");
|
||||||
|
console.log("[ChatPage] threadId:", threadId);
|
||||||
|
console.log("[ChatPage] skillBootstrap:", skillBootstrap);
|
||||||
|
|
||||||
|
if (!threadId || !skillBootstrap?.contentId) {
|
||||||
|
console.log(
|
||||||
|
"[ChatPage] skillBootstrap: skipping (no threadId or no contentId)",
|
||||||
|
);
|
||||||
|
setIsSkillBootstrapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageType = skillBootstrap.languageType ?? 0;
|
||||||
|
const initKey = `${threadId}:${skillBootstrap.contentId}:${languageType}`;
|
||||||
|
|
||||||
|
console.log("[ChatPage] initKey:", initKey);
|
||||||
|
console.log(
|
||||||
|
"[ChatPage] alreadyBootstrapped:",
|
||||||
|
skillBootstrappedKeyRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (skillBootstrappedKeyRef.current === initKey) {
|
||||||
|
console.log("[ChatPage] skillBootstrap already done for key:", initKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const runBootstrap = async () => {
|
||||||
|
console.log("[ChatPage] ========== SKILL BOOTSTRAP START ==========");
|
||||||
|
console.log("[ChatPage] threadId:", threadId);
|
||||||
|
console.log("[ChatPage] contentId:", skillBootstrap.contentId);
|
||||||
|
console.log("[ChatPage] languageType:", languageType);
|
||||||
|
console.log("[ChatPage] target_dir: /mnt/user-data/uploads/skill");
|
||||||
|
|
||||||
|
setIsSkillBootstrapping(true);
|
||||||
|
|
||||||
|
// 使用 toast 显示加载状态
|
||||||
|
const toastId = toast.loading("正在初始化 Skill 文件...", {
|
||||||
|
// description: "请稍候,正在从远程服务器获取 Skill 配置",
|
||||||
|
duration: 20000,
|
||||||
|
icon: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await bootstrapRemoteSkill({
|
||||||
|
thread_id: threadId,
|
||||||
|
content_id: skillBootstrap.contentId,
|
||||||
|
language_type: languageType,
|
||||||
|
target_dir: "/mnt/user-data/uploads/skill",
|
||||||
|
clear_target: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[ChatPage] bootstrapRemoteSkill result:", result);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
skillBootstrappedKeyRef.current = initKey;
|
||||||
|
setIsSkillBootstrapping(false);
|
||||||
|
console.log(
|
||||||
|
"[ChatPage] ========== SKILL BOOTSTRAP SUCCESS ==========",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 toast 显示成功状态
|
||||||
|
toast.success(
|
||||||
|
`已加载 Skill #${skillBootstrap.contentId}大模型将根据情况触发此 Skill`,
|
||||||
|
{
|
||||||
|
id: toastId,
|
||||||
|
icon: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("[ChatPage] bootstrap cancelled, not updating state");
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[ChatPage] ========== SKILL BOOTSTRAP FAILED ==========",
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Skill 初始化失败";
|
||||||
|
console.error("[ChatPage] error message:", message);
|
||||||
|
setIsSkillBootstrapping(false);
|
||||||
|
|
||||||
|
// 使用 DevDialog 显示错误状态
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
setErrorMessage(message);
|
||||||
|
setShowErrorDialog(true);
|
||||||
|
|
||||||
|
showNotification("Skill 初始化失败", { body: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void runBootstrap();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("[ChatPage] skillBootstrap effect cleanup");
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [threadId, skillBootstrap, showNotification]);
|
||||||
|
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
context: settings.context,
|
context: settings.context,
|
||||||
isMock,
|
isMock,
|
||||||
|
// [移植自 main 分支 4119fdc] 传递 uploadTarget
|
||||||
|
uploadTarget,
|
||||||
onStart: () => {
|
onStart: () => {
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||||
|
|
@ -73,27 +210,49 @@ export default function ChatPage() {
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
|
console.log("[ChatPage] ========== handleSubmit ==========");
|
||||||
|
console.log("[ChatPage] message.text:", message.text?.substring(0, 100));
|
||||||
|
console.log("[ChatPage] message.files:", message.files?.length || 0);
|
||||||
|
console.log("[ChatPage] isSkillBootstrapping:", isSkillBootstrapping);
|
||||||
|
console.log("[ChatPage] threadId:", threadId);
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] skill 初始化中禁止提交
|
||||||
|
if (isSkillBootstrapping) {
|
||||||
|
console.log(
|
||||||
|
"[ChatPage] handleSubmit BLOCKED: skill bootstrapping in progress",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[ChatPage] handleSubmit: calling sendMessage");
|
||||||
void sendMessage(threadId, message);
|
void sendMessage(threadId, message);
|
||||||
},
|
},
|
||||||
[sendMessage, threadId],
|
[sendMessage, threadId, isSkillBootstrapping],
|
||||||
);
|
);
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
console.log("[ChatPage] handleStop called");
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [thread]);
|
}, [thread]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||||
<ChatBox threadId={threadId}>
|
<ChatBox threadId={threadId}>
|
||||||
<div className="bg-background relative flex size-full min-h-0 justify-between">
|
<div
|
||||||
<header
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-0 right-0 left-0 z-30 mx-[20px] grid h-[58px] shrink-0 grid-cols-3 items-center rounded-t-[20px] border-b py-[15px]",
|
"relative flex size-full min-h-0 justify-between",
|
||||||
isNewThread
|
!isNewThread && "bg-background",
|
||||||
? "bg-background/0 backdrop-blur-none"
|
|
||||||
: "bg-background/80 backdrop-blur",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 返回查看结果左箭头 */}
|
<header
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 right-0 left-0 z-30 mx-[20px] grid h-[58px] shrink-0 grid-cols-3 items-center rounded-t-[20px] py-[15px]",
|
||||||
|
isNewThread
|
||||||
|
? "bg-background/0 backdrop-blur-none"
|
||||||
|
: "bg-background/80 border-b backdrop-blur",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 返回查看结果左箭头 - 新会话时隐藏 */}
|
||||||
|
{!isNewThread && (
|
||||||
<div className="flex h-full w-full items-center text-sm font-medium">
|
<div className="flex h-full w-full items-center text-sm font-medium">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -118,6 +277,9 @@ export default function ChatPage() {
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 新会话时用空 div 占位保持布局 */}
|
||||||
|
{isNewThread && <div />}
|
||||||
<div className="flex h-full w-full items-center justify-center overflow-hidden text-sm font-medium">
|
<div className="flex h-full w-full items-center justify-center overflow-hidden text-sm font-medium">
|
||||||
<ThreadTitle threadId={threadId} thread={thread} />
|
<ThreadTitle threadId={threadId} thread={thread} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,13 +324,13 @@ export default function ChatPage() {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto relative w-full max-w-[720px]",
|
"pointer-events-auto relative w-[720px]",
|
||||||
isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
|
isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<InputBox
|
<InputBox
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full -translate-y-4 rounded-[20px] bg-[#FBFAFC]",
|
"w-[720px] -translate-y-4 rounded-[20px] bg-[#FBFAFC]",
|
||||||
)}
|
)}
|
||||||
isNewThread={isNewThread}
|
isNewThread={isNewThread}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
|
@ -178,7 +340,11 @@ export default function ChatPage() {
|
||||||
extraHeader={
|
extraHeader={
|
||||||
isNewThread && <Welcome mode={settings.context.mode} />
|
isNewThread && <Welcome mode={settings.context.mode} />
|
||||||
}
|
}
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
// [移植自 main 分支 ef9a071] skill 初始化中禁用输入
|
||||||
|
disabled={
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
|
isSkillBootstrapping
|
||||||
|
}
|
||||||
onContextChange={(context) => setSettings("context", context)}
|
onContextChange={(context) => setSettings("context", context)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
|
|
@ -212,7 +378,29 @@ export default function ChatPage() {
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setShowExitDialog(false)}
|
onClick={() => {
|
||||||
|
setShowExitDialog(false);
|
||||||
|
router.push("/workspace/chats/new");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</DevDialogFooter>
|
||||||
|
</DevDialogContent>
|
||||||
|
</DevDialog>
|
||||||
|
|
||||||
|
{/* Skill 初始化错误对话框 */}
|
||||||
|
<DevDialog open={showErrorDialog} onOpenChange={setShowErrorDialog}>
|
||||||
|
<DevDialogContent>
|
||||||
|
<DevDialogHeader>
|
||||||
|
<DevDialogTitle>Skill 初始化失败</DevDialogTitle>
|
||||||
|
</DevDialogHeader>
|
||||||
|
<p className="text-muted-foreground text-sm">{errorMessage}</p>
|
||||||
|
<DevDialogFooter singleColumn>
|
||||||
|
<Button
|
||||||
|
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowErrorDialog(false)}
|
||||||
>
|
>
|
||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default function WorkspaceLayout({
|
||||||
{/* <WorkspaceSidebar className="" /> */}
|
{/* <WorkspaceSidebar className="" /> */}
|
||||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<Toaster position="top-center" />
|
<Toaster />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
|
|
@ -1097,6 +1098,7 @@ export const PromptInputSubmit = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Tooltip content="请注意,此功能将消耗token,请保证账户余额大于200可学豆。">
|
||||||
<InputGroupButton
|
<InputGroupButton
|
||||||
aria-label="Submit"
|
aria-label="Submit"
|
||||||
// 被button{bgc:#fff}覆盖了,只能加"!"
|
// 被button{bgc:#fff}覆盖了,只能加"!"
|
||||||
|
|
@ -1116,6 +1118,7 @@ export const PromptInputSubmit = ({
|
||||||
{/* {children ?? Icon} */}
|
{/* {children ?? Icon} */}
|
||||||
{text}
|
{text}
|
||||||
</InputGroupButton>
|
</InputGroupButton>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export const Suggestion = ({
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
variant = "outline",
|
variant = "ghost",
|
||||||
size = "sm",
|
size = "sm",
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
|
|
@ -61,7 +61,7 @@ export const Suggestion = ({
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground cursor-pointer rounded-full px-[20px] py-[15px] text-xs font-normal",
|
"text-muted-foreground cursor-pointer rounded-full bg-[#F9F8FA] px-[20px] py-[15px] text-xs font-normal",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,17 @@ function DevDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DevDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DevDialogFooter({
|
||||||
|
className,
|
||||||
|
singleColumn = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { singleColumn?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dev-dialog-footer"
|
data-slot="dev-dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
// sm:justify-end
|
"grid w-full justify-between gap-[30px] sm:flex-row",
|
||||||
"grid w-full grid-cols-2 justify-between gap-[30px] sm:flex-row",
|
singleColumn ? "grid-cols-1" : "grid-cols-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,43 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
CircleCheckIcon,
|
|
||||||
InfoIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
OctagonXIcon,
|
|
||||||
TriangleAlertIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme="dark"
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
|
position="top-center"
|
||||||
icons={{
|
icons={{
|
||||||
success: <CircleCheckIcon className="size-4" />,
|
success: null,
|
||||||
info: <InfoIcon className="size-4" />,
|
info: null,
|
||||||
warning: <TriangleAlertIcon className="size-4" />,
|
warning: null,
|
||||||
error: <OctagonXIcon className="size-4" />,
|
error: null,
|
||||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
loading: null,
|
||||||
|
}}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: "#4a4a4a",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 400,
|
||||||
|
padding: "12px 20px",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "320px",
|
||||||
|
minWidth: "300px",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--normal-bg": "var(--popover)",
|
"--normal-bg": "#4a4a4a",
|
||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "#ffffff",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "transparent",
|
||||||
"--border-radius": "var(--radius)",
|
"--border-radius": "12px",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,274 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
export function useThreadChat() {
|
// [移植自 main 分支 4119fdc, e2fdfa7] 扩展返回类型
|
||||||
|
export interface ThreadChatResult {
|
||||||
|
threadId: string;
|
||||||
|
isNewThread: boolean;
|
||||||
|
setIsNewThread: (value: boolean) => void;
|
||||||
|
isMock: boolean;
|
||||||
|
// [移植自 main 分支 4119fdc] 上传目标
|
||||||
|
uploadTarget?: "skill";
|
||||||
|
// [移植自 main 分支 e2fdfa7] 是否创建新会话
|
||||||
|
createNewSession: boolean;
|
||||||
|
// [移植自 main 分支 ef9a071] skill 初始化参数
|
||||||
|
skillBootstrap?: {
|
||||||
|
contentId: number;
|
||||||
|
languageType: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// postMessage 消息类型定义
|
||||||
|
interface SelectedSkillMessage {
|
||||||
|
type: "selectedSkill";
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectedSkillMessage(data: unknown): data is SelectedSkillMessage {
|
||||||
|
return (
|
||||||
|
typeof data === "object" &&
|
||||||
|
data !== null &&
|
||||||
|
(data as SelectedSkillMessage).type === "selectedSkill" &&
|
||||||
|
typeof (data as SelectedSkillMessage).id === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThreadChat(): ThreadChatResult {
|
||||||
|
console.log("[useThreadChat] ========== HOOK CALLED ==========");
|
||||||
|
|
||||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [threadId, setThreadId] = useState(() => {
|
|
||||||
return threadIdFromPath === "new" ? uuid() : threadIdFromPath;
|
console.log("[useThreadChat] threadIdFromPath:", threadIdFromPath);
|
||||||
|
console.log("[useThreadChat] pathname:", pathname);
|
||||||
|
|
||||||
|
// [移植自 main 分支 4119fdc] 从 URL 参数获取 thread_id
|
||||||
|
const queryThreadId = searchParams.get("thread_id")?.trim();
|
||||||
|
console.log("[useThreadChat] queryThreadId from URL:", queryThreadId);
|
||||||
|
|
||||||
|
// 打印所有 URL 参数
|
||||||
|
console.log("[useThreadChat] All URL params:");
|
||||||
|
searchParams.forEach((value, key) => {
|
||||||
|
console.log(` ${key}: ${value}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isNewThread, setIsNewThread] = useState(
|
// [移植自 main 分支 e2fdfa7] 判断是否创建新会话
|
||||||
() => threadIdFromPath === "new",
|
// isnew=false 表示复用现有会话,其他情况创建新会话
|
||||||
|
const createNewSession = useMemo(() => {
|
||||||
|
if (threadIdFromPath !== "new") {
|
||||||
|
console.log("[useThreadChat] createNewSession: false (not new route)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isnewParam = searchParams.get("isnew")?.trim().toLowerCase();
|
||||||
|
const result = isnewParam !== "false";
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] isnew param:",
|
||||||
|
isnewParam,
|
||||||
|
"-> createNewSession:",
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}, [threadIdFromPath, searchParams]);
|
||||||
|
|
||||||
|
// [移植自 main 分支 e2fdfa7] UI模式仅依赖路由:/workspace/chats/new 总是"新页面"模式
|
||||||
|
const isNewThread = useMemo(() => {
|
||||||
|
const result = threadIdFromPath === "new";
|
||||||
|
console.log("[useThreadChat] isNewThread:", result);
|
||||||
|
return result;
|
||||||
|
}, [threadIdFromPath]);
|
||||||
|
|
||||||
|
// [移植自 main 分支 4119fdc] 获取上传目标
|
||||||
|
const uploadTarget = useMemo(() => {
|
||||||
|
const target = searchParams.get("upload_target")?.trim().toLowerCase();
|
||||||
|
console.log("[useThreadChat] upload_target from URL:", target);
|
||||||
|
const result = target === "skill" ? ("skill" as const) : undefined;
|
||||||
|
console.log("[useThreadChat] uploadTarget result:", result);
|
||||||
|
return result;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] 获取 skill 初始化参数 (从 URL)
|
||||||
|
const skillBootstrapFromUrl = useMemo(() => {
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] --- Parsing skillBootstrap params from URL ---",
|
||||||
|
);
|
||||||
|
const skillIdRaw = searchParams.get("skill_id")?.trim();
|
||||||
|
console.log("[useThreadChat] skill_id raw:", skillIdRaw);
|
||||||
|
|
||||||
|
if (!skillIdRaw) {
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] skillBootstrapFromUrl: undefined (no skill_id)",
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentId = Number(skillIdRaw);
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] contentId parsed:",
|
||||||
|
contentId,
|
||||||
|
"isFinite:",
|
||||||
|
Number.isFinite(contentId),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!Number.isFinite(contentId)) {
|
||||||
if (pathname.endsWith("/new")) {
|
console.log(
|
||||||
setIsNewThread(true);
|
"[useThreadChat] skillBootstrapFromUrl: undefined (invalid contentId)",
|
||||||
setThreadId(uuid());
|
);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}, [pathname]);
|
|
||||||
|
const languageTypeRaw =
|
||||||
|
searchParams.get("languageType")?.trim() ??
|
||||||
|
searchParams.get("language_type")?.trim();
|
||||||
|
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] languageType raw:",
|
||||||
|
languageTypeRaw,
|
||||||
|
"parsed:",
|
||||||
|
languageType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
contentId,
|
||||||
|
languageType: Number.isFinite(languageType) ? languageType : 0,
|
||||||
|
};
|
||||||
|
console.log("[useThreadChat] skillBootstrapFromUrl result:", result);
|
||||||
|
return result;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// [新增] postMessage 方式获取 skill 初始化参数
|
||||||
|
const [skillBootstrapFromPostMessage, setSkillBootstrapFromPostMessage] =
|
||||||
|
useState<
|
||||||
|
| {
|
||||||
|
contentId: number;
|
||||||
|
languageType: number;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// [新增] 监听 postMessage 消息
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[useThreadChat] Setting up postMessage listener");
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
console.log("[useThreadChat] postMessage received:", event.data);
|
||||||
|
|
||||||
|
// 检查消息类型
|
||||||
|
if (!isSelectedSkillMessage(event.data)) {
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] postMessage ignored: not a selectedSkill message",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, title } = event.data;
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] selectedSkill message - id:",
|
||||||
|
id,
|
||||||
|
"title:",
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置 skillBootstrap
|
||||||
|
const newSkillBootstrap = {
|
||||||
|
contentId: id,
|
||||||
|
languageType: 0, // 默认语言类型
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] Setting skillBootstrap from postMessage:",
|
||||||
|
newSkillBootstrap,
|
||||||
|
);
|
||||||
|
setSkillBootstrapFromPostMessage(newSkillBootstrap);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("[useThreadChat] Cleaning up postMessage listener");
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// [新增] 合并 URL 和 postMessage 的 skillBootstrap (postMessage 优先)
|
||||||
|
const skillBootstrap = skillBootstrapFromPostMessage ?? skillBootstrapFromUrl;
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] skillBootstrap final:",
|
||||||
|
skillBootstrap ? JSON.stringify(skillBootstrap) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// [修复] 使用 useRef 缓存生成的 threadId,避免 React StrictMode 下重复生成
|
||||||
|
const threadIdRef = useRef<string | null>(null);
|
||||||
|
const isNewThreadRef = useRef<boolean | null>(null);
|
||||||
|
|
||||||
|
// 仅在首次渲染时生成 threadId
|
||||||
|
if (threadIdRef.current === null) {
|
||||||
|
if (threadIdFromPath === "new") {
|
||||||
|
// [移植自 main 分支 4119fdc] 优先使用 URL 中的 thread_id
|
||||||
|
threadIdRef.current = queryThreadId || uuid();
|
||||||
|
isNewThreadRef.current = true;
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] initial threadId (new route):",
|
||||||
|
threadIdRef.current,
|
||||||
|
"queryThreadId:",
|
||||||
|
queryThreadId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
threadIdRef.current = threadIdFromPath;
|
||||||
|
isNewThreadRef.current = false;
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] initial threadId (existing):",
|
||||||
|
threadIdRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [threadId, setThreadId] = useState(threadIdRef.current);
|
||||||
|
const [isNewThreadState, setIsNewThread] = useState(isNewThreadRef.current);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[useThreadChat] useEffect: pathname changed to:", pathname);
|
||||||
|
if (pathname.endsWith("/new")) {
|
||||||
|
console.log("[useThreadChat] setting isNewThread=true");
|
||||||
|
setIsNewThread(true);
|
||||||
|
// [移植自 main 分支 4119fdc] 优先使用 URL 中的 thread_id
|
||||||
|
// 只有当 ref 中的值不是当前 queryThreadId 时才更新
|
||||||
|
const newThreadId = queryThreadId || threadIdRef.current || uuid();
|
||||||
|
if (newThreadId !== threadId) {
|
||||||
|
console.log("[useThreadChat] updating threadId:", newThreadId);
|
||||||
|
threadIdRef.current = newThreadId;
|
||||||
|
setThreadId(newThreadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pathname, queryThreadId, threadId]);
|
||||||
|
|
||||||
const isMock = searchParams.get("mock") === "true";
|
const isMock = searchParams.get("mock") === "true";
|
||||||
return { threadId, isNewThread, setIsNewThread, isMock };
|
console.log("[useThreadChat] isMock:", isMock);
|
||||||
|
|
||||||
|
console.log("[useThreadChat] ========== FINAL RESULT ==========");
|
||||||
|
console.log("[useThreadChat] threadId:", threadId);
|
||||||
|
console.log("[useThreadChat] isNewThread:", isNewThread);
|
||||||
|
console.log("[useThreadChat] createNewSession:", createNewSession);
|
||||||
|
console.log("[useThreadChat] uploadTarget:", uploadTarget);
|
||||||
|
console.log(
|
||||||
|
"[useThreadChat] skillBootstrap:",
|
||||||
|
skillBootstrap ? JSON.stringify(skillBootstrap) : undefined,
|
||||||
|
);
|
||||||
|
console.log("[useThreadChat] ======================================");
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
isNewThread,
|
||||||
|
setIsNewThread,
|
||||||
|
isMock,
|
||||||
|
uploadTarget,
|
||||||
|
createNewSession,
|
||||||
|
skillBootstrap,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@ export function InputBox({
|
||||||
"border-0 backdrop-blur-sm w-[720px] rounded-[20px]",
|
"border-0 backdrop-blur-sm w-[720px] rounded-[20px]",
|
||||||
"transition-[height] duration-300 ease-out",
|
"transition-[height] duration-300 ease-out",
|
||||||
!isNewThread && "shadow-[0_0_20px_2px_rgba(0,0,0,0.10)]",
|
!isNewThread && "shadow-[0_0_20px_2px_rgba(0,0,0,0.10)]",
|
||||||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
effectiveIsFocused ? "!h-[200px]" : "h-[80px]",
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
globalDrop
|
globalDrop
|
||||||
|
|
@ -963,7 +963,7 @@ function SuggestionList({
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DropdownMenu>
|
{/* <DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Suggestion icon={PlusIcon} suggestion={t.common.create} />
|
<Suggestion icon={PlusIcon} suggestion={t.common.create} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
@ -986,7 +986,7 @@ function SuggestionList({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu> */}
|
||||||
</Suggestions>
|
</Suggestions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
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";
|
// [移植自 main 分支 ef9a071] 添加 useState
|
||||||
|
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 +19,8 @@ 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";
|
||||||
|
// [移植自 main 分支 ef9a071] 添加 Button
|
||||||
|
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 {
|
||||||
|
|
@ -27,6 +30,8 @@ import {
|
||||||
stripUploadedFilesTag,
|
stripUploadedFilesTag,
|
||||||
type FileInMessage,
|
type FileInMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
|
// [移植自 main 分支 ef9a071] 添加 materializeSkillYaml
|
||||||
|
import { materializeSkillYaml } from "@/core/skills";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { humanMessagePlugins } from "@/core/streamdown";
|
import { humanMessagePlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -262,6 +267,12 @@ function isImageFile(filename: string): boolean {
|
||||||
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
|
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] 检测 YAML 文件
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
|
|
@ -309,6 +320,52 @@ 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);
|
||||||
|
// [移植自 main 分支 ef9a071] YAML 文件解析状态
|
||||||
|
const isYaml = isYamlFile(file.filename);
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] YAML 文件解析状态
|
||||||
|
const [isMaterializing, setIsMaterializing] = useState(false);
|
||||||
|
const [materializeMessage, setMaterializeMessage] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] YAML 文件解析处理函数
|
||||||
|
const handleMaterializeYaml = async () => {
|
||||||
|
if (isMaterializing || !file.path) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[RichFileCard] ========== handleMaterializeYaml START ==========",
|
||||||
|
);
|
||||||
|
console.log("[RichFileCard] threadId:", threadId);
|
||||||
|
console.log("[RichFileCard] file.path:", file.path);
|
||||||
|
console.log("[RichFileCard] file.filename:", file.filename);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[RichFileCard] materializeSkillYaml result:", result);
|
||||||
|
setMaterializeMessage(
|
||||||
|
`已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "解析失败";
|
||||||
|
console.error("[RichFileCard] materializeSkillYaml failed:", message);
|
||||||
|
setMaterializeMessage(`失败: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setIsMaterializing(false);
|
||||||
|
console.log(
|
||||||
|
"[RichFileCard] ========== handleMaterializeYaml END ==========",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -380,6 +437,27 @@ function RichFileCard({
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* [移植自 main 分支 ef9a071] 注释掉测试按钮,后续根据需求再决定是否保留 */}
|
||||||
|
{/* {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,36 +102,42 @@ export const zhCN: Translations = {
|
||||||
followupConfirmReplace: "替换并发送",
|
followupConfirmReplace: "替换并发送",
|
||||||
suggestions: [
|
suggestions: [
|
||||||
{
|
{
|
||||||
suggestion: "论文写作",
|
suggestion: "自媒体文案",
|
||||||
prompt:
|
prompt:
|
||||||
"撰写一篇关于[主题]的学术论文,包含摘要、引言、正文和参考文献。",
|
"撰写一篇关于[主题]的学术论文,包含摘要、引言、正文和参考文献。",
|
||||||
icon: PenLineIcon,
|
icon: PenLineIcon,
|
||||||
skill_id: "1",
|
skill_id: "432",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
suggestion: "报告生成",
|
suggestion: "需求文档",
|
||||||
prompt: "深入分析[主题],生成一份结构清晰的调研报告。",
|
prompt: "深入分析[主题],生成一份结构清晰的调研报告。",
|
||||||
icon: MicroscopeIcon,
|
icon: MicroscopeIcon,
|
||||||
skill_id: "2",
|
skill_id: "521",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
suggestion: "策划文案",
|
suggestion: "使用指南",
|
||||||
prompt: "为[项目/活动]撰写一份完整的策划方案和宣传文案。",
|
prompt: "为[项目/活动]撰写一份完整的策划方案和宣传文案。",
|
||||||
icon: ShapesIcon,
|
icon: ShapesIcon,
|
||||||
skill_id: "3",
|
skill_id: "410",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
suggestion: "PPT生成",
|
suggestion: "PPT生成",
|
||||||
prompt: "生成一个关于[主题]的PPT演示文稿大纲和内容。",
|
prompt: "生成一个关于[主题]的PPT演示文稿大纲和内容。",
|
||||||
icon: GraduationCapIcon,
|
icon: GraduationCapIcon,
|
||||||
skill_id: "4",
|
skill_id: "180",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
suggestion: "文档处理",
|
suggestion: "Excel数据分析",
|
||||||
prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。",
|
prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。",
|
||||||
icon: CompassIcon,
|
icon: CompassIcon,
|
||||||
skill_id: "5",
|
skill_id: "5",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
suggestion: "市场调研",
|
||||||
|
prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。",
|
||||||
|
icon: CompassIcon,
|
||||||
|
skill_id: "31",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
suggestionsCreate: [
|
suggestionsCreate: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,39 @@ export interface InstallSkillResponse {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] 添加 skill yaml 解析和远程 skill 初始化 API
|
||||||
|
export interface MaterializeSkillYamlRequest {
|
||||||
|
thread_id: string;
|
||||||
|
path: string;
|
||||||
|
target_dir?: string;
|
||||||
|
clear_target?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterializeSkillYamlResponse {
|
||||||
|
success: boolean;
|
||||||
|
target_dir: string;
|
||||||
|
created_directories: number;
|
||||||
|
created_files: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapRemoteSkillRequest {
|
||||||
|
thread_id: string;
|
||||||
|
content_id: number;
|
||||||
|
language_type?: number;
|
||||||
|
target_dir?: string;
|
||||||
|
clear_target?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapRemoteSkillResponse {
|
||||||
|
success: boolean;
|
||||||
|
target_dir: string;
|
||||||
|
created_directories: number;
|
||||||
|
created_files: number;
|
||||||
|
sandbox_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function installSkill(
|
export async function installSkill(
|
||||||
request: InstallSkillRequest,
|
request: InstallSkillRequest,
|
||||||
): Promise<InstallSkillResponse> {
|
): Promise<InstallSkillResponse> {
|
||||||
|
|
@ -60,3 +93,96 @@ export async function installSkill(
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] 解析 skill.yaml 文件并创建目录结构
|
||||||
|
export async function materializeSkillYaml(
|
||||||
|
request: MaterializeSkillYamlRequest,
|
||||||
|
): Promise<MaterializeSkillYamlResponse> {
|
||||||
|
console.log("[skills/api] ========== materializeSkillYaml START ==========");
|
||||||
|
console.log("[skills/api] request:", JSON.stringify(request, null, 2));
|
||||||
|
console.log(
|
||||||
|
"[skills/api] API URL:",
|
||||||
|
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[skills/api] response status:",
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage =
|
||||||
|
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
console.error("[skills/api] materializeSkillYaml FAILED:", errorMessage);
|
||||||
|
console.error("[skills/api] error data:", errorData);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("[skills/api] materializeSkillYaml SUCCESS:", result);
|
||||||
|
console.log("[skills/api] ========== materializeSkillYaml END ==========");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] 从远程平台获取 skill 并初始化
|
||||||
|
export async function bootstrapRemoteSkill(
|
||||||
|
request: BootstrapRemoteSkillRequest,
|
||||||
|
): Promise<BootstrapRemoteSkillResponse> {
|
||||||
|
console.log("[skills/api] ========== bootstrapRemoteSkill START ==========");
|
||||||
|
console.log("[skills/api] request:", JSON.stringify(request, null, 2));
|
||||||
|
console.log("[skills/api] thread_id:", request.thread_id);
|
||||||
|
console.log("[skills/api] content_id:", request.content_id);
|
||||||
|
console.log("[skills/api] language_type:", request.language_type);
|
||||||
|
console.log("[skills/api] target_dir:", request.target_dir);
|
||||||
|
console.log(
|
||||||
|
"[skills/api] API URL:",
|
||||||
|
`${getBackendBaseURL()}/api/skills/bootstrap-remote`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/bootstrap-remote`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[skills/api] response status:",
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage =
|
||||||
|
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
console.error("[skills/api] bootstrapRemoteSkill FAILED:", errorMessage);
|
||||||
|
console.error("[skills/api] error data:", errorData);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("[skills/api] bootstrapRemoteSkill SUCCESS:", result);
|
||||||
|
console.log("[skills/api] created_directories:", result.created_directories);
|
||||||
|
console.log("[skills/api] created_files:", result.created_files);
|
||||||
|
console.log("[skills/api] sandbox_id:", result.sandbox_id);
|
||||||
|
console.log("[skills/api] ========== bootstrapRemoteSkill END ==========");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import type { LocalSettings } from "../settings";
|
||||||
import { useUpdateSubtask } from "../tasks/context";
|
import { useUpdateSubtask } from "../tasks/context";
|
||||||
import type { UploadedFileInfo } from "../uploads";
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
import { uploadFiles } from "../uploads";
|
import { uploadFiles } from "../uploads";
|
||||||
|
// [移植自 main 分支 4119fdc] 导入 UploadTarget 类型
|
||||||
|
import type { UploadTarget } from "../uploads/api";
|
||||||
|
|
||||||
import type { AgentThread, AgentThreadState } from "./types";
|
import type { AgentThread, AgentThreadState } from "./types";
|
||||||
|
|
||||||
|
|
@ -26,6 +28,8 @@ export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
|
// [移植自 main 分支 4119fdc] 上传目标
|
||||||
|
uploadTarget?: UploadTarget;
|
||||||
onStart?: (threadId: string) => void;
|
onStart?: (threadId: string) => void;
|
||||||
onFinish?: (state: AgentThreadState) => void;
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
onToolEnd?: (event: ToolEndEvent) => void;
|
onToolEnd?: (event: ToolEndEvent) => void;
|
||||||
|
|
@ -35,10 +39,17 @@ export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
|
uploadTarget,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
|
console.log("[threads/hooks] ========== useThreadStream INIT ==========");
|
||||||
|
console.log("[threads/hooks] threadId:", threadId);
|
||||||
|
console.log("[threads/hooks] context mode:", context?.mode);
|
||||||
|
console.log("[threads/hooks] isMock:", isMock);
|
||||||
|
console.log("[threads/hooks] uploadTarget:", uploadTarget);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||||
|
|
@ -175,8 +186,33 @@ export function useThreadStream({
|
||||||
message: PromptInputMessage,
|
message: PromptInputMessage,
|
||||||
extraContext?: Record<string, unknown>,
|
extraContext?: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
|
console.log("[threads/hooks] ========== sendMessage START ==========");
|
||||||
|
console.log("[threads/hooks] threadId:", threadId);
|
||||||
|
console.log(
|
||||||
|
"[threads/hooks] message.text:",
|
||||||
|
message.text?.substring(0, 100),
|
||||||
|
);
|
||||||
|
console.log("[threads/hooks] message.files:", message.files?.length || 0);
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
|
|
||||||
|
// [移植自 main 分支 ef9a071] 空提交保护:忽略空消息提交(避免页面初始化时的意外副作用)
|
||||||
|
const hasFiles = !!(message.files && message.files.length > 0);
|
||||||
|
if (!text && !hasFiles) {
|
||||||
|
console.log(
|
||||||
|
"[threads/hooks] sendMessage: IGNORING empty submit (no text, no files)",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"[threads/hooks] ========== sendMessage END (ignored) ==========",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[threads/hooks] sendMessage proceeding:");
|
||||||
|
console.log("[threads/hooks] text length:", text.length);
|
||||||
|
console.log("[threads/hooks] hasFiles:", hasFiles);
|
||||||
|
console.log("[threads/hooks] uploadTarget:", uploadTarget);
|
||||||
|
|
||||||
// Capture current count before showing optimistic messages
|
// Capture current count before showing optimistic messages
|
||||||
prevMsgCountRef.current = thread.messages.length;
|
prevMsgCountRef.current = thread.messages.length;
|
||||||
|
|
||||||
|
|
@ -258,7 +294,10 @@ export function useThreadStream({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const uploadResponse = await uploadFiles(threadId, files);
|
// [移植自 main 分支 4119fdc] 传递 uploadTarget 参数
|
||||||
|
const uploadResponse = await uploadFiles(threadId, files, {
|
||||||
|
target: uploadTarget,
|
||||||
|
});
|
||||||
uploadedFileInfo = uploadResponse.files;
|
uploadedFileInfo = uploadResponse.files;
|
||||||
|
|
||||||
// Update optimistic human message with uploaded status + paths
|
// Update optimistic human message with uploaded status + paths
|
||||||
|
|
@ -345,7 +384,14 @@ export function useThreadStream({
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
|
[
|
||||||
|
thread,
|
||||||
|
_handleOnStart,
|
||||||
|
t.uploads.uploadingFiles,
|
||||||
|
context,
|
||||||
|
queryClient,
|
||||||
|
uploadTarget,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge thread with optimistic messages for display
|
// Merge thread with optimistic messages for display
|
||||||
|
|
|
||||||
|
|
@ -29,35 +29,63 @@ export interface ListFilesResponse {
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [移植自 main 分支 4119fdc] 上传目标类型
|
||||||
|
export type UploadTarget = "skill";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload files to a thread
|
* Upload files to a thread
|
||||||
*/
|
*/
|
||||||
export async function uploadFiles(
|
export async function uploadFiles(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
|
options?: { target?: UploadTarget },
|
||||||
): Promise<UploadResponse> {
|
): Promise<UploadResponse> {
|
||||||
|
console.log("[uploads/api] ========== uploadFiles START ==========");
|
||||||
|
console.log("[uploads/api] threadId:", threadId);
|
||||||
|
console.log("[uploads/api] files count:", files.length);
|
||||||
|
files.forEach((file, i) => {
|
||||||
|
console.log(`[uploads/api] file[${i}]:`, file.name, file.size, file.type);
|
||||||
|
});
|
||||||
|
console.log("[uploads/api] options:", options);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
formData.append("files", file);
|
formData.append("files", file);
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
// [移植自 main 分支 4119fdc] 支持指定上传目标
|
||||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
|
if (options?.target) {
|
||||||
{
|
formData.append("upload_target", options.target);
|
||||||
|
console.log("[uploads/api] upload_target set to:", options.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${getBackendBaseURL()}/api/threads/${threadId}/uploads`;
|
||||||
|
console.log("[uploads/api] POST URL:", url);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
},
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[uploads/api] response status:",
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response
|
const error = await response
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ detail: "Upload failed" }));
|
.catch(() => ({ detail: "Upload failed" }));
|
||||||
|
console.error("[uploads/api] uploadFiles FAILED:", error);
|
||||||
throw new Error(error.detail ?? "Upload failed");
|
throw new Error(error.detail ?? "Upload failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const result = await response.json();
|
||||||
|
console.log("[uploads/api] uploadFiles SUCCESS:", result);
|
||||||
|
console.log("[uploads/api] ========== uploadFiles END ==========");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@
|
||||||
--muted: #1500331a;
|
--muted: #1500331a;
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
/* --accent: oklch(0.94 0.0098 87.47); */
|
/* --accent: oklch(0.94 0.0098 87.47); */
|
||||||
--accent: #1500331a;
|
--accent: #eae9eb;
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0.0098 87.47);
|
--border: oklch(0.922 0.0098 87.47);
|
||||||
|
|
@ -453,3 +453,17 @@ p {
|
||||||
.cm-line {
|
.cm-line {
|
||||||
font-size: calc(14px * var(--zoom-scale));
|
font-size: calc(14px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sonner Toast 居中样式 */
|
||||||
|
[data-sonner-toaster] {
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue