style:prettier
This commit is contained in:
parent
d9c9c8f242
commit
da2c1f2cdf
|
|
@ -9,7 +9,8 @@ import { detectLocaleServer } from "@/core/i18n/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "XClaw",
|
title: "XClaw",
|
||||||
description: "Desscriptions of XClawDesscriptions of XClawDesscriptions of XClaw",
|
description:
|
||||||
|
"Desscriptions of XClawDesscriptions of XClawDesscriptions of XClaw",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|
|
||||||
|
|
@ -62,13 +62,8 @@ export default function ChatPage() {
|
||||||
setFullscreen: setArtifactsFullscreen,
|
setFullscreen: setArtifactsFullscreen,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
const {
|
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
|
||||||
threadId,
|
useThreadChat();
|
||||||
isNewThread,
|
|
||||||
setIsNewThread,
|
|
||||||
isMock,
|
|
||||||
showWelcomeStyle,
|
|
||||||
} = useThreadChat();
|
|
||||||
|
|
||||||
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
|
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
|
||||||
const shouldRenderHistory = !showWelcomeStyle;
|
const shouldRenderHistory = !showWelcomeStyle;
|
||||||
|
|
@ -96,11 +91,12 @@ export default function ChatPage() {
|
||||||
const initializedThreadRef = useRef<string | null>(null);
|
const initializedThreadRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const currentSlogan =
|
const currentSlogan = motivationSlogans[
|
||||||
motivationSlogans[sloganIndex % motivationSlogans.length] ?? {
|
sloganIndex % motivationSlogans.length
|
||||||
text: "来,一起学习工作吧",
|
] ?? {
|
||||||
color: "#333333",
|
text: "来,一起学习工作吧",
|
||||||
};
|
color: "#333333",
|
||||||
|
};
|
||||||
const tickerCharacterList = useMemo(() => {
|
const tickerCharacterList = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const uniqueChars: string[] = [];
|
const uniqueChars: string[] = [];
|
||||||
|
|
@ -119,9 +115,12 @@ export default function ChatPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (motivationSlogans.length <= 1) return;
|
if (motivationSlogans.length <= 1) return;
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(
|
||||||
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
|
() => {
|
||||||
}, 10 * 60 * 1000);
|
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -313,7 +312,6 @@ export default function ChatPage() {
|
||||||
setIsNewThread,
|
setIsNewThread,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ threadId, thread }}>
|
<ThreadContext.Provider value={{ threadId, thread }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -420,7 +418,9 @@ export default function ChatPage() {
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 max-w-full grow flex-col",
|
"flex min-h-0 max-w-full grow flex-col",
|
||||||
showWelcomeStyle && !hasSubmitted ? "bg-white" : "bg-background",
|
showWelcomeStyle && !hasSubmitted
|
||||||
|
? "bg-white"
|
||||||
|
: "bg-background",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex size-full justify-center">
|
<div className="flex size-full justify-center">
|
||||||
|
|
@ -499,7 +499,7 @@ export default function ChatPage() {
|
||||||
</header>
|
</header>
|
||||||
<main className="min-h-0 grow overflow-auto">
|
<main className="min-h-0 grow overflow-auto">
|
||||||
<ArtifactFileList
|
<ArtifactFileList
|
||||||
className="max-w-(--container-width-sm) p-4 pt-12 mb-[207px]"
|
className="mb-[207px] max-w-(--container-width-sm) p-4 pt-12"
|
||||||
files={thread.values.artifacts ?? []}
|
files={thread.values.artifacts ?? []}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
/>
|
/>
|
||||||
|
|
@ -523,38 +523,48 @@ export default function ChatPage() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto relative w-full max-w-[720px]",
|
"pointer-events-auto relative w-full max-w-[720px]",
|
||||||
showWelcomeStyle && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
|
showWelcomeStyle &&
|
||||||
|
!hasSubmitted &&
|
||||||
|
"-translate-y-[calc(50vh-96px)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
||||||
<><InputBox
|
<>
|
||||||
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
|
<InputBox
|
||||||
threadId={threadId}
|
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
|
||||||
showWelcomeStyle={showWelcomeStyle}
|
threadId={threadId}
|
||||||
hasSubmitted={hasSubmitted}
|
showWelcomeStyle={showWelcomeStyle}
|
||||||
autoFocus={showWelcomeStyle}
|
hasSubmitted={hasSubmitted}
|
||||||
status={thread.error
|
autoFocus={showWelcomeStyle}
|
||||||
? "error"
|
status={
|
||||||
: isUploading || thread.isLoading
|
thread.error
|
||||||
? "streaming"
|
? "error"
|
||||||
: "ready"}
|
: isUploading || thread.isLoading
|
||||||
context={settings.context}
|
? "streaming"
|
||||||
extraHeader={<div className="flex flex-col gap-4">
|
: "ready"
|
||||||
{showWelcomeStyle && !hasSubmitted && (
|
}
|
||||||
<Welcome mode={settings.context.mode} />
|
context={settings.context}
|
||||||
)}
|
extraHeader={
|
||||||
</div>}
|
<div className="flex flex-col gap-4">
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
{showWelcomeStyle && !hasSubmitted && (
|
||||||
isSelectedSkillBootstrapping ||
|
<Welcome mode={settings.context.mode} />
|
||||||
isUploading ||
|
)}
|
||||||
(isNewThread && !safeThreadId)}
|
</div>
|
||||||
onContextChange={(context) => setSettings("context", context)}
|
}
|
||||||
onSubmit={handleSubmit}
|
disabled={
|
||||||
onStop={handleStop} /></>
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
|
isSelectedSkillBootstrapping ||
|
||||||
|
isUploading ||
|
||||||
|
(isNewThread && !safeThreadId)
|
||||||
|
}
|
||||||
|
onContextChange={(context) => setSettings("context", context)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onStop={handleStop}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
// <InputBoxSkeleton />
|
// <InputBoxSkeleton />
|
||||||
''
|
""
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* {isSelectedSkillBootstrapping && (
|
{/* {isSelectedSkillBootstrapping && (
|
||||||
|
|
@ -606,7 +616,9 @@ export default function ChatPage() {
|
||||||
if (threadId && threadId !== "new") {
|
if (threadId && threadId !== "new") {
|
||||||
nextQuery.set("thread_id", threadId);
|
nextQuery.set("thread_id", threadId);
|
||||||
}
|
}
|
||||||
router.replace(`/workspace/chats/${threadId}?is_chatting=false`);
|
router.replace(
|
||||||
|
`/workspace/chats/${threadId}?is_chatting=false`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
确定
|
确定
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export const ArtifactContent = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ArtifactContentProps) => (
|
}: ArtifactContentProps) => (
|
||||||
<div className="min-h-0 flex-1 overflow-auto rounded-[10px]" {...props} >
|
<div className="min-h-0 flex-1 overflow-auto rounded-[10px]" {...props}>
|
||||||
{/* <div className={cn("mb-[207px]! p-4", className)} {...props} /> */}
|
{/* <div className={cn("mb-[207px]! p-4", className)} {...props} /> */}
|
||||||
{/* <div className={cn("mb-[150px] min-h-full p-4", className)} /> */}
|
{/* <div className={cn("mb-[150px] min-h-full p-4", className)} /> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1146,8 +1146,8 @@ export const PromptInputSubmit = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
|
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
|
||||||
isDisabled
|
isDisabled
|
||||||
? "cursor-not-allowed !bg-gray-200 text-gray-400":
|
? "cursor-not-allowed !bg-gray-200 text-gray-400"
|
||||||
"!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
|
: "!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
size={size}
|
size={size}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@ export function ArtifactFileDetail({
|
||||||
const artifactViewerSrc = useMemo(() => {
|
const artifactViewerSrc = useMemo(() => {
|
||||||
return undefined;
|
return undefined;
|
||||||
}, []);
|
}, []);
|
||||||
const artifactViewerSandbox = "allow-same-origin allow-scripts allow-downloads";
|
const artifactViewerSandbox =
|
||||||
|
"allow-same-origin allow-scripts allow-downloads";
|
||||||
const { content } = useArtifactContent({
|
const { content } = useArtifactContent({
|
||||||
threadId,
|
threadId,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
|
|
@ -316,7 +317,10 @@ export function ArtifactFileDetail({
|
||||||
dirnamePosix(markdownEntryPath),
|
dirnamePosix(markdownEntryPath),
|
||||||
artifactEntryPath,
|
artifactEntryPath,
|
||||||
);
|
);
|
||||||
refToRelativeZipPath.set(ref, relativeFromMarkdown || getFileName(artifactEntryPath));
|
refToRelativeZipPath.set(
|
||||||
|
ref,
|
||||||
|
relativeFromMarkdown || getFileName(artifactEntryPath),
|
||||||
|
);
|
||||||
|
|
||||||
if (addedVirtualPaths.has(virtualPath)) continue;
|
if (addedVirtualPaths.has(virtualPath)) continue;
|
||||||
addedVirtualPaths.add(virtualPath);
|
addedVirtualPaths.add(virtualPath);
|
||||||
|
|
@ -684,7 +688,6 @@ export function ArtifactFileDetail({
|
||||||
{previewable &&
|
{previewable &&
|
||||||
viewMode === "preview" &&
|
viewMode === "preview" &&
|
||||||
(language === "markdown" || language === "html") && (
|
(language === "markdown" || language === "html") && (
|
||||||
|
|
||||||
<ArtifactFilePreview
|
<ArtifactFilePreview
|
||||||
content={displayContent}
|
content={displayContent}
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
|
|
@ -692,10 +695,9 @@ export function ArtifactFileDetail({
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
filepath={filepath}
|
filepath={filepath}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
{isCodeFile && viewMode === "code" && (
|
{isCodeFile && viewMode === "code" && (
|
||||||
<div className="min-h-full mb-[207px] rounded-b-[10px] bg-white p-0 mb-0">
|
<div className="mb-0 mb-[207px] min-h-full rounded-b-[10px] bg-white p-0">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
className="size-full resize-none rounded-none border-none py-[20px]"
|
className="size-full resize-none rounded-none border-none py-[20px]"
|
||||||
value={displayContent ?? ""}
|
value={displayContent ?? ""}
|
||||||
|
|
@ -704,16 +706,16 @@ export function ArtifactFileDetail({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCodeFile && (
|
{!isCodeFile &&
|
||||||
artifactPreviewKind === "pdf" ? (
|
(artifactPreviewKind === "pdf" ? (
|
||||||
<ArtifactPdfPreview
|
<ArtifactPdfPreview
|
||||||
className="h-full mb-[207px]"
|
className="mb-[207px] h-full"
|
||||||
artifactUrl={artifactUrl}
|
artifactUrl={artifactUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
/>
|
/>
|
||||||
) : isOfficePreviewKind(artifactPreviewKind) ? (
|
) : isOfficePreviewKind(artifactPreviewKind) ? (
|
||||||
<ArtifactOfficePreview
|
<ArtifactOfficePreview
|
||||||
className="h-full mb-[207px]"
|
className="mb-[207px] h-full"
|
||||||
kind={artifactPreviewKind}
|
kind={artifactPreviewKind}
|
||||||
artifactUrl={artifactUrl}
|
artifactUrl={artifactUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
|
|
@ -727,8 +729,7 @@ export function ArtifactFileDetail({
|
||||||
sandbox={artifactViewerSandbox}
|
sandbox={artifactViewerSandbox}
|
||||||
title={`Artifact preview: ${fileName}`}
|
title={`Artifact preview: ${fileName}`}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</ArtifactContent>
|
</ArtifactContent>
|
||||||
</Artifact>
|
</Artifact>
|
||||||
);
|
);
|
||||||
|
|
@ -820,7 +821,8 @@ function resolveReferencedVirtualPath(
|
||||||
function collectMarkdownAssetTargets(markdown: string): Set<string> {
|
function collectMarkdownAssetTargets(markdown: string): Set<string> {
|
||||||
const targets = new Set<string>();
|
const targets = new Set<string>();
|
||||||
const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
||||||
const htmlAttrRegex = /<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi;
|
const htmlAttrRegex =
|
||||||
|
/<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi;
|
||||||
|
|
||||||
for (const match of markdown.matchAll(markdownRefRegex)) {
|
for (const match of markdown.matchAll(markdownRefRegex)) {
|
||||||
const raw = match[1]?.trim();
|
const raw = match[1]?.trim();
|
||||||
|
|
@ -878,7 +880,7 @@ export function ArtifactFilePreview({
|
||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("w-full bg-white mb-[207px] p-[20px]")}
|
className={cn("mb-[207px] w-full bg-white p-[20px]")}
|
||||||
style={{ "--zoom-scale": zoomScale } as CSSProperties}
|
style={{ "--zoom-scale": zoomScale } as CSSProperties}
|
||||||
>
|
>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
|
|
@ -889,7 +891,6 @@ export function ArtifactFilePreview({
|
||||||
{normalizedContent}
|
{normalizedContent}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (language === "html") {
|
if (language === "html") {
|
||||||
|
|
@ -902,7 +903,6 @@ export function ArtifactFilePreview({
|
||||||
sandbox="allow-scripts allow-forms"
|
sandbox="allow-scripts allow-forms"
|
||||||
style={{ zoom: zoomScale }}
|
style={{ zoom: zoomScale }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,7 @@ const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
const ChatBox: React.FC<{
|
const ChatBox: React.FC<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
threadId: string | undefined;
|
threadId: string | undefined;
|
||||||
}> = ({
|
}> = ({ children, threadId }) => {
|
||||||
children,
|
|
||||||
threadId,
|
|
||||||
}) => {
|
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
export function useThreadChat() {
|
export function useThreadChat() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams<{ thread_id: string }>();
|
const params = useParams<{ thread_id: string }>();
|
||||||
|
|
@ -45,7 +44,6 @@ export function useThreadChat() {
|
||||||
return threadIdFromPathOrParams ?? "";
|
return threadIdFromPathOrParams ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
||||||
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@ export function IframeTestPanel() {
|
||||||
|
|
||||||
function handleSendSelectSkill() {
|
function handleSendSelectSkill() {
|
||||||
iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]);
|
iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]);
|
||||||
addLog("postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])");
|
addLog(
|
||||||
|
"postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSendSelectSkillArray() {
|
function handleSendSelectSkillArray() {
|
||||||
|
|
@ -187,258 +189,264 @@ export function IframeTestPanel() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && <div className="space-y-3 p-3">
|
{!collapsed && (
|
||||||
{/* 当前状态 */}
|
<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="rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="mb-1 font-semibold text-gray-500">当前状态</div>
|
||||||
<span>
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-gray-400">mode:</span>
|
<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(单个)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSendSelectSkillArray}
|
||||||
|
>
|
||||||
|
sendSelectSkill(数组)
|
||||||
|
</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=[])
|
||||||
|
</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-cyan-50 text-xs text-cyan-700 hover:bg-cyan-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
||||||
|
selectedSkills: [
|
||||||
|
{ id: "5", name: "文档处理" },
|
||||||
|
{ id: "1216", name: "市场研究报告" },
|
||||||
|
{ id: "1245", name: "市场研究报告" },
|
||||||
|
{ id: "520", name: "市场研究报告" },
|
||||||
|
{ id: "409", name: "市场研究报告" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
addLog(
|
||||||
|
"模拟宿主页 → selectedSkills [{id:'5',name:'文档处理'},{id:'1216',name:'市场研究报告'}]",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📦 模拟 selectedSkills(数组 message)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
||||||
|
selectedSkills: [],
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
addLog("模拟宿主页 → selectedSkills []");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🧹 模拟 selectedSkills(空数组)
|
||||||
|
</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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-mono font-bold",
|
"rounded px-1.5 py-0.5 text-[10px] font-medium",
|
||||||
isSkillMode ? "text-violet-600" : "text-gray-400",
|
isInIframe
|
||||||
|
? "bg-violet-100 text-violet-700"
|
||||||
|
: "bg-gray-100 text-gray-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSkillMode ? "skill ✅" : "普通"}
|
{isInIframe ? "iframe 模式" : "独立页面"}
|
||||||
</span>
|
</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(单个)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSendSelectSkillArray}
|
|
||||||
>
|
|
||||||
sendSelectSkill(数组)
|
|
||||||
</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=[])
|
|
||||||
</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-cyan-50 text-xs text-cyan-700 hover:bg-cyan-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
window.postMessage(
|
|
||||||
{
|
|
||||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
|
||||||
selectedSkills: [
|
|
||||||
{ id: "5", name: "文档处理" },
|
|
||||||
{ id: "1216", name: "市场研究报告" },
|
|
||||||
{ id: "1245", name: "市场研究报告" },
|
|
||||||
{ id: "520", name: "市场研究报告" },
|
|
||||||
{ id: "409", name: "市场研究报告" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
addLog(
|
|
||||||
"模拟宿主页 → selectedSkills [{id:'5',name:'文档处理'},{id:'1216',name:'市场研究报告'}]",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📦 模拟 selectedSkills(数组 message)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-full bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
window.postMessage(
|
|
||||||
{
|
|
||||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
|
||||||
selectedSkills: [],
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
addLog("模拟宿主页 → selectedSkills []");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🧹 模拟 selectedSkills(空数组)
|
|
||||||
</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>
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
<Button
|
||||||
|
size="sm"
|
||||||
{/* 场景 5:is_chatting */}
|
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
|
||||||
<div>
|
variant="ghost"
|
||||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
onClick={handleTestClipboardCopy}
|
||||||
⑤ is_chatting
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleSendIsChatting(true)}
|
|
||||||
>
|
|
||||||
发送 true
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleSendIsChatting(false)}
|
|
||||||
>
|
|
||||||
发送 false
|
|
||||||
</Button>
|
|
||||||
</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}
|
📋 测试复制到剪贴板
|
||||||
|
</Button>
|
||||||
|
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
|
||||||
|
{isInIframe
|
||||||
|
? "将通过 postMessage 请求父页面复制"
|
||||||
|
: "将直接调用 navigator.clipboard"}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>}
|
{/* 场景 5:is_chatting */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||||
|
⑤ is_chatting
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSendIsChatting(true)}
|
||||||
|
>
|
||||||
|
发送 true
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSendIsChatting(false)}
|
||||||
|
>
|
||||||
|
发送 false
|
||||||
|
</Button>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
|
@ -62,9 +60,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tag } from "@/components/ui/tag";
|
import { Tag } from "@/components/ui/tag";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type {
|
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
|
||||||
SelectedSkillPayloadItem,
|
|
||||||
} from "@/core/i18n/locales/types";
|
|
||||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
|
|
@ -367,7 +363,7 @@ export function InputBox({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex transition-all duration-300 ease-out",
|
"flex transition-all duration-300 ease-out",
|
||||||
!effectiveIsFocused &&
|
!effectiveIsFocused &&
|
||||||
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
|
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PromptInputTools className="min-w-0 flex-1">
|
<PromptInputTools className="min-w-0 flex-1">
|
||||||
|
|
@ -380,7 +376,7 @@ export function InputBox({
|
||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
<HistoryButton
|
<HistoryButton
|
||||||
className="px-2!"
|
className="px-2!"
|
||||||
router={router}
|
router={router}
|
||||||
threadId={threadIdFromProps}
|
threadId={threadIdFromProps}
|
||||||
|
|
@ -440,12 +436,14 @@ export function InputBox({
|
||||||
/>
|
/>
|
||||||
</PromptInput>
|
</PromptInput>
|
||||||
|
|
||||||
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
|
{showWelcomeStyle &&
|
||||||
<SuggestionListContainer
|
!hasSubmitted &&
|
||||||
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
|
searchParams.get("mode") !== "skill" && (
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
<SuggestionListContainer
|
||||||
/>
|
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
|
||||||
)}
|
isBootstrapping={iframeSkill.isBootstrapping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!disabled &&
|
{!disabled &&
|
||||||
!showWelcomeStyle &&
|
!showWelcomeStyle &&
|
||||||
|
|
@ -543,19 +541,19 @@ function SuggestionList({
|
||||||
const promptSuggestions = suggestions.filter(
|
const promptSuggestions = suggestions.filter(
|
||||||
(
|
(
|
||||||
suggestion,
|
suggestion,
|
||||||
): suggestion is Exclude<(typeof suggestions)[number], { type: "separator" }> =>
|
): suggestion is Exclude<
|
||||||
!("type" in suggestion),
|
(typeof suggestions)[number],
|
||||||
|
{ type: "separator" }
|
||||||
|
> => !("type" in suggestion),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback(
|
const handleSuggestionClick = useCallback(
|
||||||
(
|
(suggestion: {
|
||||||
suggestion: {
|
prompt: string;
|
||||||
prompt: string;
|
skill_id?: string[];
|
||||||
skill_id?: string[];
|
children?: SelectedSkillPayloadItem[];
|
||||||
children?: SelectedSkillPayloadItem[];
|
suggestion: string;
|
||||||
suggestion: string;
|
}) => {
|
||||||
},
|
|
||||||
) => {
|
|
||||||
if (isBootstrapping) return;
|
if (isBootstrapping) return;
|
||||||
|
|
||||||
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
||||||
|
|
@ -564,8 +562,9 @@ function SuggestionList({
|
||||||
id: String(item.id).trim(),
|
id: String(item.id).trim(),
|
||||||
name: item.name?.trim() ?? "",
|
name: item.name?.trim() ?? "",
|
||||||
}))
|
}))
|
||||||
.filter((item): item is { id: string; name: string } =>
|
.filter(
|
||||||
Boolean(item.id) && Boolean(item.name),
|
(item): item is { id: string; name: string } =>
|
||||||
|
Boolean(item.id) && Boolean(item.name),
|
||||||
);
|
);
|
||||||
if (childSkills.length > 0) {
|
if (childSkills.length > 0) {
|
||||||
void bootstrapAndLockSkills({
|
void bootstrapAndLockSkills({
|
||||||
|
|
@ -604,7 +603,10 @@ function SuggestionList({
|
||||||
[bootstrapAndLockSkills, isBootstrapping, textInput],
|
[bootstrapAndLockSkills, isBootstrapping, textInput],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
|
<Suggestions
|
||||||
|
className="min-h-16 w-fit items-start"
|
||||||
|
data-testid="welcome-suggestions"
|
||||||
|
>
|
||||||
{promptSuggestions.map((suggestion) => (
|
{promptSuggestions.map((suggestion) => (
|
||||||
<Suggestion
|
<Suggestion
|
||||||
key={suggestion.suggestion}
|
key={suggestion.suggestion}
|
||||||
|
|
@ -648,15 +650,34 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HistoryButton({
|
||||||
function HistoryButton({ className, router, threadId }: { className?: string; router: AppRouterInstance; threadId: string; }) {
|
className,
|
||||||
|
router,
|
||||||
|
threadId,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
router: AppRouterInstance;
|
||||||
|
threadId: string;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t.inputBox.history}>
|
<Tooltip content={t.inputBox.history}>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
||||||
onClick={() => router.replace(`/workspace/chats/${threadId}?is_chatting=true`)}>
|
onClick={() =>
|
||||||
<svg className="[&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="[&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="24px"
|
||||||
|
viewBox="0 -960 960 960"
|
||||||
|
width="24px"
|
||||||
|
fill="#1f1f1f"
|
||||||
|
>
|
||||||
|
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
|
||||||
|
</svg>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
@ -705,14 +726,17 @@ function IframeSkillDialogButton({
|
||||||
) : null}
|
) : null}
|
||||||
{!isBootstrapping && selectedSkills.length > 0 ? (
|
{!isBootstrapping && selectedSkills.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
onWheel={(event) => {
|
onWheel={(event) => {
|
||||||
if (event.deltaY === 0) return;
|
if (event.deltaY === 0) return;
|
||||||
event.currentTarget.scrollLeft += event.deltaY;
|
event.currentTarget.scrollLeft += event.deltaY;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedSkills.map((skill, index) => (
|
{selectedSkills.map((skill, index) => (
|
||||||
<Tag key={`${skill.skill_id}-${skill.title}-${index}`} className="shrink-0">
|
<Tag
|
||||||
|
key={`${skill.skill_id}-${skill.title}-${index}`}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
{skill.title}
|
{skill.title}
|
||||||
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,10 @@ export function MessageGroup({
|
||||||
return filteredSteps[filteredSteps.length - 1];
|
return filteredSteps[filteredSteps.length - 1];
|
||||||
}
|
}
|
||||||
}, [lastToolCallStep, steps]);
|
}, [lastToolCallStep, steps]);
|
||||||
const totalToolStepCount = aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
|
const totalToolStepCount =
|
||||||
const shouldShowToolSteps = !!lastToolCallStep &&
|
aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
|
||||||
(showAbove || aboveLastToolCallSteps.length === 0);
|
const shouldShowToolSteps =
|
||||||
|
!!lastToolCallStep && (showAbove || aboveLastToolCallSteps.length === 0);
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
return (
|
return (
|
||||||
<ChainOfThought
|
<ChainOfThought
|
||||||
|
|
@ -416,14 +417,15 @@ function ToolCall({
|
||||||
return t.toolCalls.executeCommand;
|
return t.toolCalls.executeCommand;
|
||||||
}
|
}
|
||||||
const command: string | undefined = (args as { command: string })?.command;
|
const command: string | undefined = (args as { command: string })?.command;
|
||||||
const shouldCollapse = !!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
|
const shouldCollapse =
|
||||||
|
!!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={id}
|
key={id}
|
||||||
label={description}
|
label={description}
|
||||||
icon={SquareTerminalIcon}
|
icon={SquareTerminalIcon}
|
||||||
action={shouldCollapse
|
action={
|
||||||
? (
|
shouldCollapse ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-7 px-3 text-xs"
|
className="h-7 px-3 text-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -436,11 +438,14 @@ function ToolCall({
|
||||||
? t.toolCalls.collapseContent
|
? t.toolCalls.collapseContent
|
||||||
: t.toolCalls.expandContent}
|
: t.toolCalls.expandContent}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
) : undefined
|
||||||
: undefined}
|
}
|
||||||
>
|
>
|
||||||
{command && (
|
{command && (
|
||||||
<ExpandableToolContent content={command} expanded={isCommandExpanded} />
|
<ExpandableToolContent
|
||||||
|
content={command}
|
||||||
|
expanded={isCommandExpanded}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,9 @@ function MessageImage({
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src;
|
src.startsWith("/mnt/") && threadId
|
||||||
|
? resolveArtifactURL(src, threadId)
|
||||||
|
: src;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
|
|
||||||
|
|
@ -210,11 +210,13 @@ export function MessageList({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{thread.isLoading && messages.length > 0 && <StreamingIndicator className="my-4" />}
|
{thread.isLoading && messages.length > 0 && (
|
||||||
|
<StreamingIndicator className="my-4" />
|
||||||
|
)}
|
||||||
<div style={{ height: `${paddingBottom}px` }} />
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
{/* showScrollToBottomButton */}
|
{/* showScrollToBottomButton */}
|
||||||
{ showScrollToBottomButton && (
|
{showScrollToBottomButton && (
|
||||||
<ConversationScrollButton
|
<ConversationScrollButton
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",
|
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,7 @@ export function WorkspaceSidebar({
|
||||||
<WorkspaceNavChatList />
|
<WorkspaceNavChatList />
|
||||||
{isSidebarOpen && <RecentChatList />}
|
{isSidebarOpen && <RecentChatList />}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>{/* <WorkspaceNavMenu /> */}</SidebarFooter>
|
||||||
{/* <WorkspaceNavMenu /> */}
|
|
||||||
</SidebarFooter>
|
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ export interface SelectedSkillPayloadItem {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
// Locale meta
|
// Locale meta
|
||||||
locale: {
|
locale: {
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,9 @@ function asRecord(value: unknown): UnknownRecord | null {
|
||||||
return value as UnknownRecord;
|
return value as UnknownRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMessage {
|
export function isSelectedSkillMessage(
|
||||||
|
value: unknown,
|
||||||
|
): value is SelectedSkillMessage {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -89,7 +91,9 @@ export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMe
|
||||||
return isValidId && typeof title === "string" && title.trim().length > 0;
|
return isValidId && typeof title === "string" && title.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSelectedSkillsMessage(value: unknown): value is SelectSkillMessage {
|
export function isSelectedSkillsMessage(
|
||||||
|
value: unknown,
|
||||||
|
): value is SelectSkillMessage {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) {
|
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,10 @@ export async function materializeSkillYaml(
|
||||||
): Promise<MaterializeSkillYamlResponse> {
|
): Promise<MaterializeSkillYamlResponse> {
|
||||||
console.log("[skills/api] ========== materializeSkillYaml START ==========");
|
console.log("[skills/api] ========== materializeSkillYaml START ==========");
|
||||||
console.log("[skills/api] request:", JSON.stringify(request, null, 2));
|
console.log("[skills/api] request:", JSON.stringify(request, null, 2));
|
||||||
console.log("[skills/api] API URL:", `${getBackendBaseURL()}/api/skills/materialize-yaml`);
|
console.log(
|
||||||
|
"[skills/api] API URL:",
|
||||||
|
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
||||||
|
|
@ -114,7 +117,11 @@ export async function materializeSkillYaml(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[skills/api] response status:", response.status, response.statusText);
|
console.log(
|
||||||
|
"[skills/api] response status:",
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,10 @@ export function useThreadStream({
|
||||||
// and to allow access to the current thread id in onUpdateEvent
|
// and to allow access to the current thread id in onUpdateEvent
|
||||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const lastErrorToastRef = useRef<{ message: string; timestamp: number } | null>(
|
const lastErrorToastRef = useRef<{
|
||||||
null,
|
message: string;
|
||||||
);
|
timestamp: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
onStart,
|
onStart,
|
||||||
|
|
@ -454,8 +455,14 @@ export function useThreadStream({
|
||||||
try {
|
try {
|
||||||
// 新会话模式下,仅在本地已有历史消息时才重置旧线程。
|
// 新会话模式下,仅在本地已有历史消息时才重置旧线程。
|
||||||
// 对于全新 thread_id,避免多发一次 DELETE /threads/{id}(通常会 404)。
|
// 对于全新 thread_id,避免多发一次 DELETE /threads/{id}(通常会 404)。
|
||||||
if (createNewSession && resolvedThreadId && thread.messages.length > 0) {
|
if (
|
||||||
await apiClient.threads.delete(resolvedThreadId).catch(() => undefined);
|
createNewSession &&
|
||||||
|
resolvedThreadId &&
|
||||||
|
thread.messages.length > 0
|
||||||
|
) {
|
||||||
|
await apiClient.threads
|
||||||
|
.delete(resolvedThreadId)
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload files first if any
|
// Upload files first if any
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ function normalizeThreadId(value?: string | null): string | undefined {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function textOfMessage(message: Message) {
|
export function textOfMessage(message: Message) {
|
||||||
if (typeof message.content === "string") {
|
if (typeof message.content === "string") {
|
||||||
return message.content;
|
return message.content;
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,9 @@ export async function downloadMarkdownAsPdf(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
options: PdfOptions & {
|
options: PdfOptions & {
|
||||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
resolveAssetUrl?: (
|
||||||
|
rawPath: string,
|
||||||
|
) => string | null | Promise<string | null>;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const html2pdf = await loadHtml2Pdf();
|
const html2pdf = await loadHtml2Pdf();
|
||||||
|
|
@ -520,7 +522,10 @@ async function createImageRunFromToken(
|
||||||
const rawHref = String(token?.href ?? token?.text ?? "").trim();
|
const rawHref = String(token?.href ?? token?.text ?? "").trim();
|
||||||
if (!rawHref) return null;
|
if (!rawHref) return null;
|
||||||
|
|
||||||
const resolvedUrl = await resolveAssetReference(rawHref, options.resolveAssetUrl);
|
const resolvedUrl = await resolveAssetReference(
|
||||||
|
rawHref,
|
||||||
|
options.resolveAssetUrl,
|
||||||
|
);
|
||||||
if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) {
|
if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -587,13 +592,12 @@ async function rewriteMarkdownImageSources(
|
||||||
const rawTarget = match[2]?.trim() ?? "";
|
const rawTarget = match[2]?.trim() ?? "";
|
||||||
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
|
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
|
||||||
if (!resolved || resolved === rawTarget) continue;
|
if (!resolved || resolved === rawTarget) continue;
|
||||||
rewritten = rewritten.replace(
|
rewritten = rewritten.replace(match[0], ``);
|
||||||
match[0],
|
|
||||||
``,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlMatches = [...rewritten.matchAll(/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi)];
|
const htmlMatches = [
|
||||||
|
...rewritten.matchAll(/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi),
|
||||||
|
];
|
||||||
for (const match of htmlMatches) {
|
for (const match of htmlMatches) {
|
||||||
const rawTarget = match[3]?.trim() ?? "";
|
const rawTarget = match[3]?.trim() ?? "";
|
||||||
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
|
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ export interface UseMarkdownDownloadReturn {
|
||||||
markdown: string,
|
markdown: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
options?: PdfOptions & {
|
options?: PdfOptions & {
|
||||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
resolveAssetUrl?: (
|
||||||
|
rawPath: string,
|
||||||
|
) => string | null | Promise<string | null>;
|
||||||
},
|
},
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,7 +125,9 @@ export function useMarkdownDownload(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
options?: PdfOptions & {
|
options?: PdfOptions & {
|
||||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
resolveAssetUrl?: (
|
||||||
|
rawPath: string,
|
||||||
|
) => string | null | Promise<string | null>;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (isDownloading) return;
|
if (isDownloading) return;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ function parseStoredSkills(raw: string | null): SkillData[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSkillsByIdsFromList(skills: SkillData[], skillIds: string[]): SkillData[] {
|
function removeSkillsByIdsFromList(
|
||||||
|
skills: SkillData[],
|
||||||
|
skillIds: string[],
|
||||||
|
): SkillData[] {
|
||||||
if (skillIds.length === 0) return skills;
|
if (skillIds.length === 0) return skills;
|
||||||
const idSet = new Set(skillIds.map((id) => String(id)));
|
const idSet = new Set(skillIds.map((id) => String(id)));
|
||||||
return skills.filter((skill) => !idSet.has(String(skill.skill_id)));
|
return skills.filter((skill) => !idSet.has(String(skill.skill_id)));
|
||||||
|
|
@ -102,7 +105,10 @@ export function useIframeSkill(
|
||||||
const latestSkills = parseStoredSkills(
|
const latestSkills = parseStoredSkills(
|
||||||
window.localStorage.getItem(STORAGE_KEYS.latest),
|
window.localStorage.getItem(STORAGE_KEYS.latest),
|
||||||
);
|
);
|
||||||
const nextLatestSkills = removeSkillsByIdsFromList(latestSkills, skillIds);
|
const nextLatestSkills = removeSkillsByIdsFromList(
|
||||||
|
latestSkills,
|
||||||
|
skillIds,
|
||||||
|
);
|
||||||
if (nextLatestSkills.length > 0) {
|
if (nextLatestSkills.length > 0) {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
STORAGE_KEYS.latest,
|
STORAGE_KEYS.latest,
|
||||||
|
|
@ -114,10 +120,18 @@ export function useIframeSkill(
|
||||||
|
|
||||||
const threadKey = getThreadStorageKey(threadId);
|
const threadKey = getThreadStorageKey(threadId);
|
||||||
if (threadKey) {
|
if (threadKey) {
|
||||||
const threadSkills = parseStoredSkills(window.localStorage.getItem(threadKey));
|
const threadSkills = parseStoredSkills(
|
||||||
const nextThreadSkills = removeSkillsByIdsFromList(threadSkills, skillIds);
|
window.localStorage.getItem(threadKey),
|
||||||
|
);
|
||||||
|
const nextThreadSkills = removeSkillsByIdsFromList(
|
||||||
|
threadSkills,
|
||||||
|
skillIds,
|
||||||
|
);
|
||||||
if (nextThreadSkills.length > 0) {
|
if (nextThreadSkills.length > 0) {
|
||||||
window.localStorage.setItem(threadKey, JSON.stringify(nextThreadSkills));
|
window.localStorage.setItem(
|
||||||
|
threadKey,
|
||||||
|
JSON.stringify(nextThreadSkills),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(threadKey);
|
window.localStorage.removeItem(threadKey);
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +184,10 @@ export function useIframeSkill(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
|
console.warn(
|
||||||
|
"[useIframeSkill] 忽略非法 selectedSkill 消息",
|
||||||
|
event.data,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("message", handleMessage);
|
window.addEventListener("message", handleMessage);
|
||||||
|
|
@ -186,7 +203,8 @@ export function useIframeSkill(
|
||||||
const latestSkills = parseStoredSkills(
|
const latestSkills = parseStoredSkills(
|
||||||
window.localStorage.getItem(STORAGE_KEYS.latest),
|
window.localStorage.getItem(STORAGE_KEYS.latest),
|
||||||
);
|
);
|
||||||
const restoredSkills = threadSkills.length > 0 ? threadSkills : latestSkills;
|
const restoredSkills =
|
||||||
|
threadSkills.length > 0 ? threadSkills : latestSkills;
|
||||||
if (restoredSkills.length === 0) return;
|
if (restoredSkills.length === 0) return;
|
||||||
setSelectedSkills(restoredSkills);
|
setSelectedSkills(restoredSkills);
|
||||||
setSelectedSkill(restoredSkills[0] ?? null);
|
setSelectedSkill(restoredSkills[0] ?? null);
|
||||||
|
|
@ -212,11 +230,17 @@ export function useIframeSkill(
|
||||||
}, [selectedSkills, threadId]);
|
}, [selectedSkills, threadId]);
|
||||||
|
|
||||||
// 发送选择预定义 skill
|
// 发送选择预定义 skill
|
||||||
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
|
const sendSelectSkill = useCallback(
|
||||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
|
(selectedSkills: SelectedSkillPayloadItem[]) => {
|
||||||
console.log("[useIframeSkill] sendSelectSkill:", message);
|
const message = {
|
||||||
sendToParent(message);
|
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
||||||
}, []);
|
selectedSkills,
|
||||||
|
};
|
||||||
|
console.log("[useIframeSkill] sendSelectSkill:", message);
|
||||||
|
sendToParent(message);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const bootstrapAndLockSkills = useCallback(
|
const bootstrapAndLockSkills = useCallback(
|
||||||
async ({
|
async ({
|
||||||
|
|
@ -254,7 +278,9 @@ export function useIframeSkill(
|
||||||
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
||||||
|
|
||||||
setIsBootstrapping(true);
|
setIsBootstrapping(true);
|
||||||
toast.loading(`正在加载技能「${title}」...`, { id: "suggest-skill-bootstrap" });
|
toast.loading(`正在加载技能「${title}」...`, {
|
||||||
|
id: "suggest-skill-bootstrap",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await bootstrapRemoteSkill({
|
const result = await bootstrapRemoteSkill({
|
||||||
|
|
@ -268,7 +294,9 @@ export function useIframeSkill(
|
||||||
toast.dismiss("suggest-skill-bootstrap");
|
toast.dismiss("suggest-skill-bootstrap");
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const failedIds = selectedSkills.map((item) => String(item.id).trim());
|
const failedIds = selectedSkills.map((item) =>
|
||||||
|
String(item.id).trim(),
|
||||||
|
);
|
||||||
removeFailedSkills(failedIds);
|
removeFailedSkills(failedIds);
|
||||||
toast.error(`技能「${title}」加载失败`, {
|
toast.error(`技能「${title}」加载失败`, {
|
||||||
description: result.message || "未知错误",
|
description: result.message || "未知错误",
|
||||||
|
|
@ -285,7 +313,8 @@ export function useIframeSkill(
|
||||||
setSelectedSkills(normalizedSkills);
|
setSelectedSkills(normalizedSkills);
|
||||||
|
|
||||||
toast.success(`技能「${title}」加载成功`, {
|
toast.success(`技能「${title}」加载成功`, {
|
||||||
description: result.message || `已创建 ${result.created_files} 个文件`,
|
description:
|
||||||
|
result.message || `已创建 ${result.created_files} 个文件`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -293,8 +322,7 @@ export function useIframeSkill(
|
||||||
const failedIds = selectedSkills.map((item) => String(item.id).trim());
|
const failedIds = selectedSkills.map((item) => String(item.id).trim());
|
||||||
removeFailedSkills(failedIds);
|
removeFailedSkills(failedIds);
|
||||||
toast.dismiss("suggest-skill-bootstrap");
|
toast.dismiss("suggest-skill-bootstrap");
|
||||||
const message =
|
const message = error instanceof Error ? error.message : "网络请求失败";
|
||||||
error instanceof Error ? error.message : "网络请求失败";
|
|
||||||
toast.error(`技能「${title}」加载失败`, {
|
toast.error(`技能「${title}」加载失败`, {
|
||||||
description: message,
|
description: message,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
}
|
}
|
||||||
/* Chrome, Safari, Opera */
|
/* Chrome, Safari, Opera */
|
||||||
/* *::-webkit-scrollbar {
|
/* *::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
} */
|
} */
|
||||||
|
|
@ -413,8 +413,9 @@
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", ui-sans-serif,
|
font-family:
|
||||||
system-ui, sans-serif;
|
"Microsoft YaHei", "微软雅黑", "PingFang SC", ui-sans-serif, system-ui,
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
|
|
@ -437,7 +438,8 @@ code,
|
||||||
kbd,
|
kbd,
|
||||||
samp,
|
samp,
|
||||||
pre {
|
pre {
|
||||||
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
|
font-family:
|
||||||
|
"Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表项 - 14px */
|
/* 列表项 - 14px */
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,9 @@ async function waitForArtifactsReady(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await openButton.click();
|
await openButton.click();
|
||||||
await expect(page.getByTestId("artifact-file-list").first()).toBeVisible({
|
await expect(
|
||||||
|
page.getByTestId("artifact-file-list").first(),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -144,11 +146,14 @@ async function waitForArtifactsReady(
|
||||||
}
|
}
|
||||||
|
|
||||||
function artifactCardByPattern(page: Page, pattern: RegExp): Locator {
|
function artifactCardByPattern(page: Page, pattern: RegExp): Locator {
|
||||||
return page.locator("[data-testid='artifact-file-card']").filter({
|
return page
|
||||||
has: page.locator("[data-slot='card-title'] div[title]").filter({
|
.locator("[data-testid='artifact-file-card']")
|
||||||
hasText: pattern,
|
.filter({
|
||||||
}),
|
has: page.locator("[data-slot='card-title'] div[title]").filter({
|
||||||
}).first();
|
hasText: pattern,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
|
async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
|
||||||
|
|
@ -165,7 +170,9 @@ async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === "pptx") {
|
if (kind === "pptx") {
|
||||||
await expect(page.getByText("请下载ppt文件以获得最佳效果").first()).toBeVisible({
|
await expect(
|
||||||
|
page.getByText("请下载ppt文件以获得最佳效果").first(),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -180,7 +187,11 @@ async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
async function capture(
|
||||||
|
page: Page,
|
||||||
|
testInfo: TestInfo,
|
||||||
|
name: string,
|
||||||
|
): Promise<void> {
|
||||||
const path = testInfo.outputPath(`${name}.png`);
|
const path = testInfo.outputPath(`${name}.png`);
|
||||||
await page.screenshot({ path, fullPage: true });
|
await page.screenshot({ path, fullPage: true });
|
||||||
await testInfo.attach(name, {
|
await testInfo.attach(name, {
|
||||||
|
|
@ -215,7 +226,9 @@ async function getArtifactFileNames(page: Page): Promise<string[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback: if title attr is absent, use first text line of each card
|
// fallback: if title attr is absent, use first text line of each card
|
||||||
const cardTexts = await page.getByTestId("artifact-file-card").allTextContents();
|
const cardTexts = await page
|
||||||
|
.getByTestId("artifact-file-card")
|
||||||
|
.allTextContents();
|
||||||
return cardTexts
|
return cardTexts
|
||||||
.map((text) => text.split("\n")[0]?.trim() ?? "")
|
.map((text) => text.split("\n")[0]?.trim() ?? "")
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
|
|
||||||
await page.getByTestId("artifacts-open-button").click();
|
await page.getByTestId("artifacts-open-button").click();
|
||||||
const imageFile = page
|
const imageFile = page
|
||||||
.locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']")
|
.locator(
|
||||||
|
"[data-testid='artifact-file-list'] [data-testid='artifact-file-card']",
|
||||||
|
)
|
||||||
.filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i })
|
.filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i })
|
||||||
.first();
|
.first();
|
||||||
testInfo.skip(
|
testInfo.skip(
|
||||||
|
|
@ -82,7 +84,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
|
|
||||||
await page.getByTestId("artifacts-open-button").click();
|
await page.getByTestId("artifacts-open-button").click();
|
||||||
const htmlFile = page
|
const htmlFile = page
|
||||||
.locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']")
|
.locator(
|
||||||
|
"[data-testid='artifact-file-list'] [data-testid='artifact-file-card']",
|
||||||
|
)
|
||||||
.filter({ hasText: /\.html?/i })
|
.filter({ hasText: /\.html?/i })
|
||||||
.first();
|
.first();
|
||||||
testInfo.skip(
|
testInfo.skip(
|
||||||
|
|
@ -102,9 +106,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
await htmlFile.click();
|
await htmlFile.click();
|
||||||
|
|
||||||
const htmlArtifactResponse = await htmlArtifactResponsePromise;
|
const htmlArtifactResponse = await htmlArtifactResponsePromise;
|
||||||
expect(htmlArtifactResponse.headers()["content-disposition"] ?? "").toContain(
|
expect(
|
||||||
"attachment;",
|
htmlArtifactResponse.headers()["content-disposition"] ?? "",
|
||||||
);
|
).toContain("attachment;");
|
||||||
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
|
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -155,10 +159,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
await page.getByTestId("artifacts-open-button").click();
|
await page.getByTestId("artifacts-open-button").click();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(async () => await page.getByTestId("artifact-file-card").count(), {
|
||||||
async () => await page.getByTestId("artifact-file-card").count(),
|
timeout: 30_000,
|
||||||
{ timeout: 30_000 },
|
})
|
||||||
)
|
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,7 @@ import {
|
||||||
|
|
||||||
test.describe("聊天工作台 / 输入区与发送", () => {
|
test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
|
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -32,11 +28,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
|
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -52,18 +44,17 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
|
await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
|
||||||
await expect.poll(inputHeight).toBeGreaterThan(120);
|
await expect.poll(inputHeight).toBeGreaterThan(120);
|
||||||
|
|
||||||
await page.getByRole("main").first().click({ position: { x: 20, y: 20 } });
|
await page
|
||||||
|
.getByRole("main")
|
||||||
|
.first()
|
||||||
|
.click({ position: { x: 20, y: 20 } });
|
||||||
await expect.poll(inputHeight).toBeLessThan(110);
|
await expect.poll(inputHeight).toBeLessThan(110);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
|
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const suggestions = page.getByTestId("welcome-suggestions");
|
const suggestions = page.getByTestId("welcome-suggestions");
|
||||||
|
|
@ -76,11 +67,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => {
|
test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -95,11 +82,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
|
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -119,11 +102,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
|
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import {
|
||||||
waitForMessageListReady,
|
waitForMessageListReady,
|
||||||
} from "./support/chat-helpers";
|
} from "./support/chat-helpers";
|
||||||
|
|
||||||
async function waitForAnyMessages(page: Parameters<typeof openChat>[0], timeoutMs = 15_000) {
|
async function waitForAnyMessages(
|
||||||
|
page: Parameters<typeof openChat>[0],
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const count = await page.locator(".is-user, .is-assistant").count();
|
const count = await page.locator(".is-user, .is-assistant").count();
|
||||||
|
|
@ -76,7 +79,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
||||||
const target = element as HTMLElement;
|
const target = element as HTMLElement;
|
||||||
return target.scrollHeight - target.clientHeight > 20;
|
return target.scrollHeight - target.clientHeight > 20;
|
||||||
});
|
});
|
||||||
testInfo.skip(canScroll === false, "当前线程消息区高度不足,无法触发滚动到底部按钮。");
|
testInfo.skip(
|
||||||
|
canScroll === false,
|
||||||
|
"当前线程消息区高度不足,无法触发滚动到底部按钮。",
|
||||||
|
);
|
||||||
|
|
||||||
await messageLog.hover();
|
await messageLog.hover();
|
||||||
await page.mouse.wheel(0, -1200);
|
await page.mouse.wheel(0, -1200);
|
||||||
|
|
@ -115,7 +121,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
expect(afterUsers.length).toBe(beforeUsers.length);
|
expect(afterUsers.length).toBe(beforeUsers.length);
|
||||||
for (const sample of beforeUsers.slice(0, Math.min(3, beforeUsers.length))) {
|
for (const sample of beforeUsers.slice(
|
||||||
|
0,
|
||||||
|
Math.min(3, beforeUsers.length),
|
||||||
|
)) {
|
||||||
expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy();
|
expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +141,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
||||||
await waitForMessageListReady(page, { requireMessages: false });
|
await waitForMessageListReady(page, { requireMessages: false });
|
||||||
|
|
||||||
const todoButton = page.getByRole("button", { name: /To-dos/i });
|
const todoButton = page.getByRole("button", { name: /To-dos/i });
|
||||||
testInfo.skip((await todoButton.count()) === 0, "当前线程未展示 To-dos 入口。");
|
testInfo.skip(
|
||||||
|
(await todoButton.count()) === 0,
|
||||||
|
"当前线程未展示 To-dos 入口。",
|
||||||
|
);
|
||||||
await expect(todoButton).toBeVisible();
|
await expect(todoButton).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,13 @@ function envThread(name: string) {
|
||||||
export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined;
|
export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined;
|
||||||
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
|
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
|
||||||
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
|
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
|
||||||
export const THREAD_WITH_MARKDOWN = envThread("FRONTEND_E2E_MARKDOWN_THREAD_ID");
|
export const THREAD_WITH_MARKDOWN = envThread(
|
||||||
|
"FRONTEND_E2E_MARKDOWN_THREAD_ID",
|
||||||
|
);
|
||||||
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
|
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
|
||||||
export const THREAD_WITH_ARTIFACTS = envThread("FRONTEND_E2E_ARTIFACTS_THREAD_ID");
|
export const THREAD_WITH_ARTIFACTS = envThread(
|
||||||
|
"FRONTEND_E2E_ARTIFACTS_THREAD_ID",
|
||||||
|
);
|
||||||
export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
|
export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
|
||||||
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
|
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
|
||||||
);
|
);
|
||||||
|
|
@ -108,10 +112,9 @@ export async function waitForMessageListReady(
|
||||||
await expect(page.getByRole("main").first()).toBeVisible();
|
await expect(page.getByRole("main").first()).toBeVisible();
|
||||||
if (requireMessages) {
|
if (requireMessages) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(async () => await page.locator(".is-user, .is-assistant").count(), {
|
||||||
async () => await page.locator(".is-user, .is-assistant").count(),
|
timeout: 30_000,
|
||||||
{ timeout: 30_000 },
|
})
|
||||||
)
|
|
||||||
.toBeGreaterThan(minMessages - 1);
|
.toBeGreaterThan(minMessages - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ test.use({
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("聊天工作台 / 错误提示", () => {
|
test.describe("聊天工作台 / 错误提示", () => {
|
||||||
test("DF-ERR-001 对话流失败时显示错误 toast", async ({
|
test("DF-ERR-001 对话流失败时显示错误 toast", async ({ page }, testInfo) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
|
||||||
|
|
||||||
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
|
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ test.use({
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("线程路由(无 isnew)", () => {
|
test.describe("线程路由(无 isnew)", () => {
|
||||||
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({ page }, testInfo) => {
|
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
@ -33,7 +35,9 @@ test.describe("线程路由(无 isnew)", () => {
|
||||||
const messageCount = await waitForAnyMessages(page);
|
const messageCount = await waitForAnyMessages(page);
|
||||||
testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。");
|
testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。");
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`));
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`),
|
||||||
|
);
|
||||||
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
|
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -62,12 +66,13 @@ test.describe("线程路由(无 isnew)", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await sendMessage(page, text);
|
await sendMessage(page, text);
|
||||||
await expect(page.locator(".is-user").filter({ hasText: text })).toHaveCount(1);
|
await expect(
|
||||||
|
page.locator(".is-user").filter({ hasText: text }),
|
||||||
|
).toHaveCount(1);
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(async () => await page.locator(".is-assistant").count(), {
|
||||||
async () => await page.locator(".is-assistant").count(),
|
timeout: 30_000,
|
||||||
{ timeout: 30_000 },
|
})
|
||||||
)
|
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
const streamRequest = await streamRequestPromise;
|
const streamRequest = await streamRequestPromise;
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
|
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const suggestions = page.getByTestId("welcome-suggestions");
|
const suggestions = page.getByTestId("welcome-suggestions");
|
||||||
|
|
@ -31,11 +27,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
|
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
|
|
@ -45,9 +37,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({
|
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto(invalidNewChatUrl());
|
await page.goto(invalidNewChatUrl());
|
||||||
|
|
||||||
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
||||||
|
|
@ -57,11 +47,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
|
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
await waitForMessageListReady(page);
|
await waitForMessageListReady(page);
|
||||||
|
|
||||||
|
|
@ -76,11 +62,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
|
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
await waitForMessageListReady(page);
|
await waitForMessageListReady(page);
|
||||||
|
|
||||||
|
|
@ -96,11 +78,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
|
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
testInfo,
|
|
||||||
THREAD_FOR_WELCOME,
|
|
||||||
"FRONTEND_E2E_THREAD_ID",
|
|
||||||
);
|
|
||||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
await waitForMessageListReady(page);
|
await waitForMessageListReady(page);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue