Compare commits
10 Commits
a34a023675
...
a721091476
| Author | SHA1 | Date |
|---|---|---|
|
|
a721091476 | |
|
|
4afba86bcb | |
|
|
93b9bc7af5 | |
|
|
49ff9411e9 | |
|
|
91e094066e | |
|
|
105802d6c1 | |
|
|
4bd2591a03 | |
|
|
00505f643d | |
|
|
e8b145bff3 | |
|
|
929260bdb8 |
|
|
@ -1,18 +0,0 @@
|
|||
# --------------- 构建阶段 ---------------
|
||||
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,7 +9,6 @@ export default tseslint.config(
|
|||
{
|
||||
ignores: [
|
||||
".next",
|
||||
"imports/**",
|
||||
"src/components/ui/**",
|
||||
"src/components/ai-elements/**",
|
||||
"*.js",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
"scripts": {
|
||||
"demo:save": "node scripts/save-demo.js",
|
||||
"build": "next build",
|
||||
"check": "eslint . --ext .ts,.tsx && tsc --noEmit",
|
||||
"check": "eslint . --ext .ts,.tsx --ignore-pattern imports/** && tsc --noEmit",
|
||||
"dev": "next dev --turbo",
|
||||
"format": "prettier --check .",
|
||||
"format:write": "prettier --write .",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --ignore-pattern imports/** --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
"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,
|
||||
};
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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,13 +23,19 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DropdownSelector } from "@/components/ui/dropdown-selector";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
|
|
@ -40,6 +46,16 @@ import { CitationLink } from "../citations/citation-link";
|
|||
|
||||
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({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
|
|
@ -238,11 +254,20 @@ export function ArtifactFileDetail({
|
|||
{isWriteFile ? (
|
||||
<div className=" w-full text-center overflow-hidden text-ellipsis whitespace-nowrap px-2">{truncateMiddle(getFileName(filepath), 50)}</div>
|
||||
) : (
|
||||
<DropdownSelector
|
||||
value={filepath}
|
||||
options={artifactOptions}
|
||||
onChange={select}
|
||||
/>
|
||||
<Select value={filepath} onValueChange={select}>
|
||||
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
|
||||
<SelectValue placeholder="Select a file" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="select-none">
|
||||
<SelectGroup>
|
||||
{artifactOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</ArtifactTitle>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
getFileIcon,
|
||||
getFileName,
|
||||
} from "@/core/utils/files";
|
||||
import { cn, truncateMiddle } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
|
||||
|
|
@ -80,14 +80,12 @@ export function ArtifactFileList({
|
|||
onClick={() => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="pr-2 pl-1">
|
||||
<CardTitle className=" relative pl-8 overflow-hidden">
|
||||
<div className=" text-ellipsis whitespace-nowrap text-sm font-normal" title={getFileName(file)}>
|
||||
{truncateMiddle(getFileName(file), 50)}
|
||||
<CardTitle className="relative pl-8">
|
||||
<div>{getFileName(file)}</div>
|
||||
<div className="absolute top-2 -left-0.5">
|
||||
{getFileIcon(file, "size-6")}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="absolute top-5 left-3">
|
||||
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
|
||||
</div>
|
||||
<CardDescription className="pl-8 text-xs">
|
||||
{getFileExtensionDisplayName(file)} file
|
||||
</CardDescription>
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
"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,7 +59,6 @@ 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 {
|
||||
|
|
@ -82,6 +81,79 @@ 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<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({
|
||||
className,
|
||||
disabled,
|
||||
|
|
@ -125,7 +197,7 @@ export function InputBox({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const iframeSkill = useIframeSkill();
|
||||
const iframeSkill = useEmbeddedIframeSkill();
|
||||
|
||||
const params = useParams();
|
||||
const threadId = threadIdProp ?? params?.thread_id;
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
/**
|
||||
* 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, "*");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
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