Compare commits

...

43 Commits

Author SHA1 Message Date
肖应宇 6c618fadfb style:prettier 2026-04-12 15:37:42 +08:00
肖应宇 80c964de09 feat(route): 前往对话页的按钮 2026-04-12 15:37:42 +08:00
肖应宇 c6c06e93a7 fix: fix莫名修改 2026-04-12 15:37:42 +08:00
肖应宇 02daf287f2 fix: 莫名修改 2026-04-12 15:37:42 +08:00
肖应宇 5d3e88a4dd feat(artifacts): 使用 RevoGrid+ExcelJS 预览 Excel
- Excel 预览从 sheet_to_html 切换为 RevoGrid 网格渲染

- 使用 ExcelJS 解析工作簿并支持工作表切换

- 更新前端依赖:新增 @revolist/revogrid、exceljs;移除 nuxt-og-image、pptx-preview、xlsx
2026-04-12 15:37:42 +08:00
肖应宇 9fb74a1132 feat:10分钟更换一次slogan 2026-04-12 15:37:42 +08:00
肖应宇 569df14659 feat(frontend): 接入 pdf.js 预览并调整产物预览逻辑 2026-04-12 15:37:42 +08:00
肖应宇 6e99f8cb37 fix(backend): 修复二进制产物误判文本导致 PDF 返回异常 2026-04-12 15:37:42 +08:00
肖应宇 e742fbc521 fix(threads): 忽略流取消导致的错误提示
识别 cancelled/canceled/abort 等取消信号。\n在流式请求被主动停止或中断时不再弹出错误 toast,减少误报。
2026-04-12 15:37:42 +08:00
肖应宇 ff0f64c98a fix: ctrl+enter键不能换行的问题 2026-04-12 15:37:42 +08:00
肖应宇 ef80a2fd2c dev:版本标识 2026-04-12 15:37:42 +08:00
肖应宇 2d7cbcf34e test: 截图测试office套件文件的展示 2026-04-12 15:37:42 +08:00
肖应宇 9d48990605 chore(backend): 强化输出文件的 present_files 交付约束 2026-04-12 15:37:42 +08:00
肖应宇 77cc6aa7af feat(frontend): 支持 DOCX/PDF 下载时包含图片资源 2026-04-12 15:37:42 +08:00
肖应宇 cc95d4dac6 fix: 修复剪贴板没有统一使用copyToClipboard的问题 2026-04-12 15:37:42 +08:00
肖应宇 96dc81edf7 feat(artifact): 禁用自动打开artifact面板的功能 2026-04-12 15:37:42 +08:00
肖应宇 e45e355dbb fix(frontend): 同意对话错误提示和增加两条e2e测试 2026-04-12 15:37:42 +08:00
肖应宇 56931c6c8b test: 测试用例测试html文件有没有向用户展示 2026-04-12 15:37:42 +08:00
肖应宇 90e0dc1a14 feat(backend): 提示词把present_files,写成了present_file,可能是不展示html文件的原因 2026-04-12 15:37:42 +08:00
肖应宇 ba9af026f7 feat: 完成显示docx, pptx, xlsx文件 2026-04-12 15:37:42 +08:00
肖应宇 4123bc7673 feat(frontend): 优化工作区输入框与 artifacts 展示体验
改进工作区核心交互,提升输入与结果查看的一致性和可用性。

调整 prompt 输入相关组件逻辑,优化输入行为与状态反馈
更新 workspace input-box 交互细节,改善可用性与稳定性
优化 message-group 展示逻辑,增强消息区域可读性
调整 artifact-file-detail 预览相关实现,为后续 Office 文件展示做准备
补充并更新 thread-routing e2e 用例,覆盖关键路由与交互回归场景
2026-04-12 15:37:42 +08:00
肖应宇 0c1a293fbd test: 新增新用户的创建逻辑用例 2026-04-12 15:37:42 +08:00
肖应宇 c9116e2fb2 dev: 从侧边栏点击直接进入对话页 2026-04-12 15:37:42 +08:00
肖应宇 c231c9f41f fix(frontend): 进入/new预创建会话并强制跳转聊天态 2026-04-12 15:37:42 +08:00
肖应宇 a70f5a3902 feat:重启tag的删除功能 2026-04-12 15:37:42 +08:00
肖应宇 cec5a7c615 fix(frontend): hide history reliably in welcome mode 2026-04-12 15:37:42 +08:00
肖应宇 1061777c34 fix(frontend): stabilize thread id when sending messages 2026-04-12 15:37:42 +08:00
肖应宇 5e0d316bb4 feat: 如果请求失败不要写入localstorage,且不要展示失败的skill 2026-04-12 15:37:42 +08:00
肖应宇 be21f8a99a feat: 全局字体和代码块字体大小 2026-04-12 15:37:42 +08:00
肖应宇 f3f87e220e feat: skill tag的复数处理。测试复skill的数量 2026-04-12 15:37:42 +08:00
肖应宇 5606519619 feat: enter换行,取消enter发送 2026-04-12 15:37:42 +08:00
肖应宇 13096b98a1 dev: 给通信面板加收起按钮 2026-04-12 15:37:42 +08:00
肖应宇 9d14c9ffd8 feat: 清空旧localstorage的内容 2026-04-12 15:37:42 +08:00
肖应宇 01964efc20 feat: 修改测试标识的位置,并写死会话标题为“来,一起学习工作吧” 2026-04-12 15:37:42 +08:00
肖应宇 72023c513b feat: 生成中禁用返回按钮 2026-04-12 15:37:42 +08:00
肖应宇 7bdc9777bf dev: 测试版本标识 2026-04-12 15:37:42 +08:00
肖应宇 2edddff141 feat: 弃用localstorage的设置 2026-04-12 15:37:42 +08:00
肖应宇 7eb55d26d8 feat: 宿主页复制 2026-04-12 15:37:42 +08:00
肖应宇 bd93e6ef4d feat: skill清空逻辑。因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 2026-04-12 15:37:42 +08:00
肖应宇 fc0118e6a5 feat: 支持多技能标签展示并持久化已选技能 2026-04-12 15:37:42 +08:00
肖应宇 f822deadf3 dev: 测试版本标识 2026-04-12 15:37:42 +08:00
肖应宇 68256eb8b4 fix:修复错误跳转无query的场景 2026-04-12 15:37:42 +08:00
肖应宇 4bbbab24ca feat(frontend): 支持宿主selectedSkills和skill bootstarp流程, 和加载skill中的加载提示与禁止发送消息 2026-04-12 15:37:42 +08:00
49 changed files with 3681 additions and 2273 deletions

View File

@ -176,6 +176,11 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
if is_text_file_by_content(actual_path):
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
try:
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
except UnicodeDecodeError:
# Some binary formats (e.g. certain PDFs) may not contain NUL bytes in
# the sampled chunk and be misclassified as text. Fall back to binary.
logger.debug("Artifact looked like text but is not valid UTF-8: %s", actual_path, exc_info=True)
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})

View File

@ -269,7 +269,8 @@ You: "Deploying to staging..." [proceed]
- Use `read_file` tool to read uploaded files using their paths from the list
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
- All temporary work happens in `/mnt/user-data/workspace`
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` tool
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
- MANDATORY delivery sequence for Markdown/HTML outputs: after `write_file` (or `str_replace`) creates/updates a deliverable `.md` or `.html` in `/mnt/user-data/outputs`, you MUST call `present_files` for that file before finishing your response
{acp_section}
</working_directory>
@ -347,8 +348,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
- Progressive Loading: Load resources incrementally as referenced in skills
- Output Files: Final deliverables must be in `/mnt/user-data/outputs`
- Delivery Completeness: If you created/updated a deliverable `.md` or `.html` file in `/mnt/user-data/outputs`, do NOT end the task until you have called `present_files` for it
- Clarity: Be direct and helpful, avoid unnecessary meta-commentary
- Including Images and Mermaid: Images and Mermaid diagrams are always welcomed in the Markdown format, and you're encouraged to use `![Image Description](image_path)\n\n` or "```mermaid" to display images in response or Markdown files
- Multi-task: Better utilize parallel tool calling to call multiple tools at one time for better performance
- Language Consistency: Keep using the same language as user's
- Always Respond: Your thinking is internal. You MUST always provide a visible response to the user after thinking.
@ -495,7 +496,7 @@ def _build_acp_section() -> str:
"- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n"
"- When writing prompts for ACP agents, describe the task only — do NOT reference `/mnt/user-data` paths\n"
"- ACP agent results are accessible at `/mnt/acp-workspace/` (read-only) — use `ls`, `read_file`, or `bash cp` to retrieve output files\n"
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_file`"
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_files`"
)

View File

@ -102,3 +102,18 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path
assert response.status_code == 200
assert response.text == "hello"
assert response.headers.get("content-disposition", "").startswith("attachment;")
def test_get_artifact_pdf_with_no_null_bytes_and_non_utf8_content_is_served_inline(tmp_path, monkeypatch) -> None:
artifact_path = tmp_path / "slides.pdf"
# No NUL bytes, but invalid UTF-8 to simulate binary content misdetected as text.
binary_content = b"%PDF-1.7\n\xff\xfe\xfa\n%%EOF"
artifact_path.write_bytes(binary_content)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path)
response = asyncio.run(artifacts_router.get_artifact("thread-1", "mnt/user-data/outputs/slides.pdf", _make_request()))
assert bytes(response.body) == binary_content
assert response.media_type == "application/pdf"
assert response.headers.get("content-disposition", "").startswith("inline;")

View File

@ -46,8 +46,10 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@revolist/revogrid": "^4.21.3",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.90.17",
"@tombcato/smart-ticker": "^1.2.4",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
@ -63,11 +65,14 @@
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"docx": "^9.6.1",
"docx-preview": "^0.3.7",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"html2pdf.js": "^0.14.0",
"jszip": "^3.10.1",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"marked": "^17.0.5",
@ -77,8 +82,8 @@
"next-themes": "^0.4.6",
"nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1",
"nuxt-og-image": "^5.1.13",
"ogl": "^1.0.11",
"pdfjs-dist": "^5.6.205",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1",

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,42 @@
[
{
"text": "开工!摸鱼退散🐟💨",
"color": "#FF6B6B"
},
{
"text": "学习搞起,摆烂禁止🙅‍♂️",
"color": "#4ECDC4"
},
{
"text": "卷不动也得动💪",
"color": "#45B7D1"
},
{
"text": "搬砖学习,同步上线🧱",
"color": "#96CEB4"
},
{
"text": "别躺了,搞钱要紧💰",
"color": "#FFA559"
},
{
"text": "今日份努力已上线✨",
"color": "#A78BFA"
},
{
"text": "支棱起来,干活啦🚀",
"color": "#FF9F1C"
},
{
"text": "拒绝摆烂,从我做起😤",
"color": "#2EC4B6"
},
{
"text": "学习人,不犯困😪",
"color": "#E71D36"
},
{
"text": "冲冲冲,别摸鱼🐎",
"color": "#3A86FF"
}
]

View File

@ -2,7 +2,8 @@
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
@ -28,6 +29,7 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks";
@ -38,10 +40,14 @@ import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { Ticker } from "@tombcato/smart-ticker";
import "@tombcato/smart-ticker/style.css";
import motivationSlogans from "./motivation-slogans.json";
export default function ChatPage() {
const { t } = useI18n();
useSpecificChatMode();
const [sloganIndex, setSloganIndex] = useState(0);
const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const router = useRouter();
@ -56,23 +62,23 @@ 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;
const createNewSession = useMemo(() => isNewThread, [isNewThread]);
const safeThreadId = useMemo(() => {
if (!threadId || threadId === "new") {
return undefined;
}
return threadId;
}, [threadId]);
// `/new` + `thread_id` now reuses the pre-created thread, instead of creating
// a new session on first submit.
const createNewSession = useMemo(
() => isNewThread && !safeThreadId,
[isNewThread, safeThreadId],
);
const streamThreadId = useMemo(() => {
if (isNewThread && createNewSession) {
@ -80,8 +86,70 @@ export default function ChatPage() {
}
return safeThreadId;
}, [createNewSession, isNewThread, safeThreadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null);
const { showNotification } = useNotification();
const currentSlogan = motivationSlogans[
sloganIndex % motivationSlogans.length
] ?? {
text: "来,一起学习工作吧",
color: "#333333",
};
const tickerCharacterList = useMemo(() => {
const seen = new Set<string>();
const uniqueChars: string[] = [];
for (const slogan of motivationSlogans) {
for (const char of slogan.text) {
if (seen.has(char)) continue;
seen.add(char);
uniqueChars.push(char);
}
}
return uniqueChars.join("");
}, []);
useEffect(() => {
if (motivationSlogans.length <= 1) return;
const timer = window.setInterval(
() => {
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
},
10 * 60 * 1000,
);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!isNewThread) {
warnedMissingThreadIdRef.current = false;
return;
}
if (!safeThreadId) {
if (!warnedMissingThreadIdRef.current) {
warnedMissingThreadIdRef.current = true;
toast.error("缺少 thread_id无法创建会话");
}
return;
}
warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return;
initializedThreadRef.current = safeThreadId;
void apiClient.threads
.create({
threadId: safeThreadId,
ifExists: "do_nothing",
})
.catch(() => {
initializedThreadRef.current = null;
toast.error("会话创建失败,请稍后重试");
});
}, [apiClient, isNewThread, safeThreadId]);
// 监听宿主页 selectedSkill 消息
const {
@ -99,8 +167,8 @@ export default function ChatPage() {
onStart: (currentThreadId) => {
setIsNewThread(false);
// if (!shouldStayOnNewRoute) {
// Keep /new in history so router.back() can return to it.
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
// Keep /new in history so router.back() can return to it.
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
// }
// history.pushState(null, "", pathOfThread(currentThreadId));
},
@ -135,10 +203,16 @@ export default function ChatPage() {
setHistoryCutoff(null);
return;
}
if (historyCutoff === null && !thread.isThreadLoading) {
setHistoryCutoff(thread.messages.length);
}
if (hasSubmitted) return;
// Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。
// 这样即使历史消息是后续异步补齐,也不会重新露出。
setHistoryCutoff((prev) => {
const next = thread.messages.length;
if (prev === null) return next;
return next > prev ? next : prev;
});
}, [
hasSubmitted,
historyCutoff,
shouldRenderHistory,
thread.isThreadLoading,
@ -193,15 +267,30 @@ export default function ChatPage() {
const todoListCollapsed = true;
const [showExitDialog, setShowExitDialog] = useState(false);
const isStreaming = isUploading || thread.isLoading;
const handleSubmit = useCallback(
(message: Parameters<typeof sendMessage>[1]) => {
if (isSelectedSkillBootstrapping) {
return;
}
if (isNewThread && !safeThreadId) {
toast.error("缺少 thread_id无法发送消息");
return;
}
setHasSubmitted(true);
void sendMessage(threadId, message);
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
}
void sendMessage(safeThreadId, message);
},
[isSelectedSkillBootstrapping, sendMessage, threadId],
[
isNewThread,
isSelectedSkillBootstrapping,
router,
safeThreadId,
sendMessage,
showWelcomeStyle,
],
);
const handleStop = useCallback(async () => {
await thread.stop();
@ -222,10 +311,9 @@ export default function ChatPage() {
setArtifactsOpen,
setIsNewThread,
]);
return (
<ThreadContext.Provider value={{ threadId,thread }}>
<ThreadContext.Provider value={{ threadId, thread }}>
<div
className={cn(
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
@ -252,6 +340,7 @@ export default function ChatPage() {
size="sm"
variant="ghost"
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
disabled={isStreaming}
onClick={() => setShowExitDialog(true)}
>
<svg
@ -271,9 +360,22 @@ export default function ChatPage() {
</svg>
</Button>
</div>
<div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
<div
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]"
style={{
color: currentSlogan.color,
}}
>
{/* threadTitle={title} */}
{title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} />
// <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
<Ticker
value={currentSlogan.text}
duration={800}
easing="easeInOut"
charWidth={1}
characterLists={tickerCharacterList}
/>
)}
</div>
<div className="flex items-center justify-end gap-2 overflow-hidden">
@ -316,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">
@ -328,9 +432,11 @@ export default function ChatPage() {
threadId={threadId}
thread={thread}
messagesOverride={
shouldRenderHistory || historyCutoff === null
shouldRenderHistory
? undefined
: thread.messages.slice(historyCutoff)
: historyCutoff === null
? []
: thread.messages.slice(historyCutoff)
}
paddingBottom={todoListCollapsed ? 160 : 280}
showScrollToBottomButton={!showWelcomeStyle}
@ -354,6 +460,7 @@ export default function ChatPage() {
<div
className={cn(
"h-full w-full transition-transform duration-300 ease-in-out",
showWelcomeStyle && !hasSubmitted ? "translate-x-0" : "",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
@ -390,9 +497,9 @@ export default function ChatPage() {
{t.common.artifacts}
</h2>
</header>
<main className="min-h-0 grow">
<main className="min-h-0 grow overflow-auto">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
className="mb-[207px] max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
@ -416,43 +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
}
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 && (
@ -475,7 +587,7 @@ export default function ChatPage() {
<DevDialogTitle></DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
退
</p>
<DevDialogFooter>
<Button
@ -504,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

@ -116,6 +116,7 @@ export const ChainOfThoughtHeader = memo(
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon | React.ReactElement;
label: ReactNode;
action?: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
};
@ -125,6 +126,7 @@ export const ChainOfThoughtStep = memo(
className,
icon: Icon = DotIcon,
label,
action,
description,
status = "complete",
children,
@ -151,7 +153,10 @@ export const ChainOfThoughtStep = memo(
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
<div className="flex items-start justify-between gap-2">
<div>{label}</div>
{action && <div className="shrink-0">{action}</div>}
</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}

View File

@ -1,7 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { cn, copyToClipboard } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
type ComponentProps,
@ -146,14 +146,9 @@ export const CodeBlockCopyButton = ({
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
const handleCopyClick = async () => {
try {
await navigator.clipboard.writeText(code);
await copyToClipboard(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
@ -167,7 +162,7 @@ export const CodeBlockCopyButton = ({
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
onClick={handleCopyClick}
size="icon"
variant="ghost"
{...props}

View File

@ -860,12 +860,15 @@ export const PromptInputBody = ({
export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
> & {
submitOnEnter?: boolean;
};
export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
submitOnEnter = true,
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
@ -877,7 +880,35 @@ export const PromptInputTextarea = ({
if (isComposing || e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
if (!submitOnEnter) {
return;
}
if (e.shiftKey || e.ctrlKey || e.metaKey) {
// Keep newline behavior explicit for modified-Enter combos.
// This avoids accidental submit shortcuts swallowing Ctrl/Cmd+Enter.
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const target = e.currentTarget;
const start = target.selectionStart ?? target.value.length;
const end = target.selectionEnd ?? target.value.length;
const nextValue =
target.value.slice(0, start) + "\n" + target.value.slice(end);
if (controller) {
controller.textInput.setInput(nextValue);
} else {
target.value = nextValue;
const inputEvent = new Event("input", { bubbles: true });
target.dispatchEvent(inputEvent);
}
// Place caret right after the inserted newline.
requestAnimationFrame(() => {
target.selectionStart = start + 1;
target.selectionEnd = start + 1;
});
}
return;
}
e.preventDefault();
@ -1083,10 +1114,12 @@ export const PromptInputSubmit = ({
controller.attachments.files.length > 0
: false;
// 正在 streaming 时不允许发送
const isStreaming = status === "streaming" || status === "submitted";
const isDisabled = disabled || !hasContent || isStreaming;
const isStreaming = status === "streaming";
const isSubmitted = status === "submitted";
// Streaming 时按钮用于停止,不受输入内容是否为空限制
const isDisabled = isStreaming
? !!disabled
: disabled || !hasContent || isSubmitted;
let Icon = <ArrowUpIcon className="size-4" />;
@ -1113,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

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Tag({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="tag"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-transparent bg-[#EAE2F5] px-[15px] py-[4px] text-xs font-medium text-[#8E47F0]",
className,
)}
{...props}
/>
);
}
export { Tag };

View File

@ -37,11 +37,12 @@ interface ArtifactsProviderProps {
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
const [autoSelect, setAutoSelect] = useState(false);
const [open, setOpen] = useState(false);
// const [open, setOpen] = useState(
// env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
// );
const [autoOpen, setAutoOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const { setOpen: setSidebarOpen } = useSidebar();

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

@ -3,6 +3,7 @@ import { useCallback, useState, type ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import { copyToClipboard } from "@/lib/utils";
import { Tooltip } from "./tooltip";
@ -14,10 +15,14 @@ export function CopyButton({
}) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(clipboardData);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const handleCopy = useCallback(async () => {
try {
await copyToClipboard(clipboardData);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// no-op: caller controls error UI if needed
}
}, [clipboardData]);
return (
<Tooltip content={t.clipboard.copyToClipboard}>

View File

@ -28,6 +28,7 @@ export function IframeTestPanel() {
const iframeSkill = useIframeSkill();
const [log, setLog] = useState<string[]>([]);
const [open, setOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
@ -57,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() {
@ -168,224 +171,282 @@ export function IframeTestPanel() {
onPointerDown={handlePointerDown}
>
<span className="text-xs font-bold text-white">🧪 iframe </span>
<button
className="text-white/70 hover:text-white"
onClick={() => setOpen(false)}
>
</button>
<div className="flex items-center gap-2">
<button
className="text-white/70 hover:text-white"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => setCollapsed((prev) => !prev)}
>
{collapsed ? "▢" : "—"}
</button>
<button
className="text-white/70 hover:text-white"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => setOpen(false)}
>
</button>
</div>
</div>
<div className="space-y-3 p-3">
{/* 当前状态 */}
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
<div className="mb-1 font-semibold text-gray-500"></div>
<div className="flex flex-col gap-1">
<span>
<span className="text-gray-400">mode</span>
{!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-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

@ -1,10 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import type { ChatStatus } from "ai";
import {
CheckIcon,
GraduationCapIcon,
LightbulbIcon,
Loader2Icon,
PaperclipIcon,
PlusIcon,
SparklesIcon,
@ -40,7 +43,6 @@ import {
usePromptInputController,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button";
import {
@ -56,13 +58,11 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
} 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 { bootstrapRemoteSkill } from "@/core/skills/api";
import type { AgentThreadContext } from "@/core/threads";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils";
@ -86,6 +86,7 @@ import {
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
export function InputBox({
className,
@ -130,8 +131,9 @@ export function InputBox({
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
const iframeSkill = useIframeSkill();
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
const router = useRouter();
const threadId = threadIdFromProps;
const { textInput } = usePromptInputController();
const attachments = usePromptInputAttachments();
@ -326,7 +328,7 @@ export function InputBox({
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)}
disabled={disabled}
disabled={isInputDisabled}
globalDrop
multiple
onSubmit={handleSubmit}
@ -341,7 +343,7 @@ export function InputBox({
"size-full",
!effectiveIsFocused && "h-[80px] py-0 leading-20",
)}
disabled={disabled}
disabled={isInputDisabled}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
@ -364,7 +366,7 @@ export function InputBox({
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
)}
>
<PromptInputTools>
<PromptInputTools className="min-w-0 flex-1">
{/* TODO: Add more connectors here
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@ -374,13 +376,20 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<HistoryButton
className="px-2!"
router={router}
threadId={threadIdFromProps}
/>
<AddAttachmentsButton className="px-2!" />
<IframeSkillDialogButton
className="px-2!"
selectedSkill={iframeSkill.selectedSkill}
selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill}
/>
{/* 参考 kexue 版本隐藏运行模式切换按钮 */}
</PromptInputTools>
{/* <ModelSelector
@ -421,18 +430,20 @@ export function InputBox({
</PromptInputFooter>
<PromptInputSubmit
className="absolute right-3 bottom-5 z-[20] border-0"
disabled={disabled}
disabled={isInputDisabled}
variant="outline"
status={status}
/>
</PromptInput>
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
<SuggestionListContainer
threadId={threadId}
sendSelectSkill={iframeSkill.sendSelectSkill}
/>
)}
{showWelcomeStyle &&
!hasSubmitted &&
searchParams.get("mode") !== "skill" && (
<SuggestionListContainer
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
isBootstrapping={iframeSkill.isBootstrapping}
/>
)}
{!disabled &&
!showWelcomeStyle &&
@ -494,91 +505,82 @@ export function InputBox({
// SuggestionList 容器
function SuggestionListContainer({
threadId,
sendSelectSkill,
bootstrapAndLockSkills,
isBootstrapping,
}: {
threadId: string;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) {
return (
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
<SuggestionList threadId={threadId} sendSelectSkill={sendSelectSkill} />
<SuggestionList
bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping}
/>
</div>
);
}
// 快速选择skillbutton
function SuggestionList({
threadId,
sendSelectSkill,
bootstrapAndLockSkills,
isBootstrapping,
}: {
threadId: string;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
const { textInput } = usePromptInputController();
const suggestions = t.inputBox.suggestions;
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;
},
) => {
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const bootstrapByIds = (ids: string[]) => {
const content_ids = Array.from(
new Set(
ids
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (!threadId || content_ids.length === 0) return;
void bootstrapRemoteSkill({
thread_id: threadId,
content_ids,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
};
(suggestion: {
prompt: string;
skill_id?: string[];
children?: SelectedSkillPayloadItem[];
suggestion: string;
}) => {
if (isBootstrapping) return;
// 优先从 children 中提取 skill_id 数组,转换为 selectedSkills 发送给宿主页
const childSkillIds = (suggestion.children ?? [])
.map((item) => String(item.id).trim())
.filter((id): id is string => Boolean(id));
if (childSkillIds.length > 0) {
sendSelectSkill(
childSkillIds.map((id) => ({
id,
name: suggestion.suggestion,
})),
// 优先使用 children 中的 skill保留每个 skill 自己的 name用于 tag 展示)
const childSkills = (suggestion.children ?? [])
.map((item) => ({
id: String(item.id).trim(),
name: item.name?.trim() ?? "",
}))
.filter(
(item): item is { id: string; name: string } =>
Boolean(item.id) && Boolean(item.name),
);
bootstrapByIds(childSkillIds);
if (childSkills.length > 0) {
void bootstrapAndLockSkills({
selectedSkills: childSkills,
title: suggestion.suggestion,
});
return;
}
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
sendSelectSkill(
suggestion.skill_id.map((id) => ({
void bootstrapAndLockSkills({
selectedSkills: suggestion.skill_id.map((id) => ({
id,
name: suggestion.suggestion,
})),
);
bootstrapByIds(suggestion.skill_id);
title: suggestion.suggestion,
});
return;
}
// 原有逻辑
@ -598,10 +600,13 @@ function SuggestionList({
}
}, 500);
},
[textInput, sendSelectSkill, threadId, searchParams],
[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}
@ -644,25 +649,60 @@ function AddAttachmentsButton({ className }: { className?: string }) {
</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
function IframeSkillDialogButton({
className,
selectedSkill,
selectedSkills,
isBootstrapping,
openSkillDialog,
clearSkill,
}: {
className?: string;
selectedSkill: { skill_id: string; title: string } | null;
selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean;
openSkillDialog: () => void;
clearSkill: () => void;
clearSkill: (skillId?: string) => void;
}) {
const { t } = useI18n();
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Tooltip content={t.inputBox.selectSkill}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)}
onClick={openSkillDialog}
>
<svg
@ -678,20 +718,38 @@ function IframeSkillDialogButton({
</svg>
</PromptInputButton>
</Tooltip>
{selectedSkill && (
<Badge
variant="secondary"
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]"
{isBootstrapping ? (
<Tag className="bg-background text-muted-foreground gap-2 border">
<Loader2Icon className="size-3 animate-spin" />
{t.common.loading}
</Tag>
) : 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"
onWheel={(event) => {
if (event.deltaY === 0) return;
event.currentTarget.scrollLeft += event.deltaY;
}}
>
{selectedSkill.title}
<button
onClick={clearSkill}
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
>
<XIcon className="size-3" />
</button>
</Badge>
)}
{selectedSkills.map((skill, index) => (
<Tag
key={`${skill.skill_id}-${skill.title}-${index}`}
className="shrink-0"
>
{skill.title}
{/* TODO: 因为后端接口不支持取消选择skill所以暂时禁用取消选择按钮 */}
<button
onClick={() => clearSkill(skill.skill_id)}
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
type="button"
>
<XIcon className="size-3" />
</button>
</Tag>
))}
</div>
) : null}
</div>
);
}

View File

@ -13,6 +13,7 @@ import {
WrenchIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import type { BundledLanguage } from "shiki";
import {
ChainOfThought,
@ -39,6 +40,8 @@ import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content";
const TOOL_CONTENT_COLLAPSE_THRESHOLD = 320;
export function MessageGroup({
className,
messages,
@ -76,6 +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 rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<ChainOfThought
@ -87,14 +94,17 @@ export function MessageGroup({
key="above"
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setShowAbove(!showAbove)}
onClick={(event) => {
event.stopPropagation();
setShowAbove((prev) => !prev);
}}
>
<ChainOfThoughtStep
label={
<span className="opacity-60">
{showAbove
? t.toolCalls.lessSteps
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
: t.toolCalls.moreSteps(totalToolStepCount)}
</span>
}
icon={
@ -108,7 +118,7 @@ export function MessageGroup({
></ChainOfThoughtStep>
</Button>
)}
{lastToolCallStep && (
{shouldShowToolSteps && (
<ChainOfThoughtContent className="px-4 pb-2">
{showAbove &&
aboveLastToolCallSteps.map((step) =>
@ -145,7 +155,10 @@ export function MessageGroup({
key={lastReasoningStep.id}
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setShowLastThinking(!showLastThinking)}
onClick={(event) => {
event.stopPropagation();
setShowLastThinking((prev) => !prev);
}}
>
<div className="flex w-full items-center justify-between">
<ChainOfThoughtStep
@ -203,6 +216,33 @@ function ToolCall({
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
const [isCommandExpanded, setIsCommandExpanded] = useState(false);
const ExpandableToolContent = ({
content,
language = "bash",
expanded = false,
}: {
content: string;
language?: BundledLanguage;
expanded?: boolean;
}) => {
const shouldCollapse = content.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
const shouldShowCodeBlock = !shouldCollapse || expanded;
return (
<div className="space-y-1">
{shouldShowCodeBlock && (
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language={language}
code={content}
/>
)}
</div>
);
};
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@ -377,18 +417,34 @@ function ToolCall({
return t.toolCalls.executeCommand;
}
const command: string | undefined = (args as { command: string })?.command;
const shouldCollapse =
!!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
return (
<ChainOfThoughtStep
key={id}
label={description}
icon={SquareTerminalIcon}
action={
shouldCollapse ? (
<Button
className="h-7 px-3 text-xs"
variant="ghost"
onClick={(event) => {
event.stopPropagation();
setIsCommandExpanded((prev) => !prev);
}}
>
{isCommandExpanded
? t.toolCalls.collapseContent
: t.toolCalls.expandContent}
</Button>
) : undefined
}
>
{command && (
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language="bash"
code={command}
<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

@ -56,6 +56,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env";
import { copyToClipboard } from "@/lib/utils";
export function RecentChatList() {
const { t } = useI18n();
@ -119,7 +120,7 @@ export function RecentChatList() {
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`;
try {
await navigator.clipboard.writeText(shareUrl);
await copyToClipboard(shareUrl);
toast.success(t.clipboard.linkCopied);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
@ -178,7 +179,7 @@ export function RecentChatList() {
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread.thread_id)}
href={`${pathOfThread(thread.thread_id)}?is_chatting=true`}
>
{titleOfThread(thread)}
</Link>

View File

@ -42,7 +42,8 @@ export function WorkspaceHeader({ className }: { className?: string }) {
</Link>
) : (
<div className="text-primary ml-2 cursor-default font-serif">
XClaw
{/* TODO: 测试标识 */}
XClaw <span className="text-sm text-[#000000c5]">v3.2.4</span>
</div>
)}
<SidebarTrigger />

View File

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

View File

@ -81,6 +81,7 @@ export const enUS: Translations = {
sendMessagePrice:
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
addAttachments: "Add attachments",
history: "History",
selectSkill: "Select Skill",
mode: "Mode",
flashMode: "Flash",
@ -277,6 +278,8 @@ export const enUS: Translations = {
writeFile: "Write file",
clickToViewContent: "Click to view file content",
writeTodos: "Update to-do list",
expandContent: "Expand",
collapseContent: "Collapse",
skillInstallTooltip: "Install skill and make it available to DeerFlow",
},

View File

@ -5,7 +5,6 @@ export interface SelectedSkillPayloadItem {
name: string;
}
export interface Translations {
// Locale meta
locale: {
@ -72,6 +71,7 @@ export interface Translations {
placeholder: string;
createSkillPrompt: string;
addAttachments: string;
history: string;
selectSkill: string;
mode: string;
flashMode: string;
@ -208,6 +208,8 @@ export interface Translations {
writeFile: string;
clickToViewContent: string;
writeTodos: string;
expandContent: string;
collapseContent: string;
skillInstallTooltip: string;
};

View File

@ -12,6 +12,7 @@ import {
import type { Translations } from "./types";
export const zhCN: Translations = {
// 隐蔽版本标识Tagv3.2.1 feat: 宿主页复制
// Locale meta
locale: {
localName: "中文",
@ -57,8 +58,7 @@ export const zhCN: Translations = {
// Welcome
welcome: {
// TODO: 测试环境标识
greeting: "轻办公 · XClaw Tagv3.2.0 --- Skill功能施工中 --- refactor(frontend): 将 SELECT_SKILL 重命名为 SELECT_SKILLS.",
greeting: "轻办公 · XClaw",
description:
"欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。",
@ -83,6 +83,7 @@ export const zhCN: Translations = {
sendMessagePrice:
"请注意此功能将消耗token请保证账户余额大于200可学豆。",
addAttachments: "添加附件",
history: "历史记录",
selectSkill: "选择Skill",
mode: "模式",
flashMode: "闪速",
@ -265,6 +266,8 @@ export const zhCN: Translations = {
writeFile: "写入文件",
clickToViewContent: "点击查看文件内容",
writeTodos: "更新 To-do 列表",
expandContent: "展开",
collapseContent: "收起",
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
},

View File

@ -11,6 +11,8 @@ export const POST_MESSAGE_TYPES = {
FULLSCREEN: "fullscreen",
// 会话是否处于聊天态
IS_CHATTING: "isChatting",
// 请求宿主页执行复制
COPY_TO_CLIPBOARD: "copyToClipboard",
// 选择预定义 skill
SELECT_SKILLS: "selectedSkills",
// 打开 skill 选择对话框
@ -21,6 +23,8 @@ export const POST_MESSAGE_TYPES = {
export const RECEIVE_MESSAGE_TYPES = {
// 选中的 skill 数据
SELECTED_SKILL: "selectedSkill",
// 选中的 skills 数据(数组)
SELECTED_SKILLS: "selectedSkills",
} as const;
// 消息类型
@ -40,6 +44,11 @@ export interface IsChattingMessage {
isChatting: boolean;
}
export interface CopyToClipboardMessage {
type: typeof POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD;
text: string;
}
export interface SelectSkillMessage {
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
selectedSkills: SelectedSkillPayloadItem[];
@ -70,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;
@ -80,11 +91,33 @@ 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 {
const record = asRecord(value);
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) {
return false;
}
const selectedSkills = record.selectedSkills;
if (!Array.isArray(selectedSkills)) {
return false;
}
return selectedSkills.every((item) => {
const skill = asRecord(item);
if (!skill) return false;
const id = skill.id;
const name = skill.name;
const isValidId = typeof id === "string" || typeof id === "number";
return isValidId && typeof name === "string" && name.trim().length > 0;
});
}
// 发送消息的辅助函数
export function sendToParent(
message:
| FullscreenMessage
| IsChattingMessage
| CopyToClipboardMessage
| SelectSkillMessage
| OpenSkillDialogMessage,
): void {

View File

@ -62,5 +62,9 @@ export function getLocalSettings(): LocalSettings {
}
export function saveLocalSettings(settings: LocalSettings) {
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
void settings;
// 注释了,因为本地存储会污染模型配置
console.log("localStorage设置已经注释");
localStorage.removeItem(LOCAL_SETTINGS_KEY);
// localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
}

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

@ -46,27 +46,104 @@ export type LegacyThreadStreamOptions = {
useSubmitThread?: boolean;
};
const STREAM_ERROR_FALLBACK_MESSAGE = "Request failed.";
const STREAM_ERROR_TOAST_MESSAGE = "出现了某些错误。";
const STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS = 2000;
const STREAM_CANCEL_PATTERNS = [
/\bcancellederror\b/i,
/\bcancelled\b/i,
/\bcanceled\b/i,
/\babort(?:ed|error)?\b/i,
];
function readMessageCandidate(value: unknown): string | null {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (value instanceof Error && value.message.trim()) {
return value.message.trim();
}
return null;
}
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
const directMessage = readMessageCandidate(error);
if (directMessage) {
return directMessage;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message");
if (typeof message === "string" && message.trim()) {
const visited = new Set<object>();
const queue: unknown[] = [error];
const preferredKeys = ["message", "detail", "error"];
while (queue.length > 0) {
const current = queue.shift();
if (current == null) {
continue;
}
const message = readMessageCandidate(current);
if (message) {
return message;
}
const nestedError = Reflect.get(error, "error");
if (nestedError instanceof Error && nestedError.message.trim()) {
return nestedError.message;
if (typeof current !== "object") {
continue;
}
if (typeof nestedError === "string" && nestedError.trim()) {
return nestedError;
if (visited.has(current)) {
continue;
}
visited.add(current);
for (const key of preferredKeys) {
const candidate = Reflect.get(current, key);
const parsed = readMessageCandidate(candidate);
if (parsed) {
return parsed;
}
if (candidate && typeof candidate === "object") {
queue.push(candidate);
}
}
if (Array.isArray(current)) {
queue.push(...current);
continue;
}
for (const value of Object.values(current)) {
if (value && typeof value === "object") {
queue.push(value);
}
}
}
return "Request failed.";
return STREAM_ERROR_FALLBACK_MESSAGE;
}
function isStreamCancellation(error: unknown, message: string): boolean {
const direct =
typeof error === "object" &&
error !== null &&
"name" in error &&
typeof Reflect.get(error, "name") === "string"
? String(Reflect.get(error, "name"))
: "";
const candidates = [message, direct];
return candidates.some((value) =>
STREAM_CANCEL_PATTERNS.some((pattern) => pattern.test(value)),
);
}
function normalizeThreadId(
value: string | null | undefined,
): string | undefined {
if (!value) return undefined;
const normalized = value.trim();
if (!normalized || normalized === "new") return undefined;
return normalized;
}
export function useThreadStreamLegacy({
@ -142,6 +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 listeners = useRef({
onStart,
@ -155,12 +236,14 @@ export function useThreadStream({
}, [onStart, onFinish, onToolEnd]);
useEffect(() => {
const normalizedThreadId = threadId ?? null;
const normalizedThreadId = normalizeThreadId(threadId) ?? null;
if (!normalizedThreadId) {
// Just reset for new thread creation when threadId becomes null/undefined
startedRef.current = false;
setOnStreamThreadId(normalizedThreadId);
}
setOnStreamThreadId((prev) =>
prev === normalizedThreadId ? prev : normalizedThreadId,
);
threadIdRef.current = normalizedThreadId;
}, [threadId]);
@ -171,6 +254,28 @@ export function useThreadStream({
}
}, []);
const showStreamErrorToast = useCallback((error: unknown) => {
const message = getStreamErrorMessage(error);
if (isStreamCancellation(error, message)) {
// Cancellation is expected when user presses "Stop" or stream disconnects.
console.info("[useThreadStream] stream cancelled:", message);
return;
}
const now = Date.now();
const lastToast = lastErrorToastRef.current;
if (
lastToast &&
lastToast.message === message &&
now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS
) {
return;
}
lastErrorToastRef.current = { message, timestamp: now };
console.error("[useThreadStream] conversation stream error:", error);
console.error("[useThreadStream] parsed error message:", message);
toast.error(STREAM_ERROR_TOAST_MESSAGE);
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
@ -250,7 +355,7 @@ export function useThreadStream({
},
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
showStreamErrorToast(error);
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
@ -275,6 +380,13 @@ export function useThreadStream({
}
}, [thread.messages.length, optimisticMessages.length]);
useEffect(() => {
if (!thread.error) {
return;
}
showStreamErrorToast(thread.error);
}, [thread.error, showStreamErrorToast]);
const sendMessage = useCallback(
async (
threadId: string | undefined,
@ -288,7 +400,9 @@ export function useThreadStream({
const text = message.text.trim();
const resolvedThreadId =
threadId ?? threadIdRef.current ?? undefined;
normalizeThreadId(threadId) ??
normalizeThreadId(threadIdRef.current) ??
undefined;
if (resolvedThreadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry.");
sendInFlightRef.current = false;
@ -341,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

@ -4,6 +4,8 @@ import {
Paragraph,
TextRun,
HeadingLevel,
ImageRun,
type ParagraphChild,
} from "docx";
import { marked } from "marked";
@ -57,6 +59,10 @@ export interface DocxOptions {
* @default 22 (11pt)
*/
codeFontSize?: number;
/**
* Markdown
*/
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
}
// ============================================================================
@ -80,10 +86,18 @@ export async function downloadMarkdownAsDocx(
filename: string,
options: DocxOptions = {},
): Promise<void> {
const { codeFont = "Courier New", codeFontSize = 22 } = options;
const {
codeFont = "Courier New",
codeFontSize = 22,
resolveAssetUrl,
} = options;
const tokens = marked.lexer(markdown);
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
const children = await parseTokensToDocx(tokens, {
codeFont,
codeFontSize,
resolveAssetUrl,
});
const doc = new DocxDocument({
sections: [{ children }],
@ -112,7 +126,11 @@ export async function downloadMarkdownAsDocx(
export async function downloadMarkdownAsPdf(
markdown: string,
filename: string,
options: PdfOptions = {},
options: PdfOptions & {
resolveAssetUrl?: (
rawPath: string,
) => string | null | Promise<string | null>;
} = {},
): Promise<void> {
const html2pdf = await loadHtml2Pdf();
@ -121,10 +139,16 @@ export async function downloadMarkdownAsPdf(
format = "a4",
orientation = "portrait",
scale = 2,
resolveAssetUrl,
} = options;
const normalizedMarkdown = await rewriteMarkdownImageSources(
markdown,
resolveAssetUrl,
);
// 解析 Markdown 为 HTML
const htmlContent = await marked.parse(markdown);
const htmlContent = await marked.parse(normalizedMarkdown);
// 创建容器并应用样式
const container = createStyledContainer(htmlContent);
@ -309,16 +333,17 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void {
/**
* Markdown Token DOCX Paragraph
*/
function parseTokensToDocx(
async function parseTokensToDocx(
tokens: MarkdownToken[],
options: Required<DocxOptions>,
): Paragraph[] {
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
Pick<DocxOptions, "resolveAssetUrl">,
): Promise<Paragraph[]> {
const paragraphs: Paragraph[] = [];
for (const token of tokens) {
switch (token.type) {
case "heading": {
const runs = parseInlineTokens(token.tokens ?? [], options);
const runs = await parseInlineTokens(token.tokens ?? [], options);
paragraphs.push(
new Paragraph({
children: runs,
@ -330,7 +355,7 @@ function parseTokensToDocx(
}
case "paragraph": {
const runs = parseInlineTokens(token.tokens ?? [], options);
const runs = await parseInlineTokens(token.tokens ?? [], options);
paragraphs.push(
new Paragraph({
children: runs.length > 0 ? runs : [new TextRun("")],
@ -361,8 +386,8 @@ function parseTokensToDocx(
}
case "list": {
token.items?.forEach((item: MarkdownToken) => {
const runs = parseInlineTokens(
for (const item of token.items ?? []) {
const runs = await parseInlineTokens(
item.tokens?.[0]?.tokens ?? [],
options,
);
@ -373,12 +398,12 @@ function parseTokensToDocx(
spacing: { after: 80 },
}),
);
});
}
break;
}
case "blockquote": {
const runs = parseInlineTokens(
const runs = await parseInlineTokens(
token.tokens?.[0]?.tokens ?? [],
options,
);
@ -407,6 +432,19 @@ function parseTokensToDocx(
paragraphs.push(new Paragraph({ children: [] }));
break;
}
case "image": {
const imageRun = await createImageRunFromToken(token, options);
if (imageRun) {
paragraphs.push(
new Paragraph({
children: [imageRun],
spacing: { after: 200 },
}),
);
}
break;
}
}
}
@ -416,11 +454,12 @@ function parseTokensToDocx(
/**
* Token TextRun
*/
function parseInlineTokens(
async function parseInlineTokens(
tokens: MarkdownToken[],
options: Required<DocxOptions>,
): TextRun[] {
const runs: TextRun[] = [];
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
Pick<DocxOptions, "resolveAssetUrl">,
): Promise<ParagraphChild[]> {
const runs: ParagraphChild[] = [];
for (const token of tokens) {
switch (token.type) {
@ -460,6 +499,14 @@ function parseInlineTokens(
runs.push(new TextRun({ text: "", break: 1 }));
break;
case "image": {
const imageRun = await createImageRunFromToken(token, options);
if (imageRun) {
runs.push(imageRun);
}
break;
}
default:
runs.push(new TextRun(token.raw ?? ""));
}
@ -468,6 +515,157 @@ function parseInlineTokens(
return runs;
}
async function createImageRunFromToken(
token: MarkdownToken,
options: Pick<DocxOptions, "resolveAssetUrl">,
): Promise<ImageRun | null> {
const rawHref = String(token?.href ?? token?.text ?? "").trim();
if (!rawHref) return null;
const resolvedUrl = await resolveAssetReference(
rawHref,
options.resolveAssetUrl,
);
if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) {
return null;
}
try {
const response = await fetch(resolvedUrl);
if (!response.ok) {
return null;
}
const blob = await response.blob();
const imageType = getDocxImageType(blob.type, resolvedUrl);
if (!imageType) {
return null;
}
const bytes = new Uint8Array(await blob.arrayBuffer());
const { width, height } = await getImageDimensions(blob);
const maxWidth = 560;
const scale = width > maxWidth ? maxWidth / width : 1;
return new ImageRun({
data: bytes,
type: imageType,
transformation: {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
},
});
} catch {
return null;
}
}
async function getImageDimensions(
blob: Blob,
): Promise<{ width: number; height: number }> {
return await new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const width = img.naturalWidth || 1;
const height = img.naturalHeight || 1;
URL.revokeObjectURL(url);
resolve({ width, height });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve({ width: 600, height: 400 });
};
img.src = url;
});
}
async function rewriteMarkdownImageSources(
markdown: string,
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>,
): Promise<string> {
if (!resolveAssetUrl) {
return markdown;
}
let rewritten = markdown;
const markdownMatches = [...rewritten.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)];
for (const match of markdownMatches) {
const alt = match[1] ?? "";
const rawTarget = match[2]?.trim() ?? "";
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
if (!resolved || resolved === rawTarget) continue;
rewritten = rewritten.replace(match[0], `![${alt}](${resolved})`);
}
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);
if (!resolved || resolved === rawTarget) continue;
rewritten = rewritten.replace(
match[0],
`${match[1]}${match[2]}${resolved}${match[2]}`,
);
}
return rewritten;
}
async function resolveAssetReference(
rawPath: string,
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>,
): Promise<string | null> {
const normalized = normalizeReference(rawPath);
if (!normalized) return null;
if (isExternalReference(normalized)) return normalized;
if (!resolveAssetUrl) return normalized;
return (await resolveAssetUrl(normalized)) ?? normalized;
}
function normalizeReference(ref: string): string {
const trimmed = ref.trim().replace(/^<|>$/g, "");
return trimmed.split(/[ \t]/)[0] ?? "";
}
function isExternalReference(ref: string): boolean {
return (
!ref ||
ref.startsWith("#") ||
ref.startsWith("//") ||
ref.startsWith("data:") ||
ref.startsWith("blob:") ||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref)
);
}
function isRenderableImageUrl(url: string): boolean {
if (url.startsWith("data:image/")) return true;
if (/\.(png|jpe?g|gif|webp|bmp|ico|avif|tiff?)([?#].*)?$/i.test(url))
return true;
if (/^https?:\/\//i.test(url)) return true;
if (url.startsWith("/")) return true;
return false;
}
function getDocxImageType(
mimeType: string,
src: string,
): "png" | "jpg" | "gif" | "bmp" {
const mime = mimeType.toLowerCase();
if (mime.includes("png")) return "png";
if (mime.includes("jpeg") || mime.includes("jpg")) return "jpg";
if (mime.includes("gif")) return "gif";
if (mime.includes("bmp")) return "bmp";
const lower = src.toLowerCase();
if (lower.includes(".png")) return "png";
if (lower.includes(".jpg") || lower.includes(".jpeg")) return "jpg";
if (lower.includes(".gif")) return "gif";
if (lower.includes(".bmp")) return "bmp";
return "png";
}
/**
*
*/

View File

@ -1,6 +1,11 @@
import { useCallback, useState } from "react";
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
import {
downloadMarkdownAsDocx,
downloadMarkdownAsPdf,
type DocxOptions,
type PdfOptions,
} from "./converter";
/**
* Markdown Hook
@ -31,11 +36,23 @@ export interface UseMarkdownDownloadReturn {
/**
* DOCX
*/
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
downloadAsDocx: (
markdown: string,
filename: string,
options?: DocxOptions,
) => Promise<void>;
/**
* PDF
*/
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
downloadAsPdf: (
markdown: string,
filename: string,
options?: PdfOptions & {
resolveAssetUrl?: (
rawPath: string,
) => string | null | Promise<string | null>;
},
) => Promise<void>;
/**
*
*/
@ -82,14 +99,14 @@ export function useMarkdownDownload(
);
const downloadAsDocx = useCallback(
async (markdown: string, filename: string) => {
async (markdown: string, filename: string, options?: DocxOptions) => {
if (isDownloading) return;
setIsDownloading("docx");
onDownloadStart?.("docx");
try {
await downloadMarkdownAsDocx(markdown, filename);
await downloadMarkdownAsDocx(markdown, filename, options);
} catch (error) {
onError?.(
error instanceof Error ? error : new Error(String(error)),
@ -104,14 +121,22 @@ export function useMarkdownDownload(
);
const downloadAsPdf = useCallback(
async (markdown: string, filename: string) => {
async (
markdown: string,
filename: string,
options?: PdfOptions & {
resolveAssetUrl?: (
rawPath: string,
) => string | null | Promise<string | null>;
},
) => {
if (isDownloading) return;
setIsDownloading("pdf");
onDownloadStart?.("pdf");
try {
await downloadMarkdownAsPdf(markdown, filename);
await downloadMarkdownAsPdf(markdown, filename, options);
} catch (error) {
onError?.(
error instanceof Error ? error : new Error(String(error)),

View File

@ -1,13 +1,16 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "sonner";
import {
POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES,
isSelectedSkillMessage,
isSelectedSkillsMessage,
type SelectedSkillPayloadItem,
sendToParent,
} from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api";
// Skill 数据类型
interface SkillData {
@ -15,22 +18,127 @@ interface SkillData {
title: string;
}
const STORAGE_KEYS = {
latest: "iframe:selectedSkills:latest",
byThreadPrefix: "iframe:selectedSkills:thread:",
} as const;
function getThreadStorageKey(threadId?: string | null): string | null {
const normalized = threadId?.trim();
if (!normalized) return null;
return `${STORAGE_KEYS.byThreadPrefix}${normalized}`;
}
function parseStoredSkills(raw: string | null): SkillData[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map((item) => {
if (typeof item !== "object" || item === null) return null;
const record = item as Record<string, unknown>;
const skillId = String(record.skill_id ?? "").trim();
const title = String(record.title ?? "").trim();
if (!skillId || !title) return null;
return { skill_id: skillId, title };
})
.filter((item): item is SkillData => item !== null);
} catch {
return [];
}
}
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)));
}
// Hook 返回类型
interface UseIframeSkillReturn {
selectedSkill: SkillData | null;
selectedSkills: SkillData[];
isBootstrapping: boolean;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
openSkillDialog: () => void;
clearSkill: () => void;
clearSkill: (skillId?: string) => void;
}
export function useIframeSkill(): UseIframeSkillReturn {
interface UseIframeSkillOptions {
threadId?: string | null;
}
export function useIframeSkill(
options?: UseIframeSkillOptions,
): UseIframeSkillReturn {
const router = useRouter();
const searchParams = useSearchParams();
const threadIdFromQuery = searchParams.get("thread_id");
const threadId = options?.threadId?.trim() || threadIdFromQuery;
const isChattingFromQuery = searchParams.get("is_chatting");
const lastThreadIdRef = useRef<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [selectedSkills, setSelectedSkills] = useState<SkillData[]>([]);
const [isBootstrapping, setIsBootstrapping] = useState(false);
const removeFailedSkills = useCallback(
(skillIds: string[]) => {
if (skillIds.length === 0) return;
// 1) 回滚内存状态:移除失败 skill避免展示错误 tag
setSelectedSkills((prev) => {
const next = removeSkillsByIdsFromList(prev, skillIds);
setSelectedSkill(next[0] ?? null);
return next;
});
// 2) 回滚 localStoragelatest + thread
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const nextLatestSkills = removeSkillsByIdsFromList(
latestSkills,
skillIds,
);
if (nextLatestSkills.length > 0) {
window.localStorage.setItem(
STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills),
);
} else {
window.localStorage.removeItem(STORAGE_KEYS.latest);
}
const threadKey = getThreadStorageKey(threadId);
if (threadKey) {
const threadSkills = parseStoredSkills(
window.localStorage.getItem(threadKey),
);
const nextThreadSkills = removeSkillsByIdsFromList(
threadSkills,
skillIds,
);
if (nextThreadSkills.length > 0) {
window.localStorage.setItem(
threadKey,
JSON.stringify(nextThreadSkills),
);
} else {
window.localStorage.removeItem(threadKey);
}
}
},
[threadId],
);
// 1. 监听 query 参数变化(临时禁用)
// TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。
@ -43,37 +151,188 @@ export function useIframeSkill(): UseIframeSkillReturn {
// }, [searchParams]);
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
useEffect(() => {
if (!threadIdFromQuery) return;
if (isChattingFromQuery !== "true") return;
if (lastThreadIdRef.current === threadIdFromQuery) return;
lastThreadIdRef.current = threadIdFromQuery;
router.replace(`/workspace/chats/${threadIdFromQuery}`);
}, [isChattingFromQuery, router, threadIdFromQuery]);
// useEffect(() => {
// if (!threadId) return;
// if (isChattingFromQuery !== "true") return;
// if (lastThreadIdRef.current === threadId) return;
// lastThreadIdRef.current = threadId;
// router.replace(`/workspace/chats/${threadId}`);
// }, [isChattingFromQuery, router, threadId]);
// 2. 监听宿主页 postMessage
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
if (isSelectedSkillMessage(event.data)) {
const { id, title } = event.data;
const singleSkill = { skill_id: String(id), title };
setSelectedSkill(singleSkill);
setSelectedSkills([singleSkill]);
return;
}
if (!isSelectedSkillMessage(event.data)) {
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
if (isSelectedSkillsMessage(event.data)) {
const normalizedSkills = event.data.selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
if (normalizedSkills.length === 0) {
setSelectedSkill(null);
setSelectedSkills([]);
return;
}
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
return;
}
const { id, title } = event.data;
setSelectedSkill({ skill_id: String(id), title });
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
console.warn(
"[useIframeSkill] 忽略非法 selectedSkill 消息",
event.data,
);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// 3. 首次进入时恢复 localStorage 中上次选择的 skill线程优先其次全局
useEffect(() => {
const threadKey = getThreadStorageKey(threadId);
const threadSkills = threadKey
? parseStoredSkills(window.localStorage.getItem(threadKey))
: [];
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const restoredSkills =
threadSkills.length > 0 ? threadSkills : latestSkills;
if (restoredSkills.length === 0) return;
setSelectedSkills(restoredSkills);
setSelectedSkill(restoredSkills[0] ?? null);
}, [threadId]);
// 4. 选择变化时同步到 localStorage
useEffect(() => {
const threadKey = getThreadStorageKey(threadId);
if (selectedSkills.length === 0) {
// 空数组也要同步到存储,避免 UI 状态与缓存不一致
window.localStorage.removeItem(STORAGE_KEYS.latest);
if (threadKey) {
window.localStorage.removeItem(threadKey);
}
return;
}
const payload = JSON.stringify(selectedSkills);
window.localStorage.setItem(STORAGE_KEYS.latest, payload);
if (threadKey) {
window.localStorage.setItem(threadKey, payload);
}
}, [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 ({
selectedSkills,
title,
}: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => {
if (!threadId) {
toast.error("技能加载失败", {
description: "缺少 thread_id无法初始化技能",
});
return false;
}
const content_ids = Array.from(
new Set(
selectedSkills
.map((item) => Number(String(item.id).trim()))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (content_ids.length === 0) {
toast.error("技能加载失败", {
description: "无效的 skill_id",
});
return false;
}
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, {
id: "suggest-skill-bootstrap",
});
try {
const result = await bootstrapRemoteSkill({
thread_id: threadId,
content_ids,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
toast.dismiss("suggest-skill-bootstrap");
if (!result.success) {
const failedIds = selectedSkills.map((item) =>
String(item.id).trim(),
);
removeFailedSkills(failedIds);
toast.error(`技能「${title}」加载失败`, {
description: result.message || "未知错误",
});
return false;
}
sendSelectSkill(selectedSkills);
const normalizedSkills = selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
toast.success(`技能「${title}」加载成功`, {
description:
result.message || `已创建 ${result.created_files} 个文件`,
});
return true;
} catch (error) {
const failedIds = selectedSkills.map((item) => String(item.id).trim());
removeFailedSkills(failedIds);
toast.dismiss("suggest-skill-bootstrap");
const message = error instanceof Error ? error.message : "网络请求失败";
toast.error(`技能「${title}」加载失败`, {
description: message,
});
return false;
} finally {
setIsBootstrapping(false);
}
},
[removeFailedSkills, searchParams, sendSelectSkill, threadId],
);
// 打开 skill 选择对话框
const openSkillDialog = useCallback(() => {
@ -86,13 +345,66 @@ export function useIframeSkill(): UseIframeSkillReturn {
}, []);
// 清除选中并发送空 selectedSkills 数组给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
// 发送空数组给主页,通知取消选择
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
sendToParent(message);
}, []);
const clearSkill = useCallback(
(skillId?: string) => {
const removeAll = !skillId;
const nextSelectedSkills = removeAll
? []
: selectedSkills.filter((skill) => skill.skill_id !== String(skillId));
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
setSelectedSkills(nextSelectedSkills);
setSelectedSkill(nextSelectedSkills[0] ?? null);
// 同步 latest 缓存:仅删除对应 skill或全部清空
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const nextLatestSkills = removeAll
? []
: latestSkills.filter((skill) => skill.skill_id !== String(skillId));
if (nextLatestSkills.length > 0) {
window.localStorage.setItem(
STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills),
);
} else {
window.localStorage.removeItem(STORAGE_KEYS.latest);
}
// 同步线程缓存:保存剩余数组,空则删除 key
const threadKey = getThreadStorageKey(threadId);
if (threadKey) {
if (nextSelectedSkills.length > 0) {
window.localStorage.setItem(
threadKey,
JSON.stringify(nextSelectedSkills),
);
} else {
window.localStorage.removeItem(threadKey);
}
}
// 通知宿主页当前剩余技能
const message = {
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: nextSelectedSkills.map((skill) => ({
id: skill.skill_id,
name: skill.title,
})),
} as const;
console.log("[useIframeSkill] clearSkill:", message);
sendToParent(message);
},
[selectedSkills, threadId],
);
return {
selectedSkill,
selectedSkills,
isBootstrapping,
sendSelectSkill,
bootstrapAndLockSkills,
openSkillDialog,
clearSkill,
};
}

View File

@ -2,7 +2,11 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useCallback, useState, useRef } from "react";
import { toast } from "sonner";
import { isSelectedSkillMessage } from "@/core/iframe-messages";
import {
isSelectedSkillMessage,
isSelectedSkillsMessage,
type SelectedSkillPayloadItem,
} from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 技能基础数据 */
@ -51,14 +55,20 @@ export function useSelectedSkillListener({
const skillBootstrappedKeyRef = useRef<string | null>(null);
const performBootstrap = useCallback(
async (id: number | string, title: string) => {
async (skills: SelectedSkillPayloadItem[], title: string) => {
if (!threadId) return;
const contentId = Number(id);
if (!Number.isFinite(contentId) || contentId <= 0) {
console.warn("[useSelectedSkillListener] 忽略非法 skill id", id);
const contentIds = Array.from(
new Set(
skills
.map((skill) => Number(String(skill.id).trim()))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (contentIds.length === 0) {
console.warn("[useSelectedSkillListener] 忽略非法 skill ids", skills);
setSkillError({
title: `技能「${title}」加载失败`,
message: `非法 skill id: ${String(id)}`,
message: "非法 skill_id 数组",
});
return;
}
@ -68,13 +78,13 @@ export function useSelectedSkillListener({
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const initKey = `${threadId}:${id}:${languageType}`;
const initKey = `${threadId}:${contentIds.join(",")}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) {
return;
}
console.log(
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`,
`[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`,
);
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
@ -82,7 +92,7 @@ export function useSelectedSkillListener({
try {
const result = await bootstrapRemoteSkill({
thread_id: threadId,
content_ids: [contentId],
content_ids: contentIds,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
@ -123,23 +133,39 @@ export function useSelectedSkillListener({
if (skillIdFromQuery && titleFromQuery) {
isFirstLoadRef.current = true;
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
void performBootstrap(skillIdFromQuery, titleFromQuery);
void performBootstrap(
[{ id: skillIdFromQuery, name: titleFromQuery }],
titleFromQuery,
);
}
}, [threadId, searchParams, performBootstrap]);
const handleMessage = useCallback(
(event: MessageEvent) => {
if (!isSelectedSkillMessage(event.data)) return;
const data = event.data;
if (isSelectedSkillMessage(event.data)) {
const data = event.data;
const { id, title } = data;
console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
data,
);
setSelectedSkill({ skill_id: String(id), title });
void performBootstrap([{ id, name: title }], title);
return;
}
const { id, title } = data;
console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
data,
);
setSelectedSkill({ skill_id: String(id), title });
void performBootstrap(id, title);
if (isSelectedSkillsMessage(event.data)) {
const { selectedSkills } = event.data;
if (!selectedSkills.length) return;
const first = selectedSkills[0]!;
const firstTitle = first.name;
console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkills:",
event.data,
);
setSelectedSkill({ skill_id: String(first.id), title: firstTitle });
void performBootstrap(selectedSkills, firstTitle);
}
},
[performBootstrap],
);

View File

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@ -18,14 +19,14 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
export async function copyToClipboard(text: string): Promise<void> {
const isInIframe = window.self !== window.top;
const message = {
type: "copyToClipboard",
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
text,
};
} as const;
if (isInIframe && window.parent) {
if (isInIframe) {
try {
// Request parent window to copy
window.parent.postMessage(message, "*");
sendToParent(message);
console.log(
"[copyToClipboard] iframe mode → postMessage to parent",
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;
} */
@ -411,6 +411,13 @@
--container-width-lg: calc(var(--spacing) * 256);
}
html,
body {
font-family:
"Microsoft YaHei", "微软雅黑", "PingFang SC", ui-sans-serif, system-ui,
sans-serif;
}
/* ========================================
Streamdown Markdown Styles
使用 data-streamdown 属性选择器统一定义
@ -431,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 */
@ -452,9 +460,9 @@ pre {
font-size: calc(16px * var(--zoom-scale));
}
/* 二三级标题 - 16px */
/* 代码块 - 14px */
[data-streamdown="code-block"] pre {
font-size: calc(16px * var(--zoom-scale));
font-size: calc(14px * var(--zoom-scale));
}
.cm-line {
@ -485,3 +493,11 @@ pre {
overflow: hidden;
contain: paint;
}
.pptx-preview-wrap {
height: 100%;
}
.pptx-preview-wrap .pptx-preview-wrapper {
height: 100% !important;
}

View File

@ -0,0 +1,235 @@
import {
expect,
test,
type Locator,
type Page,
type TestInfo,
} from "@playwright/test";
import { v4 as uuid } from "uuid";
import {
newChatEntry,
openChat,
sendMessage,
waitForMessageListReady,
} from "./support/chat-helpers";
const FILE_CASES = [
{ kind: "html", label: "html", regex: /\.html?/i },
{
kind: "image",
label: "image",
regex: /\.(png|jpe?g|gif|webp|bmp|svg|ico|avif|tiff?)/i,
},
{ kind: "md", label: "md", regex: /\.md/i },
{ kind: "docx", label: "docx", regex: /\.docx?/i },
{ kind: "pptx", label: "pptx", regex: /\.pptx?/i },
{ kind: "xlsx", label: "xlsx", regex: /\.xlsx?/i },
] as const;
test.use({
video: "on",
screenshot: "on",
});
test.describe("聊天工作台 / 智能体产物生成预览与下载", () => {
test("DF-ART-GEN-001 生成并逐个点击 html/image/md/docx/pptx/xlsx 卡片截图", async ({
page,
}, testInfo) => {
const startedAt = Date.now();
test.setTimeout(12 * 60 * 1000);
const threadId = uuid();
logStatus("开始测试", `threadId=${threadId}`);
await openChat(page, newChatEntry(threadId));
await waitForMessageListReady(page, { requireMessages: false });
logStatus("发送生成指令");
await sendMessage(page, buildGeneratePrompt());
await waitForArtifactsReady(page, FILE_CASES, startedAt);
await openArtifactPanel(page);
logStatus("Artifacts 列表已就绪,开始逐类校验");
await capture(page, testInfo, "artifact-list-ready");
for (const file of FILE_CASES) {
logStatus("校验文件类型", file.label);
const card = artifactCardByPattern(page, file.regex);
await expect(card).toBeVisible();
await card.click();
logStatus("点击并截图", file.label);
await waitAfterCardClick(page, file.kind);
await capture(page, testInfo, `card-clicked-${file.label}`);
logStatus("类型处理完成", file.label);
}
logStatus("测试完成");
});
});
function buildGeneratePrompt(): string {
return [
"请一次性创建以下 6 个文件到 /mnt/user-data/outputs并在完成后调用 present_files",
"1) e2e-artifacts-page.html包含标题 DF_E2E_HTML 和一段正文。",
"2) e2e-artifacts-image.png生成一张包含文字 DF_E2E_IMAGE 的图片。",
"3) e2e-artifacts-notes.md标题为 DF_E2E_MD并引用上面的图片。",
"4) e2e-artifacts-report.docx包含标题 DF_E2E_DOCX 和一段文字。",
"5) e2e-artifacts-slides.pptx至少 2 页,包含 DF_E2E_PPTX。",
"6) e2e-artifacts-table.xlsx至少 2 列 3 行,并包含 DF_E2E_XLSX。",
"注意:所有文件都要真实写入输出目录,不要只在回复里描述。",
].join("\n");
}
async function openArtifactPanel(page: Page): Promise<void> {
const button = page.getByTestId("artifacts-open-button");
await expect(button).toBeVisible({ timeout: 120_000 });
await button.click();
await expect(page.getByTestId("artifact-file-list").first()).toBeVisible();
}
async function waitForArtifactsReady(
page: Page,
requiredCases: ReadonlyArray<(typeof FILE_CASES)[number]>,
startedAt: number,
): Promise<void> {
let pollRound = 0;
await expect
.poll(
async () => {
pollRound += 1;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
const list = page.getByTestId("artifact-file-list").first();
// 1) 优先直接检查已展示的 artifact-file-list
if (!(await list.isVisible().catch(() => false))) {
// 2) 列表不存在时再尝试通过按钮打开
const openButton = page.getByTestId("artifacts-open-button").first();
if (!(await openButton.isVisible().catch(() => false))) {
logStatus(
"等待 artifacts 入口或列表出现",
`轮次=${pollRound}, 已耗时=${elapsedSeconds}s`,
);
return false;
}
await openButton.click();
await expect(
page.getByTestId("artifact-file-list").first(),
).toBeVisible({
timeout: 5_000,
});
}
const allFileNames = await getArtifactFileNames(page);
const found = requiredCases
.filter((fileCase) =>
allFileNames.some((name) => fileCase.regex.test(name)),
)
.map((fileCase) => fileCase.label);
const missing = requiredCases
.filter(
(fileCase) =>
!allFileNames.some((name) => fileCase.regex.test(name)),
)
.map((fileCase) => fileCase.label);
logStatus(
"等待文件类型齐全",
`轮次=${pollRound}, 已耗时=${elapsedSeconds}s, 已找到=[${found.join(", ")}], 缺失=[${missing.join(", ")}]`,
);
return missing.length === 0;
},
{
timeout: 8 * 60 * 1000,
intervals: [1000, 2000, 3000, 5000],
},
)
.toBeTruthy();
}
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();
}
async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
if (kind === "docx") {
await expect(page.locator(".docx-preview-wrap").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "xlsx") {
await expect(page.locator("#artifact-xlsx-preview").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "pptx") {
await expect(
page.getByText("请下载ppt文件以获得最佳效果").first(),
).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "md") {
await page.waitForTimeout(1200);
return;
}
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible({
timeout: 30_000,
});
}
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, {
path,
contentType: "image/png",
});
}
function logStatus(step: string, detail?: string): void {
const timestamp = new Date().toISOString();
if (detail) {
console.log(`[E2E][${timestamp}] ${step} | ${detail}`);
return;
}
console.log(`[E2E][${timestamp}] ${step}`);
}
async function getArtifactFileNames(page: Page): Promise<string[]> {
const titleNodes = page.locator(
"[data-testid='artifact-file-card'] [data-slot='card-title'] div[title]",
);
const titleCount = await titleNodes.count();
if (titleCount > 0) {
const names: string[] = [];
for (let i = 0; i < titleCount; i += 1) {
const value = (await titleNodes.nth(i).getAttribute("title"))?.trim();
if (value) {
names.push(value);
}
}
return names;
}
// fallback: if title attr is absent, use first text line of each card
const cardTexts = await page
.getByTestId("artifact-file-card")
.allTextContents();
return cardTexts
.map((text) => text.split("\n")[0]?.trim() ?? "")
.filter(Boolean);
}

View File

@ -1,11 +1,13 @@
import { expect, test } from "@playwright/test";
import {
PRIMARY_THREAD_ID,
THREAD_WITH_ARTIFACTS,
THREAD_WITH_HTML_ARTIFACT,
THREAD_WITH_IMAGE_ARTIFACT,
openChat,
reuseThreadChatEntry,
sendMessage,
skipIfMissingThread,
waitForAnyMessages,
waitForMessageListReady,
@ -51,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(
@ -80,15 +84,31 @@ 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(
(await htmlFile.count()) === 0,
"当前线程没有 HTML artifact。",
);
const htmlArtifactResponsePromise = page.waitForResponse((response) => {
const url = decodeURIComponent(response.url());
return (
response.status() === 200 &&
/\/api\/threads\/[^/]+\/artifacts\//.test(url) &&
/\.html?($|\?)/i.test(url)
);
});
await htmlFile.click();
const htmlArtifactResponse = await htmlArtifactResponsePromise;
expect(
htmlArtifactResponse.headers()["content-disposition"] ?? "",
).toContain("attachment;");
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
});
@ -117,4 +137,31 @@ test.describe("聊天工作台 / Artifact 面板", () => {
await expect(page.getByTestId("artifacts-open-button")).toBeVisible();
await expect(page.getByRole("log").first()).toBeVisible();
});
test("DF-ART-005 生成简单 HTML 后出现 artifact-file-card", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
await waitForMessageListReady(page, { requireMessages: false });
await sendMessage(page, "生成一个简单的html文件");
await expect
.poll(
async () => await page.getByTestId("artifacts-open-button").count(),
{ timeout: 120_000 },
)
.toBeGreaterThan(0);
await page.getByTestId("artifacts-open-button").click();
await expect
.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

@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
import {
PRIMARY_THREAD_ID,
openChat,
reuseThreadChatEntry,
skipIfMissingThread,
waitForMessageListReady,
} from "./support/chat-helpers";
test.use({
video: "on",
});
test.describe("聊天工作台 / 错误提示", () => {
test("DF-ERR-001 对话流失败时显示错误 toast", async ({ page }, testInfo) => {
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
await waitForMessageListReady(page, { requireMessages: false });
await page.route("**/*", async (route) => {
const request = route.request();
if (request.method() === "POST" && /\/runs\b/.test(request.url())) {
await route.abort("failed");
return;
}
await route.continue();
});
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
await textarea.fill("触发错误 toast 测试");
await submit.click({ force: true });
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "出现了某些错误。" })
.first(),
).toBeVisible({ timeout: 10_000 });
});
test("DF-ERR-002 相同错误短时间不重复弹 toast", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
await waitForMessageListReady(page, { requireMessages: false });
await page.route("**/*", async (route) => {
const request = route.request();
if (request.method() === "POST" && /\/runs\b/.test(request.url())) {
await route.abort("failed");
return;
}
await route.continue();
});
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
const errorToasts = page.locator('[data-sonner-toast][data-type="error"]');
await textarea.fill("触发重复错误 toast 测试 1");
await submit.click({ force: true });
await expect(errorToasts.first()).toBeVisible({ timeout: 10_000 });
await expect(errorToasts).toHaveCount(1);
// 在去重窗口2s内再次触发同类错误不应新增 toast
await textarea.fill("触发重复错误 toast 测试 2");
await submit.click({ force: true });
await expect(errorToasts).toHaveCount(1);
});
});

View File

@ -1,17 +1,26 @@
import { expect, test } from "@playwright/test";
import { v4 as uuid } from "uuid";
import {
THREAD_FOR_WELCOME,
newChatEntry,
openChat,
reuseThreadChatEntry,
sendMessage,
skipIfMissingThread,
waitForAnyMessages,
waitForMessageListReady,
} from "./support/chat-helpers";
test.use({
screenshot: "on",
video: "on",
});
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!));
@ -26,7 +35,73 @@ 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();
});
test("/new 使用 uuid thread_id 发送后触发 stream(cod=0) 并进入 thread 路由", async ({
page,
}) => {
const threadId = uuid();
const text = `e2e-${threadId.slice(0, 8)}`;
await openChat(page, newChatEntry(threadId));
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
const streamRequestPromise = page.waitForRequest(
(request) => {
const url = request.url();
if (!url.includes("/stream")) return false;
if (!url.includes(threadId)) return false;
try {
const parsed = new URL(url);
return parsed.searchParams.get("cancel_on_disconnect") === "0";
} catch {
return url.includes("cancel_on_disconnect=0");
}
},
{ timeout: 30_000 },
);
await sendMessage(page, text);
await expect(
page.locator(".is-user").filter({ hasText: text }),
).toHaveCount(1);
await expect
.poll(async () => await page.locator(".is-assistant").count(), {
timeout: 30_000,
})
.toBeGreaterThan(0);
const streamRequest = await streamRequestPromise;
expect(streamRequest.url()).toContain("cancel_on_disconnect=0");
await expect(page).toHaveURL(
new RegExp(`/workspace/chats/${threadId}\\?is_chatting=true`),
{ timeout: 30_000 },
);
});
test("streaming 中点击停止可中断输出", async ({ page }) => {
const threadId = uuid();
const text =
"请逐行输出 1 到 500 的数字并在每一行前面加上“第N行”前缀不要省略。";
await openChat(page, newChatEntry(threadId));
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
await sendMessage(page, text);
const submitButton = page.locator("button[aria-label='Submit']");
await expect(submitButton).toHaveText("停止", { timeout: 30_000 });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// 点击停止后应退出 streaming 态,按钮文本不再是“停止”
await expect(submitButton).toHaveText("发送", { timeout: 30_000 });
});
});

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