Compare commits
43 Commits
a5cf6c87e5
...
6c618fadfb
| Author | SHA1 | Date |
|---|---|---|
|
|
6c618fadfb | |
|
|
80c964de09 | |
|
|
c6c06e93a7 | |
|
|
02daf287f2 | |
|
|
5d3e88a4dd | |
|
|
9fb74a1132 | |
|
|
569df14659 | |
|
|
6e99f8cb37 | |
|
|
e742fbc521 | |
|
|
ff0f64c98a | |
|
|
ef80a2fd2c | |
|
|
2d7cbcf34e | |
|
|
9d48990605 | |
|
|
77cc6aa7af | |
|
|
cc95d4dac6 | |
|
|
96dc81edf7 | |
|
|
e45e355dbb | |
|
|
56931c6c8b | |
|
|
90e0dc1a14 | |
|
|
ba9af026f7 | |
|
|
4123bc7673 | |
|
|
0c1a293fbd | |
|
|
c9116e2fb2 | |
|
|
c231c9f41f | |
|
|
a70f5a3902 | |
|
|
cec5a7c615 | |
|
|
1061777c34 | |
|
|
5e0d316bb4 | |
|
|
be21f8a99a | |
|
|
f3f87e220e | |
|
|
5606519619 | |
|
|
13096b98a1 | |
|
|
9d14c9ffd8 | |
|
|
01964efc20 | |
|
|
72023c513b | |
|
|
7bdc9777bf | |
|
|
2edddff141 | |
|
|
7eb55d26d8 | |
|
|
bd93e6ef4d | |
|
|
fc0118e6a5 | |
|
|
f822deadf3 | |
|
|
68256eb8b4 | |
|
|
4bbbab24ca |
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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 `\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`"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;")
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
确定
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,7 @@ const OPEN_MODE = { chat: 60, artifacts: 40 };
|
|||
const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string | undefined;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
}> = ({ children, threadId }) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
export function useThreadChat() {
|
||||
const pathname = usePathname();
|
||||
const params = useParams<{ thread_id: string }>();
|
||||
|
|
@ -45,7 +44,6 @@ export function useThreadChat() {
|
|||
return threadIdFromPathOrParams ?? "";
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
||||
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 场景 2:skill 选择通信 */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
② postMessage 通信(发送到宿主)
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||
variant="ghost"
|
||||
onClick={handleSendSelectSkill}
|
||||
>
|
||||
sendSelectSkill(单个)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||
variant="ghost"
|
||||
onClick={handleSendSelectSkillArray}
|
||||
>
|
||||
sendSelectSkill(数组)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||
variant="ghost"
|
||||
onClick={handleOpenSkillDialog}
|
||||
>
|
||||
openSkillDialog
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-red-50 text-xs text-red-600 hover:bg-red-100"
|
||||
variant="ghost"
|
||||
onClick={handleClearSkill}
|
||||
>
|
||||
clearSkill (发送 skill_id=[])
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 3:接收宿主页 selectedSkill */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
③ 接收宿主页 selectedSkill
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-green-50 text-xs text-green-700 hover:bg-green-100"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.postMessage(
|
||||
{ type: "selectedSkill", id: 5, title: "文档处理" },
|
||||
"*",
|
||||
);
|
||||
addLog(
|
||||
"模拟宿主页 → selectedSkill { id: 5, title: '文档处理' }",
|
||||
);
|
||||
}}
|
||||
>
|
||||
✅ 模拟 selectedSkill(成功)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-cyan-50 text-xs text-cyan-700 hover:bg-cyan-100"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
||||
selectedSkills: [
|
||||
{ id: "5", name: "文档处理" },
|
||||
{ id: "1216", name: "市场研究报告" },
|
||||
{ id: "1245", name: "市场研究报告" },
|
||||
{ id: "520", name: "市场研究报告" },
|
||||
{ id: "409", name: "市场研究报告" },
|
||||
],
|
||||
},
|
||||
"*",
|
||||
);
|
||||
addLog(
|
||||
"模拟宿主页 → selectedSkills [{id:'5',name:'文档处理'},{id:'1216',name:'市场研究报告'}]",
|
||||
);
|
||||
}}
|
||||
>
|
||||
📦 模拟 selectedSkills(数组 message)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
||||
selectedSkills: [],
|
||||
},
|
||||
"*",
|
||||
);
|
||||
addLog("模拟宿主页 → selectedSkills []");
|
||||
}}
|
||||
>
|
||||
🧹 模拟 selectedSkills(空数组)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "selectedSkill",
|
||||
id: 999999,
|
||||
title: "不存在的技能",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
addLog(
|
||||
"模拟宿主页 → selectedSkill { id: 999999, title: '不存在的技能' }",
|
||||
);
|
||||
}}
|
||||
>
|
||||
❌ 模拟 selectedSkill(失败/错误)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 4:剪贴板复制(iframe 通信) */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-500">
|
||||
④ 剪贴板复制(iframe 通信)
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono font-bold",
|
||||
isSkillMode ? "text-violet-600" : "text-gray-400",
|
||||
"rounded px-1.5 py-0.5 text-[10px] font-medium",
|
||||
isInIframe
|
||||
? "bg-violet-100 text-violet-700"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{isSkillMode ? "skill ✅" : "普通"}
|
||||
{isInIframe ? "iframe 模式" : "独立页面"}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-gray-400">selectedSkill:</span>
|
||||
<span className="font-mono text-violet-600">
|
||||
{iframeSkill.selectedSkill
|
||||
? `${iframeSkill.selectedSkill.skill_id} / ${iframeSkill.selectedSkill.title}`
|
||||
: "无"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 1:侧边栏隐藏 */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
① 侧边栏隐藏(layout)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
variant="outline"
|
||||
onClick={handleEnterSkillMode}
|
||||
>
|
||||
进入 skill 模式
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
variant="outline"
|
||||
onClick={handleExitSkillMode}
|
||||
>
|
||||
退出 skill 模式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 2:skill 选择通信 */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
② postMessage 通信(发送到宿主)
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||
variant="ghost"
|
||||
onClick={handleSendSelectSkill}
|
||||
>
|
||||
sendSelectSkill(单个)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||
variant="ghost"
|
||||
onClick={handleSendSelectSkillArray}
|
||||
>
|
||||
sendSelectSkill(数组)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
|
||||
variant="ghost"
|
||||
onClick={handleOpenSkillDialog}
|
||||
>
|
||||
openSkillDialog
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-red-50 text-xs text-red-600 hover:bg-red-100"
|
||||
variant="ghost"
|
||||
onClick={handleClearSkill}
|
||||
>
|
||||
clearSkill (发送 skill_id=[])
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 3:接收宿主页 selectedSkill */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
③ 接收宿主页 selectedSkill
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-green-50 text-xs text-green-700 hover:bg-green-100"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.postMessage(
|
||||
{ type: "selectedSkill", id: 5, title: "文档处理" },
|
||||
"*",
|
||||
);
|
||||
addLog(
|
||||
"模拟宿主页 → selectedSkill { id: 5, title: '文档处理' }",
|
||||
);
|
||||
}}
|
||||
>
|
||||
✅ 模拟 selectedSkill(成功)
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.postMessage(
|
||||
{ type: "selectedSkill", id: 999999, title: "不存在的技能" },
|
||||
"*",
|
||||
);
|
||||
addLog(
|
||||
"模拟宿主页 → selectedSkill { id: 999999, title: '不存在的技能' }",
|
||||
);
|
||||
}}
|
||||
>
|
||||
❌ 模拟 selectedSkill(失败/错误)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 4:剪贴板复制(iframe 通信) */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-500">
|
||||
④ 剪贴板复制(iframe 通信)
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] font-medium",
|
||||
isInIframe
|
||||
? "bg-violet-100 text-violet-700"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{isInIframe ? "iframe 模式" : "独立页面"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
|
||||
variant="ghost"
|
||||
onClick={handleTestClipboardCopy}
|
||||
>
|
||||
📋 测试复制到剪贴板
|
||||
</Button>
|
||||
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
|
||||
{isInIframe
|
||||
? "将通过 postMessage 请求父页面复制"
|
||||
: "将直接调用 navigator.clipboard"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 5:is_chatting */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
⑤ is_chatting
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
|
||||
variant="ghost"
|
||||
onClick={() => handleSendIsChatting(true)}
|
||||
>
|
||||
发送 true
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
||||
variant="ghost"
|
||||
onClick={() => handleSendIsChatting(false)}
|
||||
>
|
||||
发送 false
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志 */}
|
||||
{log.length > 0 && (
|
||||
<div className="rounded-lg bg-gray-900 p-2">
|
||||
<div className="mb-1 text-[10px] font-semibold text-gray-400">
|
||||
操作日志
|
||||
</div>
|
||||
{log.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="truncate font-mono text-[10px] text-green-400"
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
|
||||
variant="ghost"
|
||||
onClick={handleTestClipboardCopy}
|
||||
>
|
||||
{l}
|
||||
📋 测试复制到剪贴板
|
||||
</Button>
|
||||
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
|
||||
{isInIframe
|
||||
? "将通过 postMessage 请求父页面复制"
|
||||
: "将直接调用 navigator.clipboard"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 场景 5:is_chatting */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
⑤ is_chatting
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
|
||||
variant="ghost"
|
||||
onClick={() => handleSendIsChatting(true)}
|
||||
>
|
||||
发送 true
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
||||
variant="ghost"
|
||||
onClick={() => handleSendIsChatting(false)}
|
||||
>
|
||||
发送 false
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志 */}
|
||||
{log.length > 0 && (
|
||||
<div className="rounded-lg bg-gray-900 p-2">
|
||||
<div className="mb-1 text-[10px] font-semibold text-gray-400">
|
||||
操作日志
|
||||
</div>
|
||||
{log.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="truncate font-mono text-[10px] text-green-400"
|
||||
>
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ function MessageImage({
|
|||
}
|
||||
|
||||
const url =
|
||||
src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src;
|
||||
src.startsWith("/mnt/") && threadId
|
||||
? resolveArtifactURL(src, threadId)
|
||||
: src;
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
|
|
|
|||
|
|
@ -210,11 +210,13 @@ export function MessageList({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{thread.isLoading && messages.length > 0 && <StreamingIndicator className="my-4" />}
|
||||
{thread.isLoading && messages.length > 0 && (
|
||||
<StreamingIndicator className="my-4" />
|
||||
)}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
{/* showScrollToBottomButton */}
|
||||
{ showScrollToBottomButton && (
|
||||
{showScrollToBottomButton && (
|
||||
<ConversationScrollButton
|
||||
className={cn(
|
||||
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -28,9 +28,7 @@ export function WorkspaceSidebar({
|
|||
<WorkspaceNavChatList />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{/* <WorkspaceNavMenu /> */}
|
||||
</SidebarFooter>
|
||||
<SidebarFooter>{/* <WorkspaceNavMenu /> */}</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import type { Translations } from "./types";
|
||||
|
||||
export const zhCN: Translations = {
|
||||
// 隐蔽版本标识:Tag:v3.2.1 feat: 宿主页复制
|
||||
// Locale meta
|
||||
locale: {
|
||||
localName: "中文",
|
||||
|
|
@ -57,8 +58,7 @@ export const zhCN: Translations = {
|
|||
|
||||
// Welcome
|
||||
welcome: {
|
||||
// TODO: 测试环境标识
|
||||
greeting: "轻办公 · XClaw Tag:v3.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 中使用",
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => ({}));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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], ``);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标题级别
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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) 回滚 localStorage(latest + 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@ import {
|
|||
|
||||
test.describe("聊天工作台 / 输入区与发送", () => {
|
||||
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
|
|
@ -32,11 +28,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
|||
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
|
|
@ -52,18 +44,17 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
|||
await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
|
||||
await expect.poll(inputHeight).toBeGreaterThan(120);
|
||||
|
||||
await page.getByRole("main").first().click({ position: { x: 20, y: 20 } });
|
||||
await page
|
||||
.getByRole("main")
|
||||
.first()
|
||||
.click({ position: { x: 20, y: 20 } });
|
||||
await expect.poll(inputHeight).toBeLessThan(110);
|
||||
});
|
||||
|
||||
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const suggestions = page.getByTestId("welcome-suggestions");
|
||||
|
|
@ -76,11 +67,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
|||
});
|
||||
|
||||
test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
|
|
@ -95,11 +82,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
|||
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
|
|
@ -119,11 +102,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
|||
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
waitForMessageListReady,
|
||||
} from "./support/chat-helpers";
|
||||
|
||||
async function waitForAnyMessages(page: Parameters<typeof openChat>[0], timeoutMs = 15_000) {
|
||||
async function waitForAnyMessages(
|
||||
page: Parameters<typeof openChat>[0],
|
||||
timeoutMs = 15_000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const count = await page.locator(".is-user, .is-assistant").count();
|
||||
|
|
@ -76,7 +79,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
|||
const target = element as HTMLElement;
|
||||
return target.scrollHeight - target.clientHeight > 20;
|
||||
});
|
||||
testInfo.skip(canScroll === false, "当前线程消息区高度不足,无法触发滚动到底部按钮。");
|
||||
testInfo.skip(
|
||||
canScroll === false,
|
||||
"当前线程消息区高度不足,无法触发滚动到底部按钮。",
|
||||
);
|
||||
|
||||
await messageLog.hover();
|
||||
await page.mouse.wheel(0, -1200);
|
||||
|
|
@ -115,7 +121,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
|||
.filter(Boolean);
|
||||
|
||||
expect(afterUsers.length).toBe(beforeUsers.length);
|
||||
for (const sample of beforeUsers.slice(0, Math.min(3, beforeUsers.length))) {
|
||||
for (const sample of beforeUsers.slice(
|
||||
0,
|
||||
Math.min(3, beforeUsers.length),
|
||||
)) {
|
||||
expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
|
@ -132,7 +141,10 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
|||
await waitForMessageListReady(page, { requireMessages: false });
|
||||
|
||||
const todoButton = page.getByRole("button", { name: /To-dos/i });
|
||||
testInfo.skip((await todoButton.count()) === 0, "当前线程未展示 To-dos 入口。");
|
||||
testInfo.skip(
|
||||
(await todoButton.count()) === 0,
|
||||
"当前线程未展示 To-dos 入口。",
|
||||
);
|
||||
await expect(todoButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,9 +9,13 @@ function envThread(name: string) {
|
|||
export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined;
|
||||
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
|
||||
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
|
||||
export const THREAD_WITH_MARKDOWN = envThread("FRONTEND_E2E_MARKDOWN_THREAD_ID");
|
||||
export const THREAD_WITH_MARKDOWN = envThread(
|
||||
"FRONTEND_E2E_MARKDOWN_THREAD_ID",
|
||||
);
|
||||
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
|
||||
export const THREAD_WITH_ARTIFACTS = envThread("FRONTEND_E2E_ARTIFACTS_THREAD_ID");
|
||||
export const THREAD_WITH_ARTIFACTS = envThread(
|
||||
"FRONTEND_E2E_ARTIFACTS_THREAD_ID",
|
||||
);
|
||||
export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
|
||||
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
|
||||
);
|
||||
|
|
@ -108,10 +112,9 @@ export async function waitForMessageListReady(
|
|||
await expect(page.getByRole("main").first()).toBeVisible();
|
||||
if (requireMessages) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => await page.locator(".is-user, .is-assistant").count(),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.poll(async () => await page.locator(".is-user, .is-assistant").count(), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(minMessages - 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
|||
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
const suggestions = page.getByTestId("welcome-suggestions");
|
||||
|
|
@ -31,11 +27,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
|||
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!));
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
|
|
@ -45,9 +37,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
|||
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({ page }) => {
|
||||
await page.goto(invalidNewChatUrl());
|
||||
|
||||
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
||||
|
|
@ -57,11 +47,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
|||
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||
await waitForMessageListReady(page);
|
||||
|
||||
|
|
@ -76,11 +62,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
|||
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||
await waitForMessageListReady(page);
|
||||
|
||||
|
|
@ -96,11 +78,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
|||
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(
|
||||
testInfo,
|
||||
THREAD_FOR_WELCOME,
|
||||
"FRONTEND_E2E_THREAD_ID",
|
||||
);
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||
await waitForMessageListReady(page);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue