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