Merge branch 'feat/git-main-frondend-intergretion-oldhash-20260408-165134' of https://git.xueai.art/skills/deerflow2 into feat/git-main-frondend-intergretion-oldhash-20260408-165134

This commit is contained in:
肖应宇 2026-04-13 10:46:22 +08:00
commit ff5075e519
33 changed files with 616 additions and 502 deletions

View File

@ -9,7 +9,8 @@ import { detectLocaleServer } from "@/core/i18n/server";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "XClaw", title: "XClaw",
description: "Desscriptions of XClawDesscriptions of XClawDesscriptions of XClaw", description:
"Desscriptions of XClawDesscriptions of XClawDesscriptions of XClaw",
}; };
export default async function RootLayout({ export default async function RootLayout({

View File

@ -62,13 +62,8 @@ export default function ChatPage() {
setFullscreen: setArtifactsFullscreen, setFullscreen: setArtifactsFullscreen,
fullscreen, fullscreen,
} = useArtifacts(); } = useArtifacts();
const { const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
threadId, useThreadChat();
isNewThread,
setIsNewThread,
isMock,
showWelcomeStyle,
} = useThreadChat();
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。 // 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
const shouldRenderHistory = !showWelcomeStyle; const shouldRenderHistory = !showWelcomeStyle;
@ -96,8 +91,9 @@ export default function ChatPage() {
const initializedThreadRef = useRef<string | null>(null); const initializedThreadRef = useRef<string | null>(null);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const currentSlogan = const currentSlogan = motivationSlogans[
motivationSlogans[sloganIndex % motivationSlogans.length] ?? { sloganIndex % motivationSlogans.length
] ?? {
text: "来,一起学习工作吧", text: "来,一起学习工作吧",
color: "#333333", color: "#333333",
}; };
@ -119,9 +115,12 @@ export default function ChatPage() {
useEffect(() => { useEffect(() => {
if (motivationSlogans.length <= 1) return; if (motivationSlogans.length <= 1) return;
const timer = window.setInterval(() => { const timer = window.setInterval(
() => {
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length); setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
}, 10 * 60 * 1000); },
10 * 60 * 1000,
);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
@ -313,7 +312,6 @@ export default function ChatPage() {
setIsNewThread, setIsNewThread,
]); ]);
return ( return (
<ThreadContext.Provider value={{ threadId, thread }}> <ThreadContext.Provider value={{ threadId, thread }}>
<div <div
@ -420,7 +418,9 @@ export default function ChatPage() {
<main <main
className={cn( className={cn(
"flex min-h-0 max-w-full grow flex-col", "flex min-h-0 max-w-full grow flex-col",
showWelcomeStyle && !hasSubmitted ? "bg-white" : "bg-background", showWelcomeStyle && !hasSubmitted
? "bg-white"
: "bg-background",
)} )}
> >
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
@ -499,7 +499,7 @@ export default function ChatPage() {
</header> </header>
<main className="min-h-0 grow overflow-auto"> <main className="min-h-0 grow overflow-auto">
<ArtifactFileList <ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12 mb-[207px]" className="mb-[207px] max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []} files={thread.values.artifacts ?? []}
threadId={threadId} threadId={threadId}
/> />
@ -523,10 +523,13 @@ export default function ChatPage() {
<div <div
className={cn( className={cn(
"pointer-events-auto relative w-full max-w-[720px]", "pointer-events-auto relative w-full max-w-[720px]",
showWelcomeStyle && !hasSubmitted && "-translate-y-[calc(50vh-96px)]", showWelcomeStyle &&
!hasSubmitted &&
"-translate-y-[calc(50vh-96px)]",
)} )}
> >
{!(showWelcomeStyle && thread.isThreadLoading) ? ( {!(showWelcomeStyle && thread.isThreadLoading) ? (
<>
<InputBox <InputBox
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")} className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
threadId={threadId} threadId={threadId}
@ -558,9 +561,10 @@ export default function ChatPage() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
/> />
</>
) : ( ) : (
// <InputBoxSkeleton /> // <InputBoxSkeleton />
'' ""
)} )}
{/* {isSelectedSkillBootstrapping && ( {/* {isSelectedSkillBootstrapping && (
@ -583,7 +587,7 @@ export default function ChatPage() {
<DevDialogTitle></DevDialogTitle> <DevDialogTitle></DevDialogTitle>
</DevDialogHeader> </DevDialogHeader>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
退
</p> </p>
<DevDialogFooter> <DevDialogFooter>
<Button <Button
@ -612,7 +616,9 @@ export default function ChatPage() {
if (threadId && threadId !== "new") { if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId); nextQuery.set("thread_id", threadId);
} }
router.replace(`/workspace/chats/${threadId}?is_chatting=false`); router.replace(
`/workspace/chats/${threadId}?is_chatting=false`,
);
}} }}
> >

View File

@ -140,7 +140,7 @@ export const ArtifactContent = ({
className, className,
...props ...props
}: ArtifactContentProps) => ( }: ArtifactContentProps) => (
<div className="min-h-0 flex-1 overflow-auto rounded-[10px]" {...props} > <div className="min-h-0 flex-1 overflow-auto rounded-[10px]" {...props}>
{/* <div className={cn("mb-[207px]! p-4", className)} {...props} /> */} {/* <div className={cn("mb-[207px]! p-4", className)} {...props} /> */}
{/* <div className={cn("mb-[150px] min-h-full p-4", className)} /> */} {/* <div className={cn("mb-[150px] min-h-full p-4", className)} /> */}
</div> </div>

View File

@ -1146,8 +1146,8 @@ export const PromptInputSubmit = ({
className={cn( className={cn(
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all", "h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
isDisabled isDisabled
? "cursor-not-allowed !bg-gray-200 text-gray-400": ? "cursor-not-allowed !bg-gray-200 text-gray-400"
"!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]", : "!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
className, className,
)} )}
size={size} size={size}

View File

@ -216,7 +216,8 @@ export function ArtifactFileDetail({
const artifactViewerSrc = useMemo(() => { const artifactViewerSrc = useMemo(() => {
return undefined; return undefined;
}, []); }, []);
const artifactViewerSandbox = "allow-same-origin allow-scripts allow-downloads"; const artifactViewerSandbox =
"allow-same-origin allow-scripts allow-downloads";
const { content } = useArtifactContent({ const { content } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
@ -316,7 +317,10 @@ export function ArtifactFileDetail({
dirnamePosix(markdownEntryPath), dirnamePosix(markdownEntryPath),
artifactEntryPath, artifactEntryPath,
); );
refToRelativeZipPath.set(ref, relativeFromMarkdown || getFileName(artifactEntryPath)); refToRelativeZipPath.set(
ref,
relativeFromMarkdown || getFileName(artifactEntryPath),
);
if (addedVirtualPaths.has(virtualPath)) continue; if (addedVirtualPaths.has(virtualPath)) continue;
addedVirtualPaths.add(virtualPath); addedVirtualPaths.add(virtualPath);
@ -684,7 +688,6 @@ export function ArtifactFileDetail({
{previewable && {previewable &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
@ -692,10 +695,9 @@ export function ArtifactFileDetail({
threadId={threadId} threadId={threadId}
filepath={filepath} filepath={filepath}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<div className="min-h-full mb-[207px] rounded-b-[10px] bg-white p-0 mb-0"> <div className="mb-0 mb-[207px] min-h-full rounded-b-[10px] bg-white p-0">
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none py-[20px]" className="size-full resize-none rounded-none border-none py-[20px]"
value={displayContent ?? ""} value={displayContent ?? ""}
@ -704,16 +706,16 @@ export function ArtifactFileDetail({
/> />
</div> </div>
)} )}
{!isCodeFile && ( {!isCodeFile &&
artifactPreviewKind === "pdf" ? ( (artifactPreviewKind === "pdf" ? (
<ArtifactPdfPreview <ArtifactPdfPreview
className="h-full mb-[207px]" className="mb-[207px] h-full"
artifactUrl={artifactUrl} artifactUrl={artifactUrl}
fileName={fileName} fileName={fileName}
/> />
) : isOfficePreviewKind(artifactPreviewKind) ? ( ) : isOfficePreviewKind(artifactPreviewKind) ? (
<ArtifactOfficePreview <ArtifactOfficePreview
className="h-full mb-[207px]" className="mb-[207px] h-full"
kind={artifactPreviewKind} kind={artifactPreviewKind}
artifactUrl={artifactUrl} artifactUrl={artifactUrl}
fileName={fileName} fileName={fileName}
@ -727,8 +729,7 @@ export function ArtifactFileDetail({
sandbox={artifactViewerSandbox} sandbox={artifactViewerSandbox}
title={`Artifact preview: ${fileName}`} title={`Artifact preview: ${fileName}`}
/> />
) ))}
)}
</ArtifactContent> </ArtifactContent>
</Artifact> </Artifact>
); );
@ -820,7 +821,8 @@ function resolveReferencedVirtualPath(
function collectMarkdownAssetTargets(markdown: string): Set<string> { function collectMarkdownAssetTargets(markdown: string): Set<string> {
const targets = new Set<string>(); const targets = new Set<string>();
const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g; const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
const htmlAttrRegex = /<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi; const htmlAttrRegex =
/<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi;
for (const match of markdown.matchAll(markdownRefRegex)) { for (const match of markdown.matchAll(markdownRefRegex)) {
const raw = match[1]?.trim(); const raw = match[1]?.trim();
@ -878,7 +880,7 @@ export function ArtifactFilePreview({
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div <div
className={cn("w-full bg-white mb-[207px] p-[20px]")} className={cn("mb-[207px] w-full bg-white p-[20px]")}
style={{ "--zoom-scale": zoomScale } as CSSProperties} style={{ "--zoom-scale": zoomScale } as CSSProperties}
> >
<Streamdown <Streamdown
@ -889,7 +891,6 @@ export function ArtifactFilePreview({
{normalizedContent} {normalizedContent}
</Streamdown> </Streamdown>
</div> </div>
); );
} }
if (language === "html") { if (language === "html") {
@ -902,7 +903,6 @@ export function ArtifactFilePreview({
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }} style={{ zoom: zoomScale }}
/> />
); );
} }
return null; return null;

View File

@ -26,10 +26,7 @@ const OPEN_MODE = { chat: 60, artifacts: 40 };
const ChatBox: React.FC<{ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string | undefined; threadId: string | undefined;
}> = ({ }> = ({ children, threadId }) => {
children,
threadId,
}) => {
const { thread } = useThread(); const { thread } = useThread();
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);

View File

@ -3,7 +3,6 @@
import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function useThreadChat() { export function useThreadChat() {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams<{ thread_id: string }>(); const params = useParams<{ thread_id: string }>();
@ -45,7 +44,6 @@ export function useThreadChat() {
return threadIdFromPathOrParams ?? ""; return threadIdFromPathOrParams ?? "";
}); });
useEffect(() => { useEffect(() => {
// 记住最近一次有效的 thread_id供下次加载兜底使用。 // 记住最近一次有效的 thread_id供下次加载兜底使用。
if (threadId && threadId !== "new" && typeof window !== "undefined") { if (threadId && threadId !== "new" && typeof window !== "undefined") {

View File

@ -58,7 +58,9 @@ export function IframeTestPanel() {
function handleSendSelectSkill() { function handleSendSelectSkill() {
iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]); iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]);
addLog("postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])"); addLog(
"postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])",
);
} }
function handleSendSelectSkillArray() { function handleSendSelectSkillArray() {
@ -187,7 +189,8 @@ export function IframeTestPanel() {
</div> </div>
</div> </div>
{!collapsed && <div className="space-y-3 p-3"> {!collapsed && (
<div className="space-y-3 p-3">
{/* 当前状态 */} {/* 当前状态 */}
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs"> <div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
<div className="mb-1 font-semibold text-gray-500"></div> <div className="mb-1 font-semibold text-gray-500"></div>
@ -350,7 +353,11 @@ export function IframeTestPanel() {
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
window.postMessage( window.postMessage(
{ type: "selectedSkill", id: 999999, title: "不存在的技能" }, {
type: "selectedSkill",
id: 999999,
title: "不存在的技能",
},
"*", "*",
); );
addLog( addLog(
@ -438,7 +445,8 @@ export function IframeTestPanel() {
))} ))}
</div> </div>
)} )}
</div>} </div>
)}
</div> </div>
); );
} }

View File

@ -1,5 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
CheckIcon, CheckIcon,
@ -58,9 +60,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tag } from "@/components/ui/tag"; import { Tag } from "@/components/ui/tag";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
SelectedSkillPayloadItem,
} from "@/core/i18n/locales/types";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
@ -86,6 +86,7 @@ import {
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
export function InputBox({ export function InputBox({
className, className,
@ -132,7 +133,7 @@ export function InputBox({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps }); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping; const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
const router = useRouter();
const threadId = threadIdFromProps; const threadId = threadIdFromProps;
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
@ -375,6 +376,11 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
<HistoryButton
className="px-2!"
router={router}
threadId={threadIdFromProps}
/>
<AddAttachmentsButton className="px-2!" /> <AddAttachmentsButton className="px-2!" />
<IframeSkillDialogButton <IframeSkillDialogButton
className="px-2!" className="px-2!"
@ -383,6 +389,7 @@ export function InputBox({
openSkillDialog={iframeSkill.openSkillDialog} openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill} clearSkill={iframeSkill.clearSkill}
/> />
{/* 参考 kexue 版本隐藏运行模式切换按钮 */} {/* 参考 kexue 版本隐藏运行模式切换按钮 */}
</PromptInputTools> </PromptInputTools>
{/* <ModelSelector {/* <ModelSelector
@ -429,7 +436,9 @@ export function InputBox({
/> />
</PromptInput> </PromptInput>
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && ( {showWelcomeStyle &&
!hasSubmitted &&
searchParams.get("mode") !== "skill" && (
<SuggestionListContainer <SuggestionListContainer
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills} bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
isBootstrapping={iframeSkill.isBootstrapping} isBootstrapping={iframeSkill.isBootstrapping}
@ -532,19 +541,19 @@ function SuggestionList({
const promptSuggestions = suggestions.filter( const promptSuggestions = suggestions.filter(
( (
suggestion, suggestion,
): suggestion is Exclude<(typeof suggestions)[number], { type: "separator" }> => ): suggestion is Exclude<
!("type" in suggestion), (typeof suggestions)[number],
{ type: "separator" }
> => !("type" in suggestion),
); );
const handleSuggestionClick = useCallback( const handleSuggestionClick = useCallback(
( (suggestion: {
suggestion: {
prompt: string; prompt: string;
skill_id?: string[]; skill_id?: string[];
children?: SelectedSkillPayloadItem[]; children?: SelectedSkillPayloadItem[];
suggestion: string; suggestion: string;
}, }) => {
) => {
if (isBootstrapping) return; if (isBootstrapping) return;
// 优先使用 children 中的 skill保留每个 skill 自己的 name用于 tag 展示) // 优先使用 children 中的 skill保留每个 skill 自己的 name用于 tag 展示)
@ -553,7 +562,8 @@ function SuggestionList({
id: String(item.id).trim(), id: String(item.id).trim(),
name: item.name?.trim() ?? "", name: item.name?.trim() ?? "",
})) }))
.filter((item): item is { id: string; name: string } => .filter(
(item): item is { id: string; name: string } =>
Boolean(item.id) && Boolean(item.name), Boolean(item.id) && Boolean(item.name),
); );
if (childSkills.length > 0) { if (childSkills.length > 0) {
@ -593,7 +603,10 @@ function SuggestionList({
[bootstrapAndLockSkills, isBootstrapping, textInput], [bootstrapAndLockSkills, isBootstrapping, textInput],
); );
return ( return (
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions"> <Suggestions
className="min-h-16 w-fit items-start"
data-testid="welcome-suggestions"
>
{promptSuggestions.map((suggestion) => ( {promptSuggestions.map((suggestion) => (
<Suggestion <Suggestion
key={suggestion.suggestion} key={suggestion.suggestion}
@ -636,6 +649,39 @@ function AddAttachmentsButton({ className }: { className?: string }) {
</Tooltip> </Tooltip>
); );
} }
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>
</PromptInputButton>
</Tooltip>
);
}
// 启动iframeSkillDialog // 启动iframeSkillDialog
function IframeSkillDialogButton({ function IframeSkillDialogButton({
className, className,
@ -687,7 +733,10 @@ function IframeSkillDialogButton({
}} }}
> >
{selectedSkills.map((skill, index) => ( {selectedSkills.map((skill, index) => (
<Tag key={`${skill.skill_id}-${skill.title}-${index}`} className="shrink-0"> <Tag
key={`${skill.skill_id}-${skill.title}-${index}`}
className="shrink-0"
>
{skill.title} {skill.title}
{/* TODO: 因为后端接口不支持取消选择skill所以暂时禁用取消选择按钮 */} {/* TODO: 因为后端接口不支持取消选择skill所以暂时禁用取消选择按钮 */}
<button <button

View File

@ -79,9 +79,10 @@ export function MessageGroup({
return filteredSteps[filteredSteps.length - 1]; return filteredSteps[filteredSteps.length - 1];
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const totalToolStepCount = aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0); const totalToolStepCount =
const shouldShowToolSteps = !!lastToolCallStep && aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
(showAbove || aboveLastToolCallSteps.length === 0); const shouldShowToolSteps =
!!lastToolCallStep && (showAbove || aboveLastToolCallSteps.length === 0);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return ( return (
<ChainOfThought <ChainOfThought
@ -416,14 +417,15 @@ function ToolCall({
return t.toolCalls.executeCommand; return t.toolCalls.executeCommand;
} }
const command: string | undefined = (args as { command: string })?.command; const command: string | undefined = (args as { command: string })?.command;
const shouldCollapse = !!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD; const shouldCollapse =
!!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
label={description} label={description}
icon={SquareTerminalIcon} icon={SquareTerminalIcon}
action={shouldCollapse action={
? ( shouldCollapse ? (
<Button <Button
className="h-7 px-3 text-xs" className="h-7 px-3 text-xs"
variant="ghost" variant="ghost"
@ -436,11 +438,14 @@ function ToolCall({
? t.toolCalls.collapseContent ? t.toolCalls.collapseContent
: t.toolCalls.expandContent} : t.toolCalls.expandContent}
</Button> </Button>
) ) : undefined
: undefined} }
> >
{command && ( {command && (
<ExpandableToolContent content={command} expanded={isCommandExpanded} /> <ExpandableToolContent
content={command}
expanded={isCommandExpanded}
/>
)} )}
</ChainOfThoughtStep> </ChainOfThoughtStep>
); );

View File

@ -106,7 +106,9 @@ function MessageImage({
} }
const url = const url =
src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src; src.startsWith("/mnt/") && threadId
? resolveArtifactURL(src, threadId)
: src;
return ( return (
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">

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` }} /> <div style={{ height: `${paddingBottom}px` }} />
</ConversationContent> </ConversationContent>
{/* showScrollToBottomButton */} {/* showScrollToBottomButton */}
{ showScrollToBottomButton && ( {showScrollToBottomButton && (
<ConversationScrollButton <ConversationScrollButton
className={cn( className={cn(
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm", "z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",

View File

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

View File

@ -81,6 +81,7 @@ export const enUS: Translations = {
sendMessagePrice: sendMessagePrice:
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.", "Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
addAttachments: "Add attachments", addAttachments: "Add attachments",
history: "History",
selectSkill: "Select Skill", selectSkill: "Select Skill",
mode: "Mode", mode: "Mode",
flashMode: "Flash", flashMode: "Flash",

View File

@ -5,7 +5,6 @@ export interface SelectedSkillPayloadItem {
name: string; name: string;
} }
export interface Translations { export interface Translations {
// Locale meta // Locale meta
locale: { locale: {
@ -72,6 +71,7 @@ export interface Translations {
placeholder: string; placeholder: string;
createSkillPrompt: string; createSkillPrompt: string;
addAttachments: string; addAttachments: string;
history: string;
selectSkill: string; selectSkill: string;
mode: string; mode: string;
flashMode: string; flashMode: string;

View File

@ -83,6 +83,7 @@ export const zhCN: Translations = {
sendMessagePrice: sendMessagePrice:
"请注意此功能将消耗token请保证账户余额大于200可学豆。", "请注意此功能将消耗token请保证账户余额大于200可学豆。",
addAttachments: "添加附件", addAttachments: "添加附件",
history: "历史记录",
selectSkill: "选择Skill", selectSkill: "选择Skill",
mode: "模式", mode: "模式",
flashMode: "闪速", flashMode: "闪速",

View File

@ -79,7 +79,9 @@ function asRecord(value: unknown): UnknownRecord | null {
return value as UnknownRecord; return value as UnknownRecord;
} }
export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMessage { export function isSelectedSkillMessage(
value: unknown,
): value is SelectedSkillMessage {
const record = asRecord(value); const record = asRecord(value);
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
return false; return false;
@ -89,7 +91,9 @@ export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMe
return isValidId && typeof title === "string" && title.trim().length > 0; return isValidId && typeof title === "string" && title.trim().length > 0;
} }
export function isSelectedSkillsMessage(value: unknown): value is SelectSkillMessage { export function isSelectedSkillsMessage(
value: unknown,
): value is SelectSkillMessage {
const record = asRecord(value); const record = asRecord(value);
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) { if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) {
return false; return false;

View File

@ -101,7 +101,10 @@ export async function materializeSkillYaml(
): Promise<MaterializeSkillYamlResponse> { ): Promise<MaterializeSkillYamlResponse> {
console.log("[skills/api] ========== materializeSkillYaml START =========="); console.log("[skills/api] ========== materializeSkillYaml START ==========");
console.log("[skills/api] request:", JSON.stringify(request, null, 2)); console.log("[skills/api] request:", JSON.stringify(request, null, 2));
console.log("[skills/api] API URL:", `${getBackendBaseURL()}/api/skills/materialize-yaml`); console.log(
"[skills/api] API URL:",
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
);
const response = await fetch( const response = await fetch(
`${getBackendBaseURL()}/api/skills/materialize-yaml`, `${getBackendBaseURL()}/api/skills/materialize-yaml`,
@ -114,7 +117,11 @@ export async function materializeSkillYaml(
}, },
); );
console.log("[skills/api] response status:", response.status, response.statusText); console.log(
"[skills/api] response status:",
response.status,
response.statusText,
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));

View File

@ -219,9 +219,10 @@ export function useThreadStream({
// and to allow access to the current thread id in onUpdateEvent // and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null); const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false); const startedRef = useRef(false);
const lastErrorToastRef = useRef<{ message: string; timestamp: number } | null>( const lastErrorToastRef = useRef<{
null, message: string;
); timestamp: number;
} | null>(null);
const listeners = useRef({ const listeners = useRef({
onStart, onStart,
@ -454,8 +455,14 @@ export function useThreadStream({
try { try {
// 新会话模式下,仅在本地已有历史消息时才重置旧线程。 // 新会话模式下,仅在本地已有历史消息时才重置旧线程。
// 对于全新 thread_id避免多发一次 DELETE /threads/{id}(通常会 404 // 对于全新 thread_id避免多发一次 DELETE /threads/{id}(通常会 404
if (createNewSession && resolvedThreadId && thread.messages.length > 0) { if (
await apiClient.threads.delete(resolvedThreadId).catch(() => undefined); createNewSession &&
resolvedThreadId &&
thread.messages.length > 0
) {
await apiClient.threads
.delete(resolvedThreadId)
.catch(() => undefined);
} }
// Upload files first if any // Upload files first if any

View File

@ -29,7 +29,6 @@ function normalizeThreadId(value?: string | null): string | undefined {
return trimmed; return trimmed;
} }
export function textOfMessage(message: Message) { export function textOfMessage(message: Message) {
if (typeof message.content === "string") { if (typeof message.content === "string") {
return message.content; return message.content;

View File

@ -127,7 +127,9 @@ export async function downloadMarkdownAsPdf(
markdown: string, markdown: string,
filename: string, filename: string,
options: PdfOptions & { options: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>; resolveAssetUrl?: (
rawPath: string,
) => string | null | Promise<string | null>;
} = {}, } = {},
): Promise<void> { ): Promise<void> {
const html2pdf = await loadHtml2Pdf(); const html2pdf = await loadHtml2Pdf();
@ -520,7 +522,10 @@ async function createImageRunFromToken(
const rawHref = String(token?.href ?? token?.text ?? "").trim(); const rawHref = String(token?.href ?? token?.text ?? "").trim();
if (!rawHref) return null; if (!rawHref) return null;
const resolvedUrl = await resolveAssetReference(rawHref, options.resolveAssetUrl); const resolvedUrl = await resolveAssetReference(
rawHref,
options.resolveAssetUrl,
);
if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) { if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) {
return null; return null;
} }
@ -587,13 +592,12 @@ async function rewriteMarkdownImageSources(
const rawTarget = match[2]?.trim() ?? ""; const rawTarget = match[2]?.trim() ?? "";
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl); const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
if (!resolved || resolved === rawTarget) continue; if (!resolved || resolved === rawTarget) continue;
rewritten = rewritten.replace( rewritten = rewritten.replace(match[0], `![${alt}](${resolved})`);
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) { for (const match of htmlMatches) {
const rawTarget = match[3]?.trim() ?? ""; const rawTarget = match[3]?.trim() ?? "";
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl); const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);

View File

@ -48,7 +48,9 @@ export interface UseMarkdownDownloadReturn {
markdown: string, markdown: string,
filename: string, filename: string,
options?: PdfOptions & { options?: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>; resolveAssetUrl?: (
rawPath: string,
) => string | null | Promise<string | null>;
}, },
) => Promise<void>; ) => Promise<void>;
/** /**
@ -123,7 +125,9 @@ export function useMarkdownDownload(
markdown: string, markdown: string,
filename: string, filename: string,
options?: PdfOptions & { options?: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>; resolveAssetUrl?: (
rawPath: string,
) => string | null | Promise<string | null>;
}, },
) => { ) => {
if (isDownloading) return; if (isDownloading) return;

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; if (skillIds.length === 0) return skills;
const idSet = new Set(skillIds.map((id) => String(id))); const idSet = new Set(skillIds.map((id) => String(id)));
return skills.filter((skill) => !idSet.has(String(skill.skill_id))); return skills.filter((skill) => !idSet.has(String(skill.skill_id)));
@ -102,7 +105,10 @@ export function useIframeSkill(
const latestSkills = parseStoredSkills( const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest), window.localStorage.getItem(STORAGE_KEYS.latest),
); );
const nextLatestSkills = removeSkillsByIdsFromList(latestSkills, skillIds); const nextLatestSkills = removeSkillsByIdsFromList(
latestSkills,
skillIds,
);
if (nextLatestSkills.length > 0) { if (nextLatestSkills.length > 0) {
window.localStorage.setItem( window.localStorage.setItem(
STORAGE_KEYS.latest, STORAGE_KEYS.latest,
@ -114,10 +120,18 @@ export function useIframeSkill(
const threadKey = getThreadStorageKey(threadId); const threadKey = getThreadStorageKey(threadId);
if (threadKey) { if (threadKey) {
const threadSkills = parseStoredSkills(window.localStorage.getItem(threadKey)); const threadSkills = parseStoredSkills(
const nextThreadSkills = removeSkillsByIdsFromList(threadSkills, skillIds); window.localStorage.getItem(threadKey),
);
const nextThreadSkills = removeSkillsByIdsFromList(
threadSkills,
skillIds,
);
if (nextThreadSkills.length > 0) { if (nextThreadSkills.length > 0) {
window.localStorage.setItem(threadKey, JSON.stringify(nextThreadSkills)); window.localStorage.setItem(
threadKey,
JSON.stringify(nextThreadSkills),
);
} else { } else {
window.localStorage.removeItem(threadKey); window.localStorage.removeItem(threadKey);
} }
@ -170,7 +184,10 @@ export function useIframeSkill(
return; return;
} }
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data); console.warn(
"[useIframeSkill] 忽略非法 selectedSkill 消息",
event.data,
);
} }
}; };
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
@ -186,7 +203,8 @@ export function useIframeSkill(
const latestSkills = parseStoredSkills( const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest), window.localStorage.getItem(STORAGE_KEYS.latest),
); );
const restoredSkills = threadSkills.length > 0 ? threadSkills : latestSkills; const restoredSkills =
threadSkills.length > 0 ? threadSkills : latestSkills;
if (restoredSkills.length === 0) return; if (restoredSkills.length === 0) return;
setSelectedSkills(restoredSkills); setSelectedSkills(restoredSkills);
setSelectedSkill(restoredSkills[0] ?? null); setSelectedSkill(restoredSkills[0] ?? null);
@ -212,11 +230,17 @@ export function useIframeSkill(
}, [selectedSkills, threadId]); }, [selectedSkills, threadId]);
// 发送选择预定义 skill // 发送选择预定义 skill
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => { const sendSelectSkill = useCallback(
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills }; (selectedSkills: SelectedSkillPayloadItem[]) => {
const message = {
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills,
};
console.log("[useIframeSkill] sendSelectSkill:", message); console.log("[useIframeSkill] sendSelectSkill:", message);
sendToParent(message); sendToParent(message);
}, []); },
[],
);
const bootstrapAndLockSkills = useCallback( const bootstrapAndLockSkills = useCallback(
async ({ async ({
@ -254,7 +278,9 @@ export function useIframeSkill(
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
setIsBootstrapping(true); setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "suggest-skill-bootstrap" }); toast.loading(`正在加载技能「${title}」...`, {
id: "suggest-skill-bootstrap",
});
try { try {
const result = await bootstrapRemoteSkill({ const result = await bootstrapRemoteSkill({
@ -268,7 +294,9 @@ export function useIframeSkill(
toast.dismiss("suggest-skill-bootstrap"); toast.dismiss("suggest-skill-bootstrap");
if (!result.success) { if (!result.success) {
const failedIds = selectedSkills.map((item) => String(item.id).trim()); const failedIds = selectedSkills.map((item) =>
String(item.id).trim(),
);
removeFailedSkills(failedIds); removeFailedSkills(failedIds);
toast.error(`技能「${title}」加载失败`, { toast.error(`技能「${title}」加载失败`, {
description: result.message || "未知错误", description: result.message || "未知错误",
@ -285,7 +313,8 @@ export function useIframeSkill(
setSelectedSkills(normalizedSkills); setSelectedSkills(normalizedSkills);
toast.success(`技能「${title}」加载成功`, { toast.success(`技能「${title}」加载成功`, {
description: result.message || `已创建 ${result.created_files} 个文件`, description:
result.message || `已创建 ${result.created_files} 个文件`,
}); });
return true; return true;
@ -293,8 +322,7 @@ export function useIframeSkill(
const failedIds = selectedSkills.map((item) => String(item.id).trim()); const failedIds = selectedSkills.map((item) => String(item.id).trim());
removeFailedSkills(failedIds); removeFailedSkills(failedIds);
toast.dismiss("suggest-skill-bootstrap"); toast.dismiss("suggest-skill-bootstrap");
const message = const message = error instanceof Error ? error.message : "网络请求失败";
error instanceof Error ? error.message : "网络请求失败";
toast.error(`技能「${title}」加载失败`, { toast.error(`技能「${title}」加载失败`, {
description: message, description: message,
}); });

View File

@ -399,7 +399,7 @@
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
} }
/* Chrome, Safari, Opera */ /* Chrome, Safari, Opera */
/* *::-webkit-scrollbar { /* *::-webkit-scrollbar {
display: none; display: none;
} */ } */
@ -413,8 +413,9 @@
html, html,
body { body {
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", ui-sans-serif, font-family:
system-ui, sans-serif; "Microsoft YaHei", "微软雅黑", "PingFang SC", ui-sans-serif, system-ui,
sans-serif;
} }
/* ======================================== /* ========================================
@ -437,7 +438,8 @@ code,
kbd, kbd,
samp, samp,
pre { pre {
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important; font-family:
"Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
} }
/* 列表项 - 14px */ /* 列表项 - 14px */

View File

@ -110,7 +110,9 @@ async function waitForArtifactsReady(
return false; return false;
} }
await openButton.click(); await openButton.click();
await expect(page.getByTestId("artifact-file-list").first()).toBeVisible({ await expect(
page.getByTestId("artifact-file-list").first(),
).toBeVisible({
timeout: 5_000, timeout: 5_000,
}); });
} }
@ -144,11 +146,14 @@ async function waitForArtifactsReady(
} }
function artifactCardByPattern(page: Page, pattern: RegExp): Locator { function artifactCardByPattern(page: Page, pattern: RegExp): Locator {
return page.locator("[data-testid='artifact-file-card']").filter({ return page
.locator("[data-testid='artifact-file-card']")
.filter({
has: page.locator("[data-slot='card-title'] div[title]").filter({ has: page.locator("[data-slot='card-title'] div[title]").filter({
hasText: pattern, hasText: pattern,
}), }),
}).first(); })
.first();
} }
async function waitAfterCardClick(page: Page, kind: string): Promise<void> { async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
@ -165,7 +170,9 @@ async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
return; return;
} }
if (kind === "pptx") { if (kind === "pptx") {
await expect(page.getByText("请下载ppt文件以获得最佳效果").first()).toBeVisible({ await expect(
page.getByText("请下载ppt文件以获得最佳效果").first(),
).toBeVisible({
timeout: 60_000, timeout: 60_000,
}); });
return; return;
@ -180,7 +187,11 @@ async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
}); });
} }
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> { async function capture(
page: Page,
testInfo: TestInfo,
name: string,
): Promise<void> {
const path = testInfo.outputPath(`${name}.png`); const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true }); await page.screenshot({ path, fullPage: true });
await testInfo.attach(name, { await testInfo.attach(name, {
@ -215,7 +226,9 @@ async function getArtifactFileNames(page: Page): Promise<string[]> {
} }
// fallback: if title attr is absent, use first text line of each card // fallback: if title attr is absent, use first text line of each card
const cardTexts = await page.getByTestId("artifact-file-card").allTextContents(); const cardTexts = await page
.getByTestId("artifact-file-card")
.allTextContents();
return cardTexts return cardTexts
.map((text) => text.split("\n")[0]?.trim() ?? "") .map((text) => text.split("\n")[0]?.trim() ?? "")
.filter(Boolean); .filter(Boolean);

View File

@ -53,7 +53,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
await page.getByTestId("artifacts-open-button").click(); await page.getByTestId("artifacts-open-button").click();
const imageFile = page const imageFile = page
.locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']") .locator(
"[data-testid='artifact-file-list'] [data-testid='artifact-file-card']",
)
.filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i }) .filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i })
.first(); .first();
testInfo.skip( testInfo.skip(
@ -82,7 +84,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
await page.getByTestId("artifacts-open-button").click(); await page.getByTestId("artifacts-open-button").click();
const htmlFile = page const htmlFile = page
.locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']") .locator(
"[data-testid='artifact-file-list'] [data-testid='artifact-file-card']",
)
.filter({ hasText: /\.html?/i }) .filter({ hasText: /\.html?/i })
.first(); .first();
testInfo.skip( testInfo.skip(
@ -102,9 +106,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
await htmlFile.click(); await htmlFile.click();
const htmlArtifactResponse = await htmlArtifactResponsePromise; const htmlArtifactResponse = await htmlArtifactResponsePromise;
expect(htmlArtifactResponse.headers()["content-disposition"] ?? "").toContain( expect(
"attachment;", htmlArtifactResponse.headers()["content-disposition"] ?? "",
); ).toContain("attachment;");
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible(); await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
}); });
@ -155,10 +159,9 @@ test.describe("聊天工作台 / Artifact 面板", () => {
await page.getByTestId("artifacts-open-button").click(); await page.getByTestId("artifacts-open-button").click();
await expect await expect
.poll( .poll(async () => await page.getByTestId("artifact-file-card").count(), {
async () => await page.getByTestId("artifact-file-card").count(), timeout: 30_000,
{ timeout: 30_000 }, })
)
.toBeGreaterThan(0); .toBeGreaterThan(0);
}); });
}); });

View File

@ -10,11 +10,7 @@ import {
test.describe("聊天工作台 / 输入区与发送", () => { test.describe("聊天工作台 / 输入区与发送", () => {
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => { test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
@ -32,11 +28,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({ test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
@ -52,18 +44,17 @@ test.describe("聊天工作台 / 输入区与发送", () => {
await page.locator("div.absolute.inset-0.z-1.cursor-text").click(); await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
await expect.poll(inputHeight).toBeGreaterThan(120); await expect.poll(inputHeight).toBeGreaterThan(120);
await page.getByRole("main").first().click({ position: { x: 20, y: 20 } }); await page
.getByRole("main")
.first()
.click({ position: { x: 20, y: 20 } });
await expect.poll(inputHeight).toBeLessThan(110); await expect.poll(inputHeight).toBeLessThan(110);
}); });
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({ test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const suggestions = page.getByTestId("welcome-suggestions"); const suggestions = page.getByTestId("welcome-suggestions");
@ -76,11 +67,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
}); });
test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => { test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
@ -95,11 +82,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({ test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
@ -119,11 +102,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({ test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");

View File

@ -10,7 +10,10 @@ import {
waitForMessageListReady, waitForMessageListReady,
} from "./support/chat-helpers"; } from "./support/chat-helpers";
async function waitForAnyMessages(page: Parameters<typeof openChat>[0], timeoutMs = 15_000) { async function waitForAnyMessages(
page: Parameters<typeof openChat>[0],
timeoutMs = 15_000,
) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const count = await page.locator(".is-user, .is-assistant").count(); const count = await page.locator(".is-user, .is-assistant").count();
@ -76,7 +79,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
const target = element as HTMLElement; const target = element as HTMLElement;
return target.scrollHeight - target.clientHeight > 20; return target.scrollHeight - target.clientHeight > 20;
}); });
testInfo.skip(canScroll === false, "当前线程消息区高度不足,无法触发滚动到底部按钮。"); testInfo.skip(
canScroll === false,
"当前线程消息区高度不足,无法触发滚动到底部按钮。",
);
await messageLog.hover(); await messageLog.hover();
await page.mouse.wheel(0, -1200); await page.mouse.wheel(0, -1200);
@ -115,7 +121,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
.filter(Boolean); .filter(Boolean);
expect(afterUsers.length).toBe(beforeUsers.length); expect(afterUsers.length).toBe(beforeUsers.length);
for (const sample of beforeUsers.slice(0, Math.min(3, beforeUsers.length))) { for (const sample of beforeUsers.slice(
0,
Math.min(3, beforeUsers.length),
)) {
expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy(); expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy();
} }
}); });
@ -132,7 +141,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
await waitForMessageListReady(page, { requireMessages: false }); await waitForMessageListReady(page, { requireMessages: false });
const todoButton = page.getByRole("button", { name: /To-dos/i }); const todoButton = page.getByRole("button", { name: /To-dos/i });
testInfo.skip((await todoButton.count()) === 0, "当前线程未展示 To-dos 入口。"); testInfo.skip(
(await todoButton.count()) === 0,
"当前线程未展示 To-dos 入口。",
);
await expect(todoButton).toBeVisible(); await expect(todoButton).toBeVisible();
}); });
}); });

View File

@ -9,9 +9,13 @@ function envThread(name: string) {
export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined; export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined;
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID; export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID; export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
export const THREAD_WITH_MARKDOWN = envThread("FRONTEND_E2E_MARKDOWN_THREAD_ID"); export const THREAD_WITH_MARKDOWN = envThread(
"FRONTEND_E2E_MARKDOWN_THREAD_ID",
);
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID"); export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
export const THREAD_WITH_ARTIFACTS = envThread("FRONTEND_E2E_ARTIFACTS_THREAD_ID"); export const THREAD_WITH_ARTIFACTS = envThread(
"FRONTEND_E2E_ARTIFACTS_THREAD_ID",
);
export const THREAD_WITH_IMAGE_ARTIFACT = envThread( export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID", "FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
); );
@ -108,10 +112,9 @@ export async function waitForMessageListReady(
await expect(page.getByRole("main").first()).toBeVisible(); await expect(page.getByRole("main").first()).toBeVisible();
if (requireMessages) { if (requireMessages) {
await expect await expect
.poll( .poll(async () => await page.locator(".is-user, .is-assistant").count(), {
async () => await page.locator(".is-user, .is-assistant").count(), timeout: 30_000,
{ timeout: 30_000 }, })
)
.toBeGreaterThan(minMessages - 1); .toBeGreaterThan(minMessages - 1);
} }
} }

View File

@ -13,9 +13,7 @@ test.use({
}); });
test.describe("聊天工作台 / 错误提示", () => { test.describe("聊天工作台 / 错误提示", () => {
test("DF-ERR-001 对话流失败时显示错误 toast", async ({ test("DF-ERR-001 对话流失败时显示错误 toast", async ({ page }, testInfo) => {
page,
}, testInfo) => {
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!)); await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));

View File

@ -18,7 +18,9 @@ test.use({
}); });
test.describe("线程路由(无 isnew", () => { test.describe("线程路由(无 isnew", () => {
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({ page }, testInfo) => { test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
@ -33,7 +35,9 @@ test.describe("线程路由(无 isnew", () => {
const messageCount = await waitForAnyMessages(page); const messageCount = await waitForAnyMessages(page);
testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。"); testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。");
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`)); await expect(page).toHaveURL(
new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`),
);
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible(); await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
}); });
@ -62,12 +66,13 @@ test.describe("线程路由(无 isnew", () => {
); );
await sendMessage(page, text); await sendMessage(page, text);
await expect(page.locator(".is-user").filter({ hasText: text })).toHaveCount(1); await expect(
page.locator(".is-user").filter({ hasText: text }),
).toHaveCount(1);
await expect await expect
.poll( .poll(async () => await page.locator(".is-assistant").count(), {
async () => await page.locator(".is-assistant").count(), timeout: 30_000,
{ timeout: 30_000 }, })
)
.toBeGreaterThan(0); .toBeGreaterThan(0);
const streamRequest = await streamRequestPromise; const streamRequest = await streamRequestPromise;

View File

@ -15,11 +15,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({ test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const suggestions = page.getByTestId("welcome-suggestions"); const suggestions = page.getByTestId("welcome-suggestions");
@ -31,11 +27,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({ test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!));
await expect(page).toHaveURL( await expect(page).toHaveURL(
@ -45,9 +37,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0); await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
}); });
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({ test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({ page }) => {
page,
}) => {
await page.goto(invalidNewChatUrl()); await page.goto(invalidNewChatUrl());
await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
@ -57,11 +47,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({ test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
await waitForMessageListReady(page); await waitForMessageListReady(page);
@ -76,11 +62,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({ test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
await waitForMessageListReady(page); await waitForMessageListReady(page);
@ -96,11 +78,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({ test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
page, page,
}, testInfo) => { }, testInfo) => {
skipIfMissingThread( skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
testInfo,
THREAD_FOR_WELCOME,
"FRONTEND_E2E_THREAD_ID",
);
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
await waitForMessageListReady(page); await waitForMessageListReady(page);