Compare commits
No commits in common. "a721091476b7e896dd8d4d24c95a735d192feb10" and "a34a0236752e0d890916d52f8e024b9e3e93dc48" have entirely different histories.
a721091476
...
a34a023675
|
|
@ -0,0 +1,18 @@
|
||||||
|
# --------------- 构建阶段 ---------------
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ARG PNPM_STORE_PATH=/root/.local/share/pnpm/store
|
||||||
|
ENV BETTER_AUTH_SECRET=any-random-string-123456
|
||||||
|
|
||||||
|
RUN corepack enable && corepack install -g pnpm@10.26.2
|
||||||
|
RUN pnpm config set store-dir ${PNPM_STORE_PATH}
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|
@ -9,6 +9,7 @@ export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
".next",
|
".next",
|
||||||
|
"imports/**",
|
||||||
"src/components/ui/**",
|
"src/components/ui/**",
|
||||||
"src/components/ai-elements/**",
|
"src/components/ai-elements/**",
|
||||||
"*.js",
|
"*.js",
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"demo:save": "node scripts/save-demo.js",
|
"demo:save": "node scripts/save-demo.js",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "eslint . --ext .ts,.tsx --ignore-pattern imports/** && tsc --noEmit",
|
"check": "eslint . --ext .ts,.tsx && tsc --noEmit",
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:write": "prettier --write .",
|
"format:write": "prettier --write .",
|
||||||
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --ignore-pattern imports/** --fix",
|
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function DevDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dev-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dev-dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dev-dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dev-dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dev-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DevDialogPortal data-slot="dev-dialog-portal">
|
||||||
|
<DevDialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dev-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-[400px] max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-[#ffffff] p-[40px] shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dev-dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DevDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dev-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogFooter({
|
||||||
|
className,
|
||||||
|
singleColumn = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { singleColumn?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dev-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"grid w-full justify-between gap-[30px] sm:flex-row",
|
||||||
|
singleColumn ? "grid-cols-1" : "grid-cols-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dev-dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dev-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DevDialog,
|
||||||
|
DevDialogClose,
|
||||||
|
DevDialogContent,
|
||||||
|
DevDialogDescription,
|
||||||
|
DevDialogFooter,
|
||||||
|
DevDialogHeader,
|
||||||
|
DevDialogOverlay,
|
||||||
|
DevDialogPortal,
|
||||||
|
DevDialogTitle,
|
||||||
|
DevDialogTrigger,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn, truncateMiddle } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface DropdownSelectorOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownSelectorProps<T extends string> {
|
||||||
|
value: T;
|
||||||
|
options: DropdownSelectorOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
triggerClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronDownIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="6"
|
||||||
|
viewBox="0 0 10 6"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0.75 0.75L4.75 4.75L8.75 0.75"
|
||||||
|
stroke="#666666"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronUpIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="6"
|
||||||
|
viewBox="0 0 10 6"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0.75 4.75L4.75 0.75L8.75 4.75"
|
||||||
|
stroke="#666666"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownSelector<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
triggerClassName,
|
||||||
|
contentClassName,
|
||||||
|
}: DropdownSelectorProps<T>) {
|
||||||
|
const selectedOption = options.find((opt) => opt.value === value);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className={
|
||||||
|
triggerClassName ??
|
||||||
|
"border-none bg-transparent flex justify-center w-full overflow-hidden text-ellipsis whitespace-nowrap shadow-none select-none focus:outline-none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex w-full justify-center items-center gap-1">
|
||||||
|
{truncateMiddle(selectedOption?.label ?? value, 50)}
|
||||||
|
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className={cn(contentClassName, "max-w-80")}>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={value}
|
||||||
|
onValueChange={(v) => onChange(v as T)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
title={option.label}
|
||||||
|
>
|
||||||
|
{truncateMiddle(option.label)}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,19 +23,13 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import { DropdownSelector } from "@/components/ui/dropdown-selector";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||||
|
|
@ -46,16 +40,6 @@ import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
|
||||||
const POST_MESSAGE_TYPES = {
|
|
||||||
FULLSCREEN: "fullscreen",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function sendToParent(message: unknown): void {
|
|
||||||
if (window.parent !== window) {
|
|
||||||
window.parent.postMessage(message, "*");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArtifactFileDetail({
|
export function ArtifactFileDetail({
|
||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
|
|
@ -254,20 +238,11 @@ export function ArtifactFileDetail({
|
||||||
{isWriteFile ? (
|
{isWriteFile ? (
|
||||||
<div className=" w-full text-center overflow-hidden text-ellipsis whitespace-nowrap px-2">{truncateMiddle(getFileName(filepath), 50)}</div>
|
<div className=" w-full text-center overflow-hidden text-ellipsis whitespace-nowrap px-2">{truncateMiddle(getFileName(filepath), 50)}</div>
|
||||||
) : (
|
) : (
|
||||||
<Select value={filepath} onValueChange={select}>
|
<DropdownSelector
|
||||||
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
|
value={filepath}
|
||||||
<SelectValue placeholder="Select a file" />
|
options={artifactOptions}
|
||||||
</SelectTrigger>
|
onChange={select}
|
||||||
<SelectContent className="select-none">
|
/>
|
||||||
<SelectGroup>
|
|
||||||
{artifactOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
</ArtifactTitle>
|
</ArtifactTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
getFileName,
|
getFileName,
|
||||||
} from "@/core/utils/files";
|
} from "@/core/utils/files";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, truncateMiddle } from "@/lib/utils";
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
|
||||||
|
|
@ -80,12 +80,14 @@ export function ArtifactFileList({
|
||||||
onClick={() => handleClick(file)}
|
onClick={() => handleClick(file)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pr-2 pl-1">
|
<CardHeader className="pr-2 pl-1">
|
||||||
<CardTitle className="relative pl-8">
|
<CardTitle className=" relative pl-8 overflow-hidden">
|
||||||
<div>{getFileName(file)}</div>
|
<div className=" text-ellipsis whitespace-nowrap text-sm font-normal" title={getFileName(file)}>
|
||||||
<div className="absolute top-2 -left-0.5">
|
{truncateMiddle(getFileName(file), 50)}
|
||||||
{getFileIcon(file, "size-6")}
|
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="absolute top-5 left-3">
|
||||||
|
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
|
||||||
|
</div>
|
||||||
<CardDescription className="pl-8 text-xs">
|
<CardDescription className="pl-8 text-xs">
|
||||||
{getFileExtensionDisplayName(file)} file
|
{getFileExtensionDisplayName(file)} file
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className={cn(
|
||||||
|
"z-[100] rounded-[20px] bg-white p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
<QueueList className="w-64">
|
||||||
|
{todos.map((todo, i) => (
|
||||||
|
<QueueItem key={i + (todo.content ?? "")}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<QueueItemIndicator
|
||||||
|
className={
|
||||||
|
todo.status === "in_progress" ? "bg-primary/70" : ""
|
||||||
|
}
|
||||||
|
completed={todo.status === "completed"}
|
||||||
|
/>
|
||||||
|
<QueueItemContent
|
||||||
|
className={
|
||||||
|
todo.status === "in_progress" ? "text-primary/70" : ""
|
||||||
|
}
|
||||||
|
completed={todo.status === "completed"}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</QueueItemContent>
|
||||||
|
</div>
|
||||||
|
</QueueItem>
|
||||||
|
))}
|
||||||
|
</QueueList>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string[]>([]);
|
||||||
|
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();
|
||||||
|
void copyToClipboard(testText);
|
||||||
|
addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否在 iframe 中
|
||||||
|
const isInIframe = typeof window !== "undefined" && window.self !== window.top;
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="fixed bottom-24 left-3 z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
🧪 测试面板
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-24 left-3 z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm">
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="flex items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2">
|
||||||
|
<span className="text-xs font-bold text-white">🧪 iframe 通信测试</span>
|
||||||
|
<button
|
||||||
|
className="text-white/70 hover:text-white"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
{/* 当前状态 */}
|
||||||
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||||
|
<div className="mb-1 font-semibold text-gray-500">当前状态</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>
|
||||||
|
<span className="text-gray-400">mode:</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono font-bold",
|
||||||
|
isSkillMode ? "text-violet-600" : "text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSkillMode ? "skill ✅" : "普通"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-gray-400">selectedSkill:</span>
|
||||||
|
<span className="font-mono text-violet-600">
|
||||||
|
{iframeSkill.selectedSkill
|
||||||
|
? `${iframeSkill.selectedSkill.skill_id} / ${iframeSkill.selectedSkill.title}`
|
||||||
|
: "无"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 场景 1:侧边栏隐藏 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||||
|
① 侧边栏隐藏(layout)
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleEnterSkillMode}
|
||||||
|
>
|
||||||
|
进入 skill 模式
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleExitSkillMode}
|
||||||
|
>
|
||||||
|
退出 skill 模式
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 场景 2:skill 选择通信 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||||
|
② postMessage 通信(发送到宿主)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSendSelectSkill}
|
||||||
|
>
|
||||||
|
sendSelectSkill (skill_001)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleOpenSkillDialog}
|
||||||
|
>
|
||||||
|
openSkillDialog
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-red-50 text-xs text-red-600 hover:bg-red-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClearSkill}
|
||||||
|
>
|
||||||
|
clearSkill (发送 skill_id=0)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 场景 3:接收宿主页 selectedSkill */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||||
|
③ 接收宿主页 selectedSkill
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-green-50 text-xs text-green-700 hover:bg-green-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
window.postMessage(
|
||||||
|
{ type: "selectedSkill", id: 5, title: "文档处理" },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
addLog(
|
||||||
|
"模拟宿主页 → selectedSkill { id: 5, title: '文档处理' }",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✅ 模拟 selectedSkill(成功)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
window.postMessage(
|
||||||
|
{ type: "selectedSkill", id: 999999, title: "不存在的技能" },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
addLog(
|
||||||
|
"模拟宿主页 → selectedSkill { id: 999999, title: '不存在的技能' }",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
❌ 模拟 selectedSkill(失败/错误)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 场景 4:剪贴板复制(iframe 通信) */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-500">
|
||||||
|
④ 剪贴板复制(iframe 通信)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded px-1.5 py-0.5 text-[10px] font-medium",
|
||||||
|
isInIframe
|
||||||
|
? "bg-violet-100 text-violet-700"
|
||||||
|
: "bg-gray-100 text-gray-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isInIframe ? "iframe 模式" : "独立页面"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleTestClipboardCopy}
|
||||||
|
>
|
||||||
|
📋 测试复制到剪贴板
|
||||||
|
</Button>
|
||||||
|
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
|
||||||
|
{isInIframe
|
||||||
|
? "将通过 postMessage 请求父页面复制"
|
||||||
|
: "将直接调用 navigator.clipboard"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日志 */}
|
||||||
|
{log.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-gray-900 p-2">
|
||||||
|
<div className="mb-1 text-[10px] font-semibold text-gray-400">
|
||||||
|
操作日志
|
||||||
|
</div>
|
||||||
|
{log.map((l, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="truncate font-mono text-[10px] text-green-400"
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,7 @@ import {
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
|
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -81,79 +82,6 @@ import {
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
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<IframeSkillData | null>(
|
|
||||||
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({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
|
|
@ -197,7 +125,7 @@ export function InputBox({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const iframeSkill = useEmbeddedIframeSkill();
|
const iframeSkill = useIframeSkill();
|
||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const threadId = threadIdProp ?? params?.thread_id;
|
const threadId = threadIdProp ?? params?.thread_id;
|
||||||
|
|
|
||||||
|
|
@ -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, "*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SkillData | null>(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 };
|
||||||
|
}
|
||||||
|
|
@ -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<SkillData | null>(null);
|
||||||
|
const [skillError, setSkillError] = useState<SkillError | null>(null);
|
||||||
|
const [isBootstrapping, setIsBootstrapping] = useState(false);
|
||||||
|
|
||||||
|
const isFirstLoadRef = useRef(false);
|
||||||
|
const skillBootstrappedKeyRef = useRef<string | null>(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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue