style:prettier

This commit is contained in:
肖应宇 2026-04-11 21:45:48 +08:00 committed by Titan
parent 80c964de09
commit 6c618fadfb
31 changed files with 594 additions and 502 deletions

View File

@ -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({

View File

@ -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`,
);
}}
>

View File

@ -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>

View File

@ -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}

View File

@ -7,7 +7,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{

View File

@ -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;

View File

@ -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);

View File

@ -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") {

View File

@ -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>
{/* 场景 2skill 选择通信 */}
<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>
{/* 场景 2skill 选择通信 */}
<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>
{/* 场景 5is_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>}
{/* 场景 5is_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>
);
}

View File

@ -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

View File

@ -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>
);

View File

@ -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">

View File

@ -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",

View File

@ -28,9 +28,7 @@ export function WorkspaceSidebar({
<WorkspaceNavChatList />
{isSidebarOpen && <RecentChatList />}
</SidebarContent>
<SidebarFooter>
{/* <WorkspaceNavMenu /> */}
</SidebarFooter>
<SidebarFooter>{/* <WorkspaceNavMenu /> */}</SidebarFooter>
<SidebarRail />
</Sidebar>
</>

View File

@ -5,7 +5,6 @@ export interface SelectedSkillPayloadItem {
name: string;
}
export interface Translations {
// Locale meta
locale: {

View File

@ -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;

View File

@ -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(() => ({}));

View File

@ -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

View File

@ -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;

View File

@ -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],
`![${alt}](${resolved})`,
);
rewritten = rewritten.replace(match[0], `![${alt}](${resolved})`);
}
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);

View File

@ -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;

View File

@ -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,
});

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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']");

View File

@ -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();
});
});

View File

@ -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);
}
}

View File

@ -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!));

View File

@ -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;

View File

@ -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);