diff --git a/frontend/imports/github-main-frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/imports/github-main-frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 12ba855e..f9c31786 100644 --- a/frontend/imports/github-main-frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/imports/github-main-frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -156,7 +156,6 @@ export default function AgentChatPage() { (); + const searchParams = useSearchParams(); + const promptInputController = usePromptInputController(); + + // UI mode depends only on route: /workspace/chats/new is always "new page" mode. + const isNewThread = useMemo( + () => threadIdFromPath === "new", + [threadIdFromPath], + ); + + // Submission strategy is controlled by `isnew` query param only. + // - isnew=false: reuse existing thread + // - otherwise: create/start a new session + const createNewSession = useMemo(() => { + if (threadIdFromPath !== "new") { + return false; + } + + return searchParams.get("isnew")?.trim().toLowerCase() !== "false"; + }, [threadIdFromPath, searchParams]); + + const uploadTarget = useMemo(() => { + const target = searchParams.get("upload_target")?.trim().toLowerCase(); + return target === "skill" ? "skill" : undefined; + }, [searchParams]); + + const [threadId, setThreadId] = useState(null); + useEffect(() => { + if (threadIdFromPath !== "new") { + setThreadId(threadIdFromPath); + } else { + const queryThreadId = searchParams.get("thread_id")?.trim(); + setThreadId(queryThreadId ?? uuid()); + } + }, [threadIdFromPath, searchParams]); + + // Runtime strategy for /new page: + // - UI remains new-page mode + // - if isnew=false, execute against existing thread_id without creating a new one + const reuseExistingThread = useMemo( + () => threadIdFromPath === "new" && !createNewSession && !!threadId, + [threadIdFromPath, createNewSession, threadId], + ); const { showNotification } = useNotification(); - const [thread, sendMessage, isUploading] = useThreadStream({ - threadId: isNewThread ? undefined : threadId, - context: settings.context, - isMock, - 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. - history.replaceState(null, "", `/workspace/chats/${threadId}`); - }, + // 监听宿主页 selectedSkill 消息 + const { + selectedSkill, + skillError: selectedSkillError, + clearSkillError: clearSelectedSkillError, + isBootstrapping: isSelectedSkillBootstrapping, + } = useSelectedSkillListener({ threadId }); + const [finalState, setFinalState] = useState(null); + const thread = useThreadStream({ + // Keep UI in new-page mode, but runtime may reuse existing thread + isNewThread: reuseExistingThread ? false : isNewThread, + threadId, + fetchStateHistory: true, onFinish: (state) => { + setFinalState(state); + // 新对话完成后导航到对话页面 + if (isNewThread && threadId) { + router.push(pathOfThread(threadId)); + } if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; - const lastMessage = state.messages.at(-1); + const lastMessage = state.messages[state.messages.length - 1]; if (lastMessage) { const textContent = textOfMessage(lastMessage); if (textContent) { - body = - textContent.length > 200 - ? textContent.substring(0, 200) + "..." - : textContent; + if (textContent.length > 200) { + body = textContent.substring(0, 200) + "..."; + } else { + body = textContent; + } } } - showNotification(state.title, { body }); + showNotification(state.title, { + body, + }); } }, - }); + }) as unknown as UseStream; + useEffect(() => { + if (thread.isLoading) setFinalState(null); + }, [thread.isLoading]); - const handleSubmit = useCallback( - (message: PromptInputMessage) => { - void sendMessage(threadId, message); + const title = useMemo(() => { + let result = isNewThread + ? "" + : titleOfThread(thread as unknown as AgentThread); + if (result === "Untitled") { + result = ""; + } + return result; + }, [thread, isNewThread]); + + const [hasSubmitted, setHasSubmitted] = useState(false); + const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; + + useEffect(() => { + const pageTitle = isNewThread + ? t.pages.newChat + : thread.values?.title && thread.values.title !== "Untitled" + ? thread.values.title + : t.pages.untitled; + if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { + document.title = `Loading... - ${t.pages.appName}`; + } else { + document.title = `${pageTitle} - ${t.pages.appName}`; + } + }, [ + isNewThread, + t.pages.newChat, + t.pages.untitled, + t.pages.appName, + thread.values.title, + thread.isThreadLoading, + suppressExistingThreadPrefetchUi, + ]); + + const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); + useEffect(() => { + setArtifacts(thread.values.artifacts); + if ( + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && + autoSelectFirstArtifact + ) { + if (thread?.values?.artifacts?.length > 0) { + setAutoSelectFirstArtifact(false); + selectArtifact(thread.values.artifacts[0]!); + } + } + }, [ + autoSelectFirstArtifact, + selectArtifact, + setArtifacts, + thread.values.artifacts, + ]); + + const artifactPanelOpen = useMemo(() => { + if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { + return artifactsOpen && artifacts?.length > 0; + } + return artifactsOpen; + }, [artifactsOpen, artifacts]); + + const [todoListCollapsed, setTodoListCollapsed] = useState(true); + const [showExitDialog, setShowExitDialog] = useState(false); + + const submitThread = useSubmitThread({ + isNewThread, + createNewSession, + threadId, + thread, + uploadTarget, + threadContext: { + ...settings.context, + thinking_enabled: settings.context.mode !== "flash", + is_plan_mode: + settings.context.mode === "pro" || settings.context.mode === "ultra", + subagent_enabled: settings.context.mode === "ultra", }, - [sendMessage, threadId], + afterSubmit() { + // 导航已在 onFinish 中处理,确保 stream 完成后再导航 + }, + }); + const handleSubmit = useCallback( + (message: Parameters[0]) => { + if (isSelectedSkillBootstrapping) { + return; + } + setHasSubmitted(true); + void submitThread(message); + }, + [isSelectedSkillBootstrapping, submitThread], ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); + if (!threadId) { + return null; + } + return ( - - -
-
+
+
+
-
- -
-
- - - -
-
-
-
- -
-
-
+
-
-
-
+
+
- - } - disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading} - onContextChange={(context) => setSettings("context", context)} - onSubmit={handleSubmit} - onStop={handleStop} - /> - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( -
- {t.common.notAvailableInDemoMode} -
+
+ {title !== "Untitled" && ( + + )} +
+
+ +
+
+
+ > +
+ +
+
-
+
+
+
+ {selectedArtifact ? ( + + ) : ( +
+
+ +
+ {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

+ {t.common.artifacts} +

+
+
+ +
+
+ )} +
+ )} +
+
-
+ + {/* Fixed 底部居中输入框容器 */} +
+
+ + {isNewThread && !hasSubmitted && ( + + )} +
+ } + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isSelectedSkillBootstrapping + } + onContextChange={(context) => setSettings("context", context)} + onSubmit={handleSubmit} + onStop={handleStop} + /> + + {/* {isSelectedSkillBootstrapping && ( +
+ 正在初始化 Skill 文件... +
+ )} */} + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( +
+ {t.common.notAvailableInDemoMode} +
+ )} +
+ + + {/* 退出确认对话框 */} + + + + 提示 + +

+ 退出后,当前会话结束并销毁,请先下载保存当前结果! +

+ + + + +
+
+ + {/* selectedSkill 失败:错误弹窗 */} + { + if (!open) clearSelectedSkillError(); + }} + > + + + + ⚠️ {selectedSkillError?.title ?? "技能加载失败"} + + +

+ {selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"} +

+ + + +
+
+ + {/* MARK: 开发测试:iframe 通信功能测试面板 */} + {/* */} +
); } diff --git a/frontend/src/components/ai-elements/artifact.tsx b/frontend/src/components/ai-elements/artifact.tsx index 550dfa11..0eddbf28 100644 --- a/frontend/src/components/ai-elements/artifact.tsx +++ b/frontend/src/components/ai-elements/artifact.tsx @@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes; export const Artifact = ({ className, ...props }: ArtifactProps) => (
(
( -
-); +
+
+
+) diff --git a/frontend/src/components/ai-elements/message.tsx b/frontend/src/components/ai-elements/message.tsx index c0071c21..fb83cee3 100644 --- a/frontend/src/components/ai-elements/message.tsx +++ b/frontend/src/components/ai-elements/message.tsx @@ -28,7 +28,7 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
{ + if (name.length <= maxLen) return name; + const ext = name.slice(name.lastIndexOf(".")); + const baseName = name.slice(0, name.lastIndexOf(".")); + const truncated = baseName.slice(0, maxLen - ext.length - 3); + return truncated + "..." + ext; + }; return ( - - -
-
-
- {isImage ? ( - {filename - ) : ( -
- -
- )} -
- + + + + +
- - {attachmentLabel} -
-
- -
- {isImage && ( -
- {filename -
- )} -
-
-

- {filename || (isImage ? "Image" : "Attachment")} -

- {data.mediaType && ( -

- {data.mediaType} -

- )} -
+ + ) : ( + <> +
+ + + {truncateFilename(filename)} +
-
- - + {/* 关闭按钮 - 右上角 */} + + + )} +
); } @@ -393,13 +427,14 @@ export function PromptInputAttachments({ return (
{attachments.files.map((file) => ( - -
{children(file)}
-
+ {children(file)} ))}
); @@ -457,10 +492,13 @@ export type PromptInputProps = Omit< message: PromptInputMessage, event: FormEvent, ) => void | Promise; + // className for InputGroup (passes through to inner InputGroup component) + inputGroupClassName?: string; }; export const PromptInput = ({ className, + inputGroupClassName, accept, disabled, multiple, @@ -794,7 +832,7 @@ export const PromptInput = ({ ref={formRef} {...props} > - {children} + {children} ); @@ -1027,32 +1065,63 @@ export type PromptInputSubmitProps = ComponentProps & { export const PromptInputSubmit = ({ className, variant = "default", - size = "icon-sm", + size = "sm", status, + disabled, children, ...props }: PromptInputSubmitProps) => { + const controller = useOptionalPromptInputController(); + const { t } = useI18n(); + + // 判断是否有内容可发送 + const hasContent = controller + ? controller.textInput.value.trim().length > 0 || + controller.attachments.files.length > 0 + : false; + + // 正在 streaming 时不允许发送 + // const isStreaming = status === "streaming" || status === "submitted"; + + // const isDisabled = disabled || !hasContent || isStreaming; + let Icon = ; + let text: string = "发送"; + if (status === "submitted") { Icon = ; + text = "生成中..."; } else if (status === "streaming") { Icon = ; + text = "停止"; } else if (status === "error") { Icon = ; + text = "错误"; } return ( - - {children ?? Icon} - + + + {/* {children ?? Icon} */} + {text} + + ); }; @@ -1128,8 +1197,6 @@ export const PromptInputSpeechButton = ({ null, ); const recognitionRef = useRef(null); - const callbacksRef = useRef({ textareaRef, onTranscriptionChange }); - callbacksRef.current = { textareaRef, onTranscriptionChange }; useEffect(() => { if ( @@ -1162,18 +1229,15 @@ export const PromptInputSpeechButton = ({ } } - const currentTextareaRef = callbacksRef.current.textareaRef; - const currentOnTranscriptionChange = callbacksRef.current.onTranscriptionChange; - - if (finalTranscript && currentTextareaRef?.current) { - const textarea = currentTextareaRef.current; + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; const currentValue = textarea.value; const newValue = currentValue + (currentValue ? " " : "") + finalTranscript; textarea.value = newValue; textarea.dispatchEvent(new Event("input", { bubbles: true })); - currentOnTranscriptionChange?.(newValue); + onTranscriptionChange?.(newValue); } }; @@ -1191,7 +1255,7 @@ export const PromptInputSpeechButton = ({ recognitionRef.current.stop(); } }; - }, []); + }, [textareaRef, onTranscriptionChange]); const toggleListening = useCallback(() => { if (!recognition) { diff --git a/frontend/src/components/ai-elements/suggestion.tsx b/frontend/src/components/ai-elements/suggestion.tsx index fe12ae2c..a7f3b033 100644 --- a/frontend/src/components/ai-elements/suggestion.tsx +++ b/frontend/src/components/ai-elements/suggestion.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; +import { Icon } from "@radix-ui/react-select"; import type { LucideIcon } from "lucide-react"; import { Children, type ComponentProps } from "react"; @@ -60,16 +61,17 @@ export const Suggestion = ({ return ( ); diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index 681ad980..d57fb22e 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
) { + return ; +} + +function DevDialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DevDialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DevDialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DevDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DevDialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DevDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DevDialogFooter({ + className, + singleColumn = false, + ...props +}: React.ComponentProps<"div"> & { singleColumn?: boolean }) { + return ( +
+ ); +} + +function DevDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DevDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DevDialog, + DevDialogClose, + DevDialogContent, + DevDialogDescription, + DevDialogFooter, + DevDialogHeader, + DevDialogOverlay, + DevDialogPortal, + DevDialogTitle, + DevDialogTrigger, +}; diff --git a/frontend/src/components/ui/input-group.tsx b/frontend/src/components/ui/input-group.tsx index e35f75b2..a1fea926 100644 --- a/frontend/src/components/ui/input-group.tsx +++ b/frontend/src/components/ui/input-group.tsx @@ -14,14 +14,14 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group border-input/50 dark:bg-background/80 relative flex w-full items-center rounded-md border bg-white/80 shadow-xs transition-[color,box-shadow] outline-none", + "group/input-group border-input/50 dark:bg-background/80 overflow-hidden relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", "h-9 min-w-0 has-[>textarea]:h-auto", // Variants based on alignment. "has-[>[data-align=inline-start]]:[&>input]:pl-2", "has-[>[data-align=inline-end]]:[&>input]:pr-2", "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", - "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + "has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", // Focus state. "has-[[data-slot=input-group-control]:focus-visible]:border-input has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index c232e36c..8531f0ef 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
- + +
{previewable && ( { if (value) { @@ -481,7 +486,7 @@ export function ArtifactFileDetail({ )} {isCodeFile && viewMode === "code" && ( @@ -618,8 +623,8 @@ export const ArtifactZoomSelector = ({ {value}% @@ -630,9 +635,9 @@ export const ArtifactZoomSelector = ({ disabled={!canZoomOut} className={cn( "flex h-full w-10 items-center justify-center rounded transition-colors", - "text-gray-400 hover:bg-gray-100 hover:text-gray-600", + "text-muted-foreground hover:bg-muted hover:text-foreground", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", - "dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300", + "dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground", )} aria-label="缩小" > diff --git a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx index 99b2e374..4aad6301 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx @@ -18,7 +18,7 @@ import { getFileIcon, getFileName, } from "@/core/utils/files"; -import { cn } from "@/lib/utils"; +import { cn, truncateMiddle } from "@/lib/utils"; import { useArtifacts } from "./context"; @@ -80,12 +80,14 @@ export function ArtifactFileList({ onClick={() => handleClick(file)} > - -
{getFileName(file)}
-
- {getFileIcon(file, "size-6")} + +
+ {truncateMiddle(getFileName(file), 50)}
+
+ {getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")} +
{getFileExtensionDisplayName(file)} file diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index a57aa522..3f693bc4 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -132,7 +132,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ > {selectedArtifact ? ( diff --git a/frontend/src/components/workspace/dev-todo-list.tsx b/frontend/src/components/workspace/dev-todo-list.tsx new file mode 100644 index 00000000..466d395a --- /dev/null +++ b/frontend/src/components/workspace/dev-todo-list.tsx @@ -0,0 +1,69 @@ +"use client"; + +import type { Todo } from "@/core/todos"; +import { cn } from "@/lib/utils"; + +import { + QueueItem, + QueueItemContent, + QueueItemIndicator, + QueueList, +} from "../ai-elements/queue"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; + +export function DevTodoList({ + className, + todos, + trigger, + hidden, +}: { + className?: string; + todos: Todo[]; + trigger: React.ReactNode; + hidden: boolean; +}) { + if (hidden) { + return null; + } + console.log(todos); + return ( + + {trigger} + + + {todos.map((todo, i) => ( + +
+ + + {todo.content} + +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/workspace/iframe-test-panel.tsx b/frontend/src/components/workspace/iframe-test-panel.tsx new file mode 100644 index 00000000..0967bde9 --- /dev/null +++ b/frontend/src/components/workspace/iframe-test-panel.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useSearchParams, useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { useIframeSkill } from "@/hooks/use-iframe-skill"; +import { copyToClipboard } from "@/lib/utils"; +import { cn } from "@/lib/utils"; + +/** + * IframeTestPanel —— 仅用于开发阶段测试 iframe 通信功能 + * + * 测试场景: + * 1. mode=skill 侧边栏隐藏 + * 2. useSpecificChatMode 注入提示词 + * 3. sendSelectSkill / openSkillDialog / clearSkill + */ +export function IframeTestPanel() { + const router = useRouter(); + const searchParams = useSearchParams(); + const iframeSkill = useIframeSkill(); + const [log, setLog] = useState([]); + const [open, setOpen] = useState(true); + + const isSkillMode = searchParams.get("mode") === "skill"; + + function addLog(msg: string) { + setLog((prev) => [ + `[${new Date().toLocaleTimeString()}] ${msg}`, + ...prev.slice(0, 9), + ]); + } + + function handleEnterSkillMode() { + router.push(`?mode=skill&skill_id=123&title=测试技能`); + addLog("进入 mode=skill,URL 已更新"); + } + + function handleExitSkillMode() { + router.push(`?`); + addLog("退出 skill 模式"); + } + + function handleSendSelectSkill() { + iframeSkill.sendSelectSkill("skill_001"); + addLog("postMessage → selectSkill (skill_id=skill_001)"); + } + + function handleOpenSkillDialog() { + iframeSkill.openSkillDialog(); + addLog("postMessage → openSkillDialog"); + } + + function handleClearSkill() { + iframeSkill.clearSkill(); + addLog("clearSkill 已调用,postMessage → skill_id=0"); + } + + function handleTestClipboardCopy() { + const testText = "测试复制内容 - " + new Date().toISOString(); + copyToClipboard(testText); + addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`); + } + + // 检测是否在 iframe 中 + const isInIframe = typeof window !== "undefined" && window.self !== window.top; + if (!open) { + return ( + + ); + } + return ( +
+ {/* 标题栏 */} +
+ 🧪 iframe 通信测试 + +
+ +
+ {/* 当前状态 */} +
+
当前状态
+
+ + mode: + + {isSkillMode ? "skill ✅" : "普通"} + + + + selectedSkill: + + {iframeSkill.selectedSkill + ? `${iframeSkill.selectedSkill.skill_id} / ${iframeSkill.selectedSkill.title}` + : "无"} + + +
+
+ + {/* 场景 1:侧边栏隐藏 */} +
+
+ ① 侧边栏隐藏(layout) +
+
+ + +
+
+ + {/* 场景 2:skill 选择通信 */} +
+
+ ② postMessage 通信(发送到宿主) +
+
+ + + +
+
+ + {/* 场景 3:接收宿主页 selectedSkill */} +
+
+ ③ 接收宿主页 selectedSkill +
+
+ + +
+
+ + {/* 场景 4:剪贴板复制(iframe 通信) */} +
+
+ + ④ 剪贴板复制(iframe 通信) + + + {isInIframe ? "iframe 模式" : "独立页面"} + +
+
+ +
+ {isInIframe + ? "将通过 postMessage 请求父页面复制" + : "将直接调用 navigator.clipboard"} +
+
+
+ + {/* 日志 */} + {log.length > 0 && ( +
+
+ 操作日志 +
+ {log.map((l, i) => ( +
+ {l} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 92b377be..b517019b 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -59,6 +59,7 @@ import { import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import type { AgentThreadContext } from "@/core/threads"; +import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { cn } from "@/lib/utils"; import { @@ -81,79 +82,6 @@ import { import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; -const POST_MESSAGE_TYPES = { - SELECT_SKILL: "selectSkill", - OPEN_SKILL_DIALOG: "openSkillDialog", -} as const; - -const RECEIVE_MESSAGE_TYPES = { - SELECTED_SKILL: "selectedSkill", -} as const; - -type IframeSelectedSkillMessage = { - type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL; - id: string | number; - title: string; -}; - -type IframeSkillData = { - skill_id: string; - title: string; -}; - -function sendIframeMessageToParent(message: unknown): void { - if (window.parent !== window) { - window.parent.postMessage(message, "*"); - } -} - -function useEmbeddedIframeSkill() { - const searchParams = useSearchParams(); - const skillIdFromQuery = searchParams.get("skill_id"); - const titleFromQuery = searchParams.get("title"); - const [selectedSkill, setSelectedSkill] = useState( - null, - ); - - useEffect(() => { - if (skillIdFromQuery && titleFromQuery) { - setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); - } - }, [skillIdFromQuery, titleFromQuery]); - - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { - const { id, title } = event.data as IframeSelectedSkillMessage; - setSelectedSkill({ skill_id: String(id), title }); - } - }; - window.addEventListener("message", handleMessage); - return () => window.removeEventListener("message", handleMessage); - }, []); - - const sendSelectSkill = useCallback((skill_id: string) => { - sendIframeMessageToParent({ type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id }); - }, []); - - const openSkillDialog = useCallback(() => { - sendIframeMessageToParent({ - type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG, - openSkillDialog: true, - }); - }, []); - - const clearSkill = useCallback(() => { - setSelectedSkill(null); - sendIframeMessageToParent({ - type: POST_MESSAGE_TYPES.SELECT_SKILL, - skill_id: "0", - }); - }, []); - - return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill }; -} - export function InputBox({ className, disabled, @@ -163,7 +91,6 @@ export function InputBox({ extraHeader, isNewThread, hasSubmitted, - threadId: threadIdProp, initialValue, onContextChange, onSubmit, @@ -182,7 +109,6 @@ export function InputBox({ extraHeader?: React.ReactNode; isNewThread?: boolean; hasSubmitted?: boolean; - threadId?: string; initialValue?: string; onContextChange?: ( context: Omit< @@ -197,10 +123,10 @@ export function InputBox({ }) { const { t } = useI18n(); const searchParams = useSearchParams(); - const iframeSkill = useEmbeddedIframeSkill(); + const iframeSkill = useIframeSkill(); const params = useParams(); - const threadId = threadIdProp ?? params?.thread_id; + const threadId = params?.thread_id; const { textInput } = usePromptInputController(); const attachments = usePromptInputAttachments(); const promptRootRef = useRef(null); @@ -375,18 +301,16 @@ export function InputBox({ ; + thread: UseStream; + threadId?: string; isMock?: boolean; } diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index b7205f55..45c57211 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -79,7 +79,7 @@ export function MessageGroup({ const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); return ( {aboveLastToolCallSteps.length > 0 && ( diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index f3efa9c4..c7f14d74 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -49,7 +49,10 @@ export function MessageListItem({ const isHuman = message.type === "human"; return ( @@ -327,7 +330,7 @@ function RichFileCard({ if (isUploading) { return ( -
+
); } return ( -
+
; + thread: UseStream; + /** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */ + messagesOverride?: Message[]; + suppressThreadLoading?: boolean; paddingBottom?: number; }) { const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); - const messages = thread.messages; - if (thread.isThreadLoading && messages.length === 0) { + const messages = messagesOverride ?? thread.messages; + if (thread.isThreadLoading && !suppressThreadLoading) { return ; } return ( - + {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { - return group.messages.map((msg) => { - return ( - - ); - }); + return ( + + ); } else if (group.type === "assistant:clarification") { const message = group.messages[0]; if (message && hasContent(message)) { @@ -168,9 +172,9 @@ export function MessageList({ {t.subtasks.executing(tasks.size)}
, ); - const taskIds = message.tool_calls - ?.filter((toolCall) => toolCall.name === "task") - .map((toolCall) => toolCall.id); + const taskIds = message.tool_calls?.map( + (toolCall) => toolCall.id, + ); for (const taskId of taskIds ?? []) { results.push( ; + threadId?: string; + thread?: UseStream; + threadTitle?: string; }) { const { t } = useI18n(); const { isNewThread } = useThreadChat(); useEffect(() => { + if (!thread) { + return; + } let _title = t.pages.untitled; if (thread.values?.title) { @@ -35,11 +40,14 @@ export function ThreadTitle({ t.pages.newChat, t.pages.untitled, t.pages.appName, - thread.isThreadLoading, - thread.values, + thread?.isThreadLoading, + thread?.values, ]); - if (!thread.values?.title) { + if (threadTitle) { + return {threadTitle}; + } + if (!thread || !thread.values?.title || !threadId) { return null; } return ( diff --git a/frontend/src/components/workspace/welcome.tsx b/frontend/src/components/workspace/welcome.tsx index 4fa9ec74..8386df27 100644 --- a/frontend/src/components/workspace/welcome.tsx +++ b/frontend/src/components/workspace/welcome.tsx @@ -41,10 +41,16 @@ export function Welcome({ `✨ ${t.welcome.createYourOwnSkill} ✨` ) : (
-
- {isUltra ? "🚀" : "👋"} -
- {t.welcome.greeting} + + {t.welcome.greeting} +
)}
@@ -59,13 +65,7 @@ export function Welcome({ )}
) : ( -
- {t.welcome.description.includes("\n") ? ( -
{t.welcome.description}
- ) : ( -

{t.welcome.description}

- )} -
+
)}
); diff --git a/frontend/src/core/iframe-messages.ts b/frontend/src/core/iframe-messages.ts new file mode 100644 index 00000000..9a28b2f1 --- /dev/null +++ b/frontend/src/core/iframe-messages.ts @@ -0,0 +1,59 @@ +/** + * iframe 与宿主页通信消息类型常量 + * + * 消息格式:{ type: MESSAGE_TYPE, ...其他字段 } + * 发送方式:window.parent.postMessage(message, "*") + */ + +// 发送给宿主页的消息类型 +export const POST_MESSAGE_TYPES = { + // 全屏切换 + FULLSCREEN: "fullscreen", + // 选择预定义 skill + SELECT_SKILL: "selectSkill", + // 打开 skill 选择对话框 + OPEN_SKILL_DIALOG: "openSkillDialog", +} as const; + +// 接收来自宿主页的消息类型 +export const RECEIVE_MESSAGE_TYPES = { + // 选中的 skill 数据 + SELECTED_SKILL: "selectedSkill", +} as const; + +// 消息类型 +export type PostMessageType = + (typeof POST_MESSAGE_TYPES)[keyof typeof POST_MESSAGE_TYPES]; +export type ReceiveMessageType = + (typeof RECEIVE_MESSAGE_TYPES)[keyof typeof RECEIVE_MESSAGE_TYPES]; + +// 消息数据类型 +export interface FullscreenMessage { + type: typeof POST_MESSAGE_TYPES.FULLSCREEN; + fullscreen: boolean; +} + +export interface SelectSkillMessage { + type: typeof POST_MESSAGE_TYPES.SELECT_SKILL; + skill_id: string; +} + +export interface OpenSkillDialogMessage { + type: typeof POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG; + openSkillDialog: true; +} + +export interface SelectedSkillMessage { + type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL; + id: string | number; + title: string; +} + +// 发送消息的辅助函数 +export function sendToParent( + message: FullscreenMessage | SelectSkillMessage | OpenSkillDialogMessage, +): void { + if (window.parent !== window) { + window.parent.postMessage(message, "*"); + } +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index f637ac4e..46e5cc77 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -1,6 +1,6 @@ import type { AIMessage, Message } from "@langchain/langgraph-sdk"; import type { ThreadsClient } from "@langchain/langgraph-sdk/client"; -import { useStream } from "@langchain/langgraph-sdk/react"; +import { useStream, type UseStream } from "@langchain/langgraph-sdk/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -15,8 +15,9 @@ import type { LocalSettings } from "../settings"; import { useUpdateSubtask } from "../tasks/context"; import type { UploadedFileInfo } from "../uploads"; import { uploadFiles } from "../uploads"; +import type { UploadTarget } from "../uploads/api"; -import type { AgentThread, AgentThreadState } from "./types"; +import type { AgentThread, AgentThreadContext, AgentThreadState } from "./types"; export type ToolEndEvent = { name: string; @@ -32,6 +33,14 @@ export type ThreadStreamOptions = { onToolEnd?: (event: ToolEndEvent) => void; }; +export type LegacyThreadStreamOptions = { + isNewThread: boolean; + threadId: string | null | undefined; + fetchStateHistory?: boolean; + onFinish?: (state: AgentThreadState) => void; + useSubmitThread?: boolean; +}; + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; @@ -55,6 +64,64 @@ function getStreamErrorMessage(error: unknown): string { return "Request failed."; } +export function useThreadStreamLegacy({ + threadId, + isNewThread, + fetchStateHistory = true, + onFinish, +}: LegacyThreadStreamOptions): UseStream { + const queryClient = useQueryClient(); + const updateSubtask = useUpdateSubtask(); + const thread = useStream({ + client: getAPIClient(), + assistantId: "lead_agent", + threadId: isNewThread ? undefined : threadId, + reconnectOnMount: true, + fetchStateHistory, + onCustomEvent(event: unknown) { + console.info(event); + if ( + typeof event === "object" && + event !== null && + "type" in event && + event.type === "task_running" + ) { + const e = event as { + type: "task_running"; + task_id: string; + message: AIMessage; + }; + updateSubtask({ id: e.task_id, latestMessage: e.message }); + } + }, + onFinish(state) { + onFinish?.(state.values); + queryClient.setQueriesData( + { + queryKey: ["threads", "search"], + exact: false, + }, + (oldData: Array) => { + return oldData.map((t) => { + if (t.thread_id === threadId) { + return { + ...t, + values: { + ...t.values, + title: state.values.title, + }, + }; + } + return t; + }); + }, + ); + }, + }); + return thread; +} + + export function useThreadStream({ threadId, context, @@ -410,6 +477,123 @@ export function useThreadStream({ return [mergedThread, sendMessage, isUploading] as const; } +export function useSubmitThread({ + threadId, + thread, + threadContext, + isNewThread, + createNewSession, + uploadTarget, + afterSubmit, +}: { + isNewThread: boolean; + createNewSession: boolean; + threadId: string | null | undefined; + thread: UseStream; + threadContext: Omit; + uploadTarget?: UploadTarget; + afterSubmit?: () => void; +}) { + const queryClient = useQueryClient(); + const apiClient = getAPIClient(); + const callback = useCallback( + async (message: PromptInputMessage) => { + const text = message.text.trim(); + + const hasFiles = !!(message.files && message.files.length > 0); + if (!text && !hasFiles) { + return; + } + + if (createNewSession && threadId) { + try { + await apiClient.threads.delete(threadId); + } catch { + // Ignore delete errors + } + } + + if (message.files && message.files.length > 0) { + try { + const filePromises = message.files.map(async (fileUIPart) => { + if (fileUIPart.url && fileUIPart.filename) { + try { + const response = await fetch(fileUIPart.url); + const blob = await response.blob(); + + return new File([blob], fileUIPart.filename, { + type: fileUIPart.mediaType || blob.type, + }); + } catch (error) { + console.error( + `Failed to fetch file ${fileUIPart.filename}:`, + error, + ); + return null; + } + } + return null; + }); + + const files = (await Promise.all(filePromises)).filter( + (file): file is File => file !== null, + ); + + if (files.length > 0 && threadId) { + await uploadFiles(threadId, files, { target: uploadTarget }); + } + } catch (error) { + console.error("Failed to upload files:", error); + } + } + + await thread.submit( + { + messages: [ + { + type: "human", + content: [ + { + type: "text", + text, + }, + ], + }, + ] as Message[], + }, + { + threadId: createNewSession ? threadId! : undefined, + streamSubgraphs: true, + streamResumable: true, + streamMode: ["values", "messages-tuple", "custom"], + config: { + recursion_limit: 1000, + }, + context: { + ...threadContext, + thread_id: threadId, + }, + }, + ); + + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + afterSubmit?.(); + }, + [ + thread, + isNewThread, + createNewSession, + threadId, + threadContext, + uploadTarget, + queryClient, + apiClient, + afterSubmit, + ], + ); + return callback; +} + export function useThreads( params: Parameters[0] = { limit: 50, diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts new file mode 100644 index 00000000..63cd5cee --- /dev/null +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -0,0 +1,78 @@ +import { useSearchParams } from "next/navigation"; +import { useState, useEffect, useCallback } from "react"; + +import { + POST_MESSAGE_TYPES, + RECEIVE_MESSAGE_TYPES, + sendToParent, + type SelectedSkillMessage, +} from "@/core/iframe-messages"; + +// Skill 数据类型 +interface SkillData { + skill_id: string; + title: string; +} + +// Hook 返回类型 +interface UseIframeSkillReturn { + selectedSkill: SkillData | null; + sendSelectSkill: (skill_id: string) => void; + openSkillDialog: () => void; + clearSkill: () => void; +} + +export function useIframeSkill(): UseIframeSkillReturn { + const searchParams = useSearchParams(); + const skillIdFromQuery = searchParams.get("skill_id"); + const titleFromQuery = searchParams.get("title"); + + const [selectedSkill, setSelectedSkill] = useState(null); + + // 1. 监听 query 参数变化 + useEffect(() => { + if (skillIdFromQuery && titleFromQuery) { + setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); + } + }, [skillIdFromQuery, titleFromQuery]); + + // 2. 监听宿主页 postMessage + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { + const { id, title } = event.data as SelectedSkillMessage; + setSelectedSkill({ skill_id: String(id), title }); + } + }; + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + // 发送选择预定义 skill + const sendSelectSkill = useCallback((skill_id: string) => { + const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id }; + console.log("[useIframeSkill] sendSelectSkill:", message); + sendToParent(message); + }, []); + + // 打开 skill 选择对话框 + const openSkillDialog = useCallback(() => { + const message = { + type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG, + openSkillDialog: true, + } as const; + console.log("[useIframeSkill] openSkillDialog:", message); + sendToParent(message); + }, []); + + // 清除选中并发送 skill_id=0 给主页 + const clearSkill = useCallback(() => { + setSelectedSkill(null); + // 发送 skill_id=0 给主页,通知取消选择 + const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" }; + console.log("[useIframeSkill] clearSkill, sending skill_id=0:", message); + sendToParent(message); + }, []); + + return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill }; +} diff --git a/frontend/src/hooks/use-selected-skill-listener.ts b/frontend/src/hooks/use-selected-skill-listener.ts new file mode 100644 index 00000000..fc20d66d --- /dev/null +++ b/frontend/src/hooks/use-selected-skill-listener.ts @@ -0,0 +1,152 @@ +import { useSearchParams } from "next/navigation"; +import { useEffect, useCallback, useState, useRef } from "react"; +import { toast } from "sonner"; + +import { bootstrapRemoteSkill } from "@/core/skills/api"; + +/** 宿主页发过来的 selectedSkill 消息结构 */ +interface SelectedSkillMessage { + type: "selectedSkill"; + id: number | string; + title: string; +} + +/** 技能基础数据 */ +interface SkillData { + skill_id: string; + title: string; +} + +/** 错误信息状态 */ +interface SkillError { + title: string; + message: string; +} + +interface UseSelectedSkillListenerOptions { + /** 当前会话 thread_id,用于调用 bootstrapRemoteSkill */ + threadId: string | null; +} + +interface UseSelectedSkillListenerReturn { + /** 当前选中的技能数据(用于 UI 展示,如 Badge) */ + selectedSkill: SkillData | null; + /** 当前错误信息,不为 null 时展示 DevDialog */ + skillError: SkillError | null; + /** 清除错误信息(关闭 DevDialog 时调用) */ + clearSkillError: () => void; + /** 是否正在加载(处理 skill 中) */ + isBootstrapping: boolean; +} + +/** + * 监听宿主页通过 postMessage 发送的 selectedSkill 消息或 URL 中的 skill 参数, + * 收到后自动调用 bootstrapRemoteSkill 接口: + * - 成功:使用 toast 提示 + * - 失败:返回 skillError 供 DevDialog 显示 + */ +export function useSelectedSkillListener({ + threadId, +}: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn { + const searchParams = useSearchParams(); + const [selectedSkill, setSelectedSkill] = useState(null); + const [skillError, setSkillError] = useState(null); + const [isBootstrapping, setIsBootstrapping] = useState(false); + + const isFirstLoadRef = useRef(false); + const skillBootstrappedKeyRef = useRef(null); + + const performBootstrap = useCallback( + async (id: number | string, title: string) => { + if (!threadId) return; + + const languageTypeRaw = + searchParams.get("languageType")?.trim() ?? + searchParams.get("language_type")?.trim(); + const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; + + const initKey = `${threadId}:${id}:${languageType}`; + if (skillBootstrappedKeyRef.current === initKey) { + return; + } + + console.log( + `[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`, + ); + setIsBootstrapping(true); + toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); + + try { + const result = await bootstrapRemoteSkill({ + thread_id: threadId, + content_id: Number(id), + language_type: languageType, + target_dir: "/mnt/user-data/uploads/skill", + clear_target: true, + }); + + toast.dismiss("skill-bootstrap"); + + if (result.success) { + skillBootstrappedKeyRef.current = initKey; + toast.success(`技能「${title}」加载成功`, { + description: + result.message || `已创建 ${result.created_files} 个文件`, + duration: 4000, + }); + } else { + setSkillError({ + title: `技能「${title}」加载失败`, + message: result.message || "未知错误", + }); + } + } catch (err) { + toast.dismiss("skill-bootstrap"); + const message = err instanceof Error ? err.message : "网络请求失败"; + setSkillError({ title: `技能「${title}」加载出错`, message }); + } finally { + setIsBootstrapping(false); + } + }, + [threadId, searchParams], + ); + + // 1. URL 初始化集成 + useEffect(() => { + if (!threadId || isFirstLoadRef.current) return; + + const skillIdFromQuery = searchParams.get("skill_id"); + const titleFromQuery = searchParams.get("title"); + if (skillIdFromQuery && titleFromQuery) { + isFirstLoadRef.current = true; + setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); + void performBootstrap(skillIdFromQuery, titleFromQuery); + } + }, [threadId, searchParams, performBootstrap]); + + const handleMessage = useCallback( + (event: MessageEvent) => { + const data = event.data as SelectedSkillMessage; + if (data?.type !== "selectedSkill") return; + + const { id, title } = data; + console.log( + "[useSelectedSkillListener] 收到 postMessage selectedSkill:", + data, + ); + + setSelectedSkill({ skill_id: String(id), title }); + void performBootstrap(id, title); + }, + [performBootstrap], + ); + + useEffect(() => { + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [handleMessage]); + + const clearSkillError = useCallback(() => setSkillError(null), []); + + return { selectedSkill, skillError, clearSkillError, isBootstrapping }; +}