feat: 移植 Titan main 分支 skill 初始化相关功能

This commit is contained in:
肖应宇 2026-03-18 11:28:53 +08:00
parent 37f309931c
commit 7342cc08d3
6 changed files with 609 additions and 55 deletions

View File

@ -1,7 +1,9 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "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 { Button } from "@/components/ui/button";
@ -28,26 +30,148 @@ import { useArtifacts } from "@/components/workspace/artifacts";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
// [移植自 main 分支 ef9a071] 导入 skill 初始化 API
import { bootstrapRemoteSkill } from "@/core/skills";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
console.log("[ChatPage] ========== COMPONENT RENDER ==========");
const router = useRouter();
const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const [showExitDialog, setShowExitDialog] = useState(false);
const [showErrorDialog, setShowErrorDialog] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
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();
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({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
// [移植自 main 分支 4119fdc] 传递 uploadTarget
uploadTarget,
onStart: () => {
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.
@ -73,51 +197,72 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(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);
},
[sendMessage, threadId],
[sendMessage, threadId, isSkillBootstrapping],
);
const handleStop = useCallback(async () => {
console.log("[ChatPage] handleStop called");
await thread.stop();
}, [thread]);
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="bg-background relative flex size-full min-h-0 justify-between">
<div className={cn(
"relative flex size-full min-h-0 justify-between",
!isNewThread && "bg-background"
)}>
<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] border-b py-[15px]",
"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 backdrop-blur",
: "bg-background/80 backdrop-blur border-b",
)}
>
{/* 返回查看结果左箭头 */}
<div className="flex h-full w-full items-center text-sm font-medium">
<Button
size="sm"
variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium"
onClick={() => setShowExitDialog(true)}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{/* 返回查看结果左箭头 - 新会话时隐藏 */}
{!isNewThread && (
<div className="flex h-full w-full items-center text-sm font-medium">
<Button
size="sm"
variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium"
onClick={() => setShowExitDialog(true)}
>
<path
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
</div>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
</div>
)}
{/* 新会话时用空 div 占位保持布局 */}
{isNewThread && <div />}
<div className="flex h-full w-full items-center justify-center overflow-hidden text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
@ -162,13 +307,13 @@ export default function ChatPage() {
>
<div
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)]",
)}
>
<InputBox
className={cn(
"w-full -translate-y-4 rounded-[20px] bg-[#FBFAFC]",
"-translate-y-4 w-[720px] rounded-[20px] bg-[#FBFAFC]",
)}
isNewThread={isNewThread}
threadId={threadId}
@ -178,7 +323,11 @@ export default function ChatPage() {
extraHeader={
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)}
onSubmit={handleSubmit}
onStop={handleStop}
@ -212,7 +361,31 @@ export default function ChatPage() {
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
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>

View File

@ -1,29 +1,168 @@
"use client";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
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;
};
}
export function useThreadChat(): ThreadChatResult {
console.log("[useThreadChat] ========== HOOK CALLED ==========");
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname();
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(
() => threadIdFromPath === "new",
);
// [移植自 main 分支 e2fdfa7] 判断是否创建新会话
// 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 初始化参数
const skillBootstrap = useMemo(() => {
console.log("[useThreadChat] --- Parsing skillBootstrap params ---");
const skillIdRaw = searchParams.get("skill_id")?.trim();
console.log("[useThreadChat] skill_id raw:", skillIdRaw);
if (!skillIdRaw) {
console.log("[useThreadChat] skillBootstrap: undefined (no skill_id)");
return undefined;
}
const contentId = Number(skillIdRaw);
console.log("[useThreadChat] contentId parsed:", contentId, "isFinite:", Number.isFinite(contentId));
if (!Number.isFinite(contentId)) {
console.log("[useThreadChat] skillBootstrap: undefined (invalid contentId)");
return undefined;
}
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] skillBootstrap result:", result);
return result;
}, [searchParams]);
// [修复] 使用 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);
setThreadId(uuid());
// [移植自 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]);
}, [pathname, queryThreadId, threadId]);
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,
};
}

View File

@ -1,7 +1,8 @@
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";
// [移植自 main 分支 ef9a071] 添加 useState
import { memo, useMemo, useState, type ImgHTMLAttributes } from "react";
import rehypeKatex from "rehype-katex";
import { Loader } from "@/components/ai-elements/loader";
@ -18,6 +19,8 @@ import {
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
// [移植自 main 分支 ef9a071] 添加 Button
import { Button } from "@/components/ui/button";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
@ -27,6 +30,8 @@ import {
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
// [移植自 main 分支 ef9a071] 添加 materializeSkillYaml
import { materializeSkillYaml } from "@/core/skills";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
@ -262,6 +267,12 @@ function isImageFile(filename: string): boolean {
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
*/
@ -309,6 +320,48 @@ function RichFileCard({
const { t } = useI18n();
const isUploading = file.status === "uploading";
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) {
return (
@ -380,6 +433,27 @@ function RichFileCard({
{formatBytes(file.size)}
</span>
</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>
);
}

View File

@ -35,6 +35,39 @@ export interface InstallSkillResponse {
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(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
@ -60,3 +93,82 @@ export async function installSkill(
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;
}

View File

@ -14,6 +14,8 @@ import type { LocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { uploadFiles } from "../uploads";
// [移植自 main 分支 4119fdc] 导入 UploadTarget 类型
import type { UploadTarget } from "../uploads/api";
import type { AgentThread, AgentThreadState } from "./types";
@ -26,6 +28,8 @@ export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
// [移植自 main 分支 4119fdc] 上传目标
uploadTarget?: UploadTarget;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void;
@ -35,10 +39,17 @@ export function useThreadStream({
threadId,
context,
isMock,
uploadTarget,
onStart,
onFinish,
onToolEnd,
}: 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();
// Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
@ -175,8 +186,26 @@ export function useThreadStream({
message: PromptInputMessage,
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();
// [移植自 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
prevMsgCountRef.current = thread.messages.length;
@ -258,7 +287,10 @@ export function useThreadStream({
}
if (files.length > 0) {
const uploadResponse = await uploadFiles(threadId, files);
// [移植自 main 分支 4119fdc] 传递 uploadTarget 参数
const uploadResponse = await uploadFiles(threadId, files, {
target: uploadTarget,
});
uploadedFileInfo = uploadResponse.files;
// Update optimistic human message with uploaded status + paths
@ -345,7 +377,7 @@ export function useThreadStream({
throw error;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient, uploadTarget],
);
// Merge thread with optimistic messages for display

View File

@ -29,35 +29,59 @@ export interface ListFilesResponse {
count: number;
}
// [移植自 main 分支 4119fdc] 上传目标类型
export type UploadTarget = "skill";
/**
* Upload files to a thread
*/
export async function uploadFiles(
threadId: string,
files: File[],
options?: { target?: UploadTarget },
): 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();
files.forEach((file) => {
formData.append("files", file);
});
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
{
method: "POST",
body: formData,
},
);
// [移植自 main 分支 4119fdc] 支持指定上传目标
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",
body: formData,
});
console.log("[uploads/api] response status:", response.status, response.statusText);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Upload failed" }));
console.error("[uploads/api] uploadFiles FAILED:", error);
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;
}
/**