Compare commits
No commits in common. "6c618fadfbf94cdd9d8222f9e52730c622e9e3cd" and "a5cf6c87e54d33c3103f0739ba9d69705e75ca46" have entirely different histories.
6c618fadfb
...
a5cf6c87e5
|
|
@ -176,11 +176,6 @@ 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)
|
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
|
||||||
|
|
||||||
if is_text_file_by_content(actual_path):
|
if is_text_file_by_content(actual_path):
|
||||||
try:
|
|
||||||
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
|
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)})
|
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
|
||||||
|
|
|
||||||
|
|
@ -269,8 +269,7 @@ You: "Deploying to staging..." [proceed]
|
||||||
- Use `read_file` tool to read uploaded files using their paths from the list
|
- 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
|
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
|
||||||
- All temporary work happens in `/mnt/user-data/workspace`
|
- All temporary work happens in `/mnt/user-data/workspace`
|
||||||
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
|
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` 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}
|
{acp_section}
|
||||||
</working_directory>
|
</working_directory>
|
||||||
|
|
||||||
|
|
@ -348,8 +347,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.
|
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
|
||||||
- Progressive Loading: Load resources incrementally as referenced in skills
|
- Progressive Loading: Load resources incrementally as referenced in skills
|
||||||
- Output Files: Final deliverables must be in `/mnt/user-data/outputs`
|
- 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
|
- 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
|
- 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
|
- 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.
|
- Always Respond: Your thinking is internal. You MUST always provide a visible response to the user after thinking.
|
||||||
|
|
@ -496,7 +495,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"
|
"- 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"
|
"- 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"
|
"- 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_files`"
|
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_file`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,18 +102,3 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "hello"
|
assert response.text == "hello"
|
||||||
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
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,10 +46,8 @@
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@revolist/revogrid": "^4.21.3",
|
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"@tombcato/smart-ticker": "^1.2.4",
|
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||||
|
|
@ -65,14 +63,11 @@
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"docx": "^9.6.1",
|
"docx": "^9.6.1",
|
||||||
"docx-preview": "^0.3.7",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"exceljs": "^4.4.0",
|
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
|
|
@ -82,8 +77,8 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextra": "^4.6.1",
|
"nextra": "^4.6.1",
|
||||||
"nextra-theme-docs": "^4.6.1",
|
"nextra-theme-docs": "^4.6.1",
|
||||||
|
"nuxt-og-image": "^5.1.13",
|
||||||
"ogl": "^1.0.11",
|
"ogl": "^1.0.11",
|
||||||
"pdfjs-dist": "^5.6.205",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,8 +9,7 @@ import { detectLocaleServer } from "@/core/i18n/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "XClaw",
|
title: "XClaw",
|
||||||
description:
|
description: "Desscriptions of XClawDesscriptions of XClawDesscriptions of XClaw",
|
||||||
"Desscriptions of XClawDesscriptions of XClawDesscriptions of XClaw",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"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,8 +2,7 @@
|
||||||
|
|
||||||
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -29,7 +28,6 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
||||||
import { Welcome } from "@/components/workspace/welcome";
|
import { Welcome } from "@/components/workspace/welcome";
|
||||||
import { getAPIClient } from "@/core/api";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
|
|
@ -40,14 +38,10 @@ import { env } from "@/env";
|
||||||
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
|
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
|
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() {
|
export default function ChatPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
const [sloganIndex, setSloganIndex] = useState(0);
|
|
||||||
const [settings, setSettings] = useLocalSettings();
|
const [settings, setSettings] = useLocalSettings();
|
||||||
const { setOpen: setSidebarOpen } = useSidebar();
|
const { setOpen: setSidebarOpen } = useSidebar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -62,23 +56,23 @@ export default function ChatPage() {
|
||||||
setFullscreen: setArtifactsFullscreen,
|
setFullscreen: setArtifactsFullscreen,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
|
const {
|
||||||
useThreadChat();
|
threadId,
|
||||||
|
isNewThread,
|
||||||
|
setIsNewThread,
|
||||||
|
isMock,
|
||||||
|
showWelcomeStyle,
|
||||||
|
} = useThreadChat();
|
||||||
|
|
||||||
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
|
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
|
||||||
const shouldRenderHistory = !showWelcomeStyle;
|
const shouldRenderHistory = !showWelcomeStyle;
|
||||||
|
const createNewSession = useMemo(() => isNewThread, [isNewThread]);
|
||||||
const safeThreadId = useMemo(() => {
|
const safeThreadId = useMemo(() => {
|
||||||
if (!threadId || threadId === "new") {
|
if (!threadId || threadId === "new") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return threadId;
|
return threadId;
|
||||||
}, [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(() => {
|
const streamThreadId = useMemo(() => {
|
||||||
if (isNewThread && createNewSession) {
|
if (isNewThread && createNewSession) {
|
||||||
|
|
@ -86,70 +80,8 @@ export default function ChatPage() {
|
||||||
}
|
}
|
||||||
return safeThreadId;
|
return safeThreadId;
|
||||||
}, [createNewSession, isNewThread, safeThreadId]);
|
}, [createNewSession, isNewThread, safeThreadId]);
|
||||||
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
|
|
||||||
const warnedMissingThreadIdRef = useRef(false);
|
|
||||||
const initializedThreadRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
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 消息
|
// 监听宿主页 selectedSkill 消息
|
||||||
const {
|
const {
|
||||||
|
|
@ -203,16 +135,10 @@ export default function ChatPage() {
|
||||||
setHistoryCutoff(null);
|
setHistoryCutoff(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSubmitted) return;
|
if (historyCutoff === null && !thread.isThreadLoading) {
|
||||||
// Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。
|
setHistoryCutoff(thread.messages.length);
|
||||||
// 这样即使历史消息是后续异步补齐,也不会重新露出。
|
}
|
||||||
setHistoryCutoff((prev) => {
|
|
||||||
const next = thread.messages.length;
|
|
||||||
if (prev === null) return next;
|
|
||||||
return next > prev ? next : prev;
|
|
||||||
});
|
|
||||||
}, [
|
}, [
|
||||||
hasSubmitted,
|
|
||||||
historyCutoff,
|
historyCutoff,
|
||||||
shouldRenderHistory,
|
shouldRenderHistory,
|
||||||
thread.isThreadLoading,
|
thread.isThreadLoading,
|
||||||
|
|
@ -267,30 +193,15 @@ export default function ChatPage() {
|
||||||
|
|
||||||
const todoListCollapsed = true;
|
const todoListCollapsed = true;
|
||||||
const [showExitDialog, setShowExitDialog] = useState(false);
|
const [showExitDialog, setShowExitDialog] = useState(false);
|
||||||
const isStreaming = isUploading || thread.isLoading;
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: Parameters<typeof sendMessage>[1]) => {
|
(message: Parameters<typeof sendMessage>[1]) => {
|
||||||
if (isSelectedSkillBootstrapping) {
|
if (isSelectedSkillBootstrapping) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNewThread && !safeThreadId) {
|
|
||||||
toast.error("缺少 thread_id,无法发送消息");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHasSubmitted(true);
|
setHasSubmitted(true);
|
||||||
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
|
void sendMessage(threadId, message);
|
||||||
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 () => {
|
const handleStop = useCallback(async () => {
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
|
|
@ -312,6 +223,7 @@ export default function ChatPage() {
|
||||||
setIsNewThread,
|
setIsNewThread,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ threadId,thread }}>
|
<ThreadContext.Provider value={{ threadId,thread }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -340,7 +252,6 @@ export default function ChatPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
|
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
|
||||||
disabled={isStreaming}
|
|
||||||
onClick={() => setShowExitDialog(true)}
|
onClick={() => setShowExitDialog(true)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -360,22 +271,9 @@ export default function ChatPage() {
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
|
||||||
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" && (
|
{title !== "Untitled" && (
|
||||||
// <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
|
<ThreadTitle threadId={threadId} threadTitle={title} />
|
||||||
<Ticker
|
|
||||||
value={currentSlogan.text}
|
|
||||||
duration={800}
|
|
||||||
easing="easeInOut"
|
|
||||||
charWidth={1}
|
|
||||||
characterLists={tickerCharacterList}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||||||
|
|
@ -418,9 +316,7 @@ export default function ChatPage() {
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 max-w-full grow flex-col",
|
"flex min-h-0 max-w-full grow flex-col",
|
||||||
showWelcomeStyle && !hasSubmitted
|
showWelcomeStyle && !hasSubmitted ? "bg-white" : "bg-background",
|
||||||
? "bg-white"
|
|
||||||
: "bg-background",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex size-full justify-center">
|
<div className="flex size-full justify-center">
|
||||||
|
|
@ -432,10 +328,8 @@ export default function ChatPage() {
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
messagesOverride={
|
messagesOverride={
|
||||||
shouldRenderHistory
|
shouldRenderHistory || historyCutoff === null
|
||||||
? undefined
|
? undefined
|
||||||
: historyCutoff === null
|
|
||||||
? []
|
|
||||||
: thread.messages.slice(historyCutoff)
|
: thread.messages.slice(historyCutoff)
|
||||||
}
|
}
|
||||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||||
|
|
@ -460,7 +354,6 @@ export default function ChatPage() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full transition-transform duration-300 ease-in-out",
|
"h-full w-full transition-transform duration-300 ease-in-out",
|
||||||
showWelcomeStyle && !hasSubmitted ? "translate-x-0" : "",
|
|
||||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -497,9 +390,9 @@ export default function ChatPage() {
|
||||||
{t.common.artifacts}
|
{t.common.artifacts}
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<main className="min-h-0 grow overflow-auto">
|
<main className="min-h-0 grow">
|
||||||
<ArtifactFileList
|
<ArtifactFileList
|
||||||
className="mb-[207px] max-w-(--container-width-sm) p-4 pt-12"
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
files={thread.values.artifacts ?? []}
|
files={thread.values.artifacts ?? []}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
/>
|
/>
|
||||||
|
|
@ -523,13 +416,10 @@ export default function ChatPage() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto relative w-full max-w-[720px]",
|
"pointer-events-auto relative w-full max-w-[720px]",
|
||||||
showWelcomeStyle &&
|
showWelcomeStyle && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
|
||||||
!hasSubmitted &&
|
|
||||||
"-translate-y-[calc(50vh-96px)]",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
{!(showWelcomeStyle && thread.isThreadLoading) ? (
|
||||||
<>
|
|
||||||
<InputBox
|
<InputBox
|
||||||
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
|
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
|
@ -554,17 +444,15 @@ export default function ChatPage() {
|
||||||
disabled={
|
disabled={
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
isSelectedSkillBootstrapping ||
|
isSelectedSkillBootstrapping ||
|
||||||
isUploading ||
|
isUploading
|
||||||
(isNewThread && !safeThreadId)
|
|
||||||
}
|
}
|
||||||
onContextChange={(context) => setSettings("context", context)}
|
onContextChange={(context) => setSettings("context", context)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
// <InputBoxSkeleton />
|
// <InputBoxSkeleton />
|
||||||
""
|
''
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* {isSelectedSkillBootstrapping && (
|
{/* {isSelectedSkillBootstrapping && (
|
||||||
|
|
@ -587,7 +475,7 @@ export default function ChatPage() {
|
||||||
<DevDialogTitle>提示</DevDialogTitle>
|
<DevDialogTitle>提示</DevDialogTitle>
|
||||||
</DevDialogHeader>
|
</DevDialogHeader>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
历史记录每七天自动删除,现在将返回欢迎页,是否继续?
|
(测试中:计划销毁但是现在没有销毁) 退出后,当前会话结束并销毁,请先下载保存当前结果!
|
||||||
</p>
|
</p>
|
||||||
<DevDialogFooter>
|
<DevDialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -616,9 +504,7 @@ export default function ChatPage() {
|
||||||
if (threadId && threadId !== "new") {
|
if (threadId && threadId !== "new") {
|
||||||
nextQuery.set("thread_id", threadId);
|
nextQuery.set("thread_id", threadId);
|
||||||
}
|
}
|
||||||
router.replace(
|
router.replace(`/workspace/chats/${threadId}?is_chatting=false`);
|
||||||
`/workspace/chats/${threadId}?is_chatting=false`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
确定
|
确定
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,6 @@ export const ChainOfThoughtHeader = memo(
|
||||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||||
icon?: LucideIcon | React.ReactElement;
|
icon?: LucideIcon | React.ReactElement;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
action?: ReactNode;
|
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
status?: "complete" | "active" | "pending";
|
status?: "complete" | "active" | "pending";
|
||||||
};
|
};
|
||||||
|
|
@ -126,7 +125,6 @@ export const ChainOfThoughtStep = memo(
|
||||||
className,
|
className,
|
||||||
icon: Icon = DotIcon,
|
icon: Icon = DotIcon,
|
||||||
label,
|
label,
|
||||||
action,
|
|
||||||
description,
|
description,
|
||||||
status = "complete",
|
status = "complete",
|
||||||
children,
|
children,
|
||||||
|
|
@ -153,10 +151,7 @@ export const ChainOfThoughtStep = memo(
|
||||||
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2 overflow-hidden">
|
<div className="flex-1 space-y-2 overflow-hidden">
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div>{label}</div>
|
<div>{label}</div>
|
||||||
{action && <div className="shrink-0">{action}</div>}
|
|
||||||
</div>
|
|
||||||
{description && (
|
{description && (
|
||||||
<div className="text-muted-foreground text-xs">{description}</div>
|
<div className="text-muted-foreground text-xs">{description}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn, copyToClipboard } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
|
@ -146,9 +146,14 @@ export const CodeBlockCopyButton = ({
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const { code } = useContext(CodeBlockContext);
|
const { code } = useContext(CodeBlockContext);
|
||||||
|
|
||||||
const handleCopyClick = async () => {
|
const copyToClipboard = async () => {
|
||||||
|
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||||
|
onError?.(new Error("Clipboard API not available"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(code);
|
await navigator.clipboard.writeText(code);
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
onCopy?.();
|
onCopy?.();
|
||||||
setTimeout(() => setIsCopied(false), timeout);
|
setTimeout(() => setIsCopied(false), timeout);
|
||||||
|
|
@ -162,7 +167,7 @@ export const CodeBlockCopyButton = ({
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn("shrink-0", className)}
|
className={cn("shrink-0", className)}
|
||||||
onClick={handleCopyClick}
|
onClick={copyToClipboard}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -860,15 +860,12 @@ export const PromptInputBody = ({
|
||||||
|
|
||||||
export type PromptInputTextareaProps = ComponentProps<
|
export type PromptInputTextareaProps = ComponentProps<
|
||||||
typeof InputGroupTextarea
|
typeof InputGroupTextarea
|
||||||
> & {
|
>;
|
||||||
submitOnEnter?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PromptInputTextarea = ({
|
export const PromptInputTextarea = ({
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
placeholder = "What would you like to know?",
|
placeholder = "What would you like to know?",
|
||||||
submitOnEnter = true,
|
|
||||||
...props
|
...props
|
||||||
}: PromptInputTextareaProps) => {
|
}: PromptInputTextareaProps) => {
|
||||||
const controller = useOptionalPromptInputController();
|
const controller = useOptionalPromptInputController();
|
||||||
|
|
@ -880,35 +877,7 @@ export const PromptInputTextarea = ({
|
||||||
if (isComposing || e.nativeEvent.isComposing) {
|
if (isComposing || e.nativeEvent.isComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!submitOnEnter) {
|
if (e.shiftKey) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1114,12 +1083,10 @@ export const PromptInputSubmit = ({
|
||||||
controller.attachments.files.length > 0
|
controller.attachments.files.length > 0
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const isStreaming = status === "streaming";
|
// 正在 streaming 时不允许发送
|
||||||
const isSubmitted = status === "submitted";
|
const isStreaming = status === "streaming" || status === "submitted";
|
||||||
// Streaming 时按钮用于停止,不受输入内容是否为空限制
|
|
||||||
const isDisabled = isStreaming
|
const isDisabled = disabled || !hasContent || isStreaming;
|
||||||
? !!disabled
|
|
||||||
: disabled || !hasContent || isSubmitted;
|
|
||||||
|
|
||||||
let Icon = <ArrowUpIcon className="size-4" />;
|
let Icon = <ArrowUpIcon className="size-4" />;
|
||||||
|
|
||||||
|
|
@ -1146,8 +1113,8 @@ export const PromptInputSubmit = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
|
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
|
||||||
isDisabled
|
isDisabled
|
||||||
? "cursor-not-allowed !bg-gray-200 text-gray-400"
|
? "cursor-not-allowed !bg-gray-200 text-gray-400":
|
||||||
: "!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
|
"!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
size={size}
|
size={size}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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,12 +37,11 @@ interface ArtifactsProviderProps {
|
||||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||||
const [artifacts, setArtifacts] = useState<string[]>([]);
|
const [artifacts, setArtifacts] = useState<string[]>([]);
|
||||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||||
const [autoSelect, setAutoSelect] = useState(false);
|
const [autoSelect, setAutoSelect] = useState(true);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(
|
||||||
// const [open, setOpen] = useState(
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
// env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
);
|
||||||
// );
|
const [autoOpen, setAutoOpen] = useState(true);
|
||||||
const [autoOpen, setAutoOpen] = useState(false);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const { setOpen: setSidebarOpen } = useSidebar();
|
const { setOpen: setSidebarOpen } = useSidebar();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
const ChatBox: React.FC<{
|
const ChatBox: React.FC<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
threadId: string | undefined;
|
threadId: string | undefined;
|
||||||
}> = ({ children, threadId }) => {
|
}> = ({
|
||||||
|
children,
|
||||||
|
threadId,
|
||||||
|
}) => {
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
export function useThreadChat() {
|
export function useThreadChat() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams<{ thread_id: string }>();
|
const params = useParams<{ thread_id: string }>();
|
||||||
|
|
@ -44,6 +45,7 @@ export function useThreadChat() {
|
||||||
return threadIdFromPathOrParams ?? "";
|
return threadIdFromPathOrParams ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
// 记住最近一次有效的 thread_id,供下次加载兜底使用。
|
||||||
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
if (threadId && threadId !== "new" && typeof window !== "undefined") {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useCallback, useState, type ComponentProps } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
|
|
@ -15,14 +14,10 @@ export function CopyButton({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(() => {
|
||||||
try {
|
void navigator.clipboard.writeText(clipboardData);
|
||||||
await copyToClipboard(clipboardData);
|
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch {
|
|
||||||
// no-op: caller controls error UI if needed
|
|
||||||
}
|
|
||||||
}, [clipboardData]);
|
}, [clipboardData]);
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t.clipboard.copyToClipboard}>
|
<Tooltip content={t.clipboard.copyToClipboard}>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ export function IframeTestPanel() {
|
||||||
const iframeSkill = useIframeSkill();
|
const iframeSkill = useIframeSkill();
|
||||||
const [log, setLog] = useState<string[]>([]);
|
const [log, setLog] = useState<string[]>([]);
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -58,9 +57,7 @@ export function IframeTestPanel() {
|
||||||
|
|
||||||
function handleSendSelectSkill() {
|
function handleSendSelectSkill() {
|
||||||
iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]);
|
iframeSkill.sendSelectSkill([{ id: "skill_001", name: "测试技能1" }]);
|
||||||
addLog(
|
addLog("postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])");
|
||||||
"postMessage → selectedSkills ([{id:'skill_001',name:'测试技能1'}])",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSendSelectSkillArray() {
|
function handleSendSelectSkillArray() {
|
||||||
|
|
@ -171,25 +168,14 @@ export function IframeTestPanel() {
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold text-white">🧪 iframe 通信测试</span>
|
<span className="text-xs font-bold text-white">🧪 iframe 通信测试</span>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
className="text-white/70 hover:text-white"
|
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)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="space-y-3 p-3">
|
<div className="space-y-3 p-3">
|
||||||
{/* 当前状态 */}
|
{/* 当前状态 */}
|
||||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||||
|
|
@ -305,59 +291,13 @@ export function IframeTestPanel() {
|
||||||
>
|
>
|
||||||
✅ 模拟 selectedSkill(成功)
|
✅ 模拟 selectedSkill(成功)
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
|
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{
|
{ type: "selectedSkill", id: 999999, title: "不存在的技能" },
|
||||||
type: "selectedSkill",
|
|
||||||
id: 999999,
|
|
||||||
title: "不存在的技能",
|
|
||||||
},
|
|
||||||
"*",
|
"*",
|
||||||
);
|
);
|
||||||
addLog(
|
addLog(
|
||||||
|
|
@ -446,7 +386,6 @@ export function IframeTestPanel() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
Loader2Icon,
|
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
|
|
@ -43,6 +40,7 @@ import {
|
||||||
usePromptInputController,
|
usePromptInputController,
|
||||||
type PromptInputMessage,
|
type PromptInputMessage,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfettiButton } from "@/components/ui/confetti-button";
|
import { ConfettiButton } from "@/components/ui/confetti-button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -58,11 +56,13 @@ import {
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tag } from "@/components/ui/tag";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -86,7 +86,6 @@ import {
|
||||||
|
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
|
||||||
|
|
||||||
export function InputBox({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
|
|
@ -131,9 +130,8 @@ export function InputBox({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
const iframeSkill = useIframeSkill();
|
||||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
|
||||||
const router = useRouter();
|
|
||||||
const threadId = threadIdFromProps;
|
const threadId = threadIdFromProps;
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const attachments = usePromptInputAttachments();
|
const attachments = usePromptInputAttachments();
|
||||||
|
|
@ -328,7 +326,7 @@ export function InputBox({
|
||||||
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||||||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
||||||
)}
|
)}
|
||||||
disabled={isInputDisabled}
|
disabled={disabled}
|
||||||
globalDrop
|
globalDrop
|
||||||
multiple
|
multiple
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|
@ -343,7 +341,7 @@ export function InputBox({
|
||||||
"size-full",
|
"size-full",
|
||||||
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
||||||
)}
|
)}
|
||||||
disabled={isInputDisabled}
|
disabled={disabled}
|
||||||
placeholder={t.inputBox.placeholder}
|
placeholder={t.inputBox.placeholder}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
defaultValue={initialValue}
|
defaultValue={initialValue}
|
||||||
|
|
@ -366,7 +364,7 @@ export function InputBox({
|
||||||
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
|
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PromptInputTools className="min-w-0 flex-1">
|
<PromptInputTools>
|
||||||
{/* TODO: Add more connectors here
|
{/* TODO: Add more connectors here
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuTrigger className="px-2!" />
|
<PromptInputActionMenuTrigger className="px-2!" />
|
||||||
|
|
@ -376,20 +374,13 @@ export function InputBox({
|
||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
<HistoryButton
|
|
||||||
className="px-2!"
|
|
||||||
router={router}
|
|
||||||
threadId={threadIdFromProps}
|
|
||||||
/>
|
|
||||||
<AddAttachmentsButton className="px-2!" />
|
<AddAttachmentsButton className="px-2!" />
|
||||||
<IframeSkillDialogButton
|
<IframeSkillDialogButton
|
||||||
className="px-2!"
|
className="px-2!"
|
||||||
selectedSkills={iframeSkill.selectedSkills}
|
selectedSkill={iframeSkill.selectedSkill}
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
|
||||||
openSkillDialog={iframeSkill.openSkillDialog}
|
openSkillDialog={iframeSkill.openSkillDialog}
|
||||||
clearSkill={iframeSkill.clearSkill}
|
clearSkill={iframeSkill.clearSkill}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 参考 kexue 版本隐藏运行模式切换按钮 */}
|
{/* 参考 kexue 版本隐藏运行模式切换按钮 */}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
{/* <ModelSelector
|
{/* <ModelSelector
|
||||||
|
|
@ -430,18 +421,16 @@ export function InputBox({
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
className="absolute right-3 bottom-5 z-[20] border-0"
|
className="absolute right-3 bottom-5 z-[20] border-0"
|
||||||
disabled={isInputDisabled}
|
disabled={disabled}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
</PromptInput>
|
</PromptInput>
|
||||||
|
|
||||||
{showWelcomeStyle &&
|
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
|
||||||
!hasSubmitted &&
|
|
||||||
searchParams.get("mode") !== "skill" && (
|
|
||||||
<SuggestionListContainer
|
<SuggestionListContainer
|
||||||
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
|
threadId={threadId}
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
sendSelectSkill={iframeSkill.sendSelectSkill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -505,82 +494,91 @@ export function InputBox({
|
||||||
|
|
||||||
// SuggestionList 容器
|
// SuggestionList 容器
|
||||||
function SuggestionListContainer({
|
function SuggestionListContainer({
|
||||||
bootstrapAndLockSkills,
|
threadId,
|
||||||
isBootstrapping,
|
sendSelectSkill,
|
||||||
}: {
|
}: {
|
||||||
bootstrapAndLockSkills: (params: {
|
threadId: string;
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||||
title: string;
|
|
||||||
}) => Promise<boolean>;
|
|
||||||
isBootstrapping: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
|
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
|
||||||
<SuggestionList
|
<SuggestionList threadId={threadId} sendSelectSkill={sendSelectSkill} />
|
||||||
bootstrapAndLockSkills={bootstrapAndLockSkills}
|
|
||||||
isBootstrapping={isBootstrapping}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快速选择skillbutton
|
// 快速选择skillbutton
|
||||||
function SuggestionList({
|
function SuggestionList({
|
||||||
bootstrapAndLockSkills,
|
threadId,
|
||||||
isBootstrapping,
|
sendSelectSkill,
|
||||||
}: {
|
}: {
|
||||||
bootstrapAndLockSkills: (params: {
|
threadId: string;
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||||
title: string;
|
|
||||||
}) => Promise<boolean>;
|
|
||||||
isBootstrapping: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const suggestions = t.inputBox.suggestions;
|
const suggestions = t.inputBox.suggestions;
|
||||||
const promptSuggestions = suggestions.filter(
|
const promptSuggestions = suggestions.filter(
|
||||||
(
|
(
|
||||||
suggestion,
|
suggestion,
|
||||||
): suggestion is Exclude<
|
): suggestion is Exclude<(typeof suggestions)[number], { type: "separator" }> =>
|
||||||
(typeof suggestions)[number],
|
!("type" in suggestion),
|
||||||
{ type: "separator" }
|
|
||||||
> => !("type" in suggestion),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback(
|
const handleSuggestionClick = useCallback(
|
||||||
(suggestion: {
|
(
|
||||||
|
suggestion: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
skill_id?: string[];
|
skill_id?: string[];
|
||||||
children?: SelectedSkillPayloadItem[];
|
children?: SelectedSkillPayloadItem[];
|
||||||
suggestion: string;
|
suggestion: string;
|
||||||
}) => {
|
},
|
||||||
if (isBootstrapping) return;
|
) => {
|
||||||
|
const languageTypeRaw =
|
||||||
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
searchParams.get("languageType")?.trim() ??
|
||||||
const childSkills = (suggestion.children ?? [])
|
searchParams.get("language_type")?.trim();
|
||||||
.map((item) => ({
|
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
||||||
id: String(item.id).trim(),
|
const bootstrapByIds = (ids: string[]) => {
|
||||||
name: item.name?.trim() ?? "",
|
const content_ids = Array.from(
|
||||||
}))
|
new Set(
|
||||||
.filter(
|
ids
|
||||||
(item): item is { id: string; name: string } =>
|
.map((id) => Number(id))
|
||||||
Boolean(item.id) && Boolean(item.name),
|
.filter((id) => Number.isFinite(id) && id > 0),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (childSkills.length > 0) {
|
if (!threadId || content_ids.length === 0) return;
|
||||||
void bootstrapAndLockSkills({
|
void bootstrapRemoteSkill({
|
||||||
selectedSkills: childSkills,
|
thread_id: threadId,
|
||||||
title: suggestion.suggestion,
|
content_ids,
|
||||||
|
language_type: languageType,
|
||||||
|
target_dir: "/mnt/user-data/uploads/skill",
|
||||||
|
clear_target: true,
|
||||||
});
|
});
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
|
// 优先从 children 中提取 skill_id 数组,转换为 selectedSkills 发送给宿主页
|
||||||
void bootstrapAndLockSkills({
|
const childSkillIds = (suggestion.children ?? [])
|
||||||
selectedSkills: suggestion.skill_id.map((id) => ({
|
.map((item) => String(item.id).trim())
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
if (childSkillIds.length > 0) {
|
||||||
|
sendSelectSkill(
|
||||||
|
childSkillIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
name: suggestion.suggestion,
|
name: suggestion.suggestion,
|
||||||
})),
|
})),
|
||||||
title: suggestion.suggestion,
|
);
|
||||||
});
|
bootstrapByIds(childSkillIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
|
||||||
|
sendSelectSkill(
|
||||||
|
suggestion.skill_id.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: suggestion.suggestion,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
bootstrapByIds(suggestion.skill_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 原有逻辑
|
// 原有逻辑
|
||||||
|
|
@ -600,13 +598,10 @@ function SuggestionList({
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
[bootstrapAndLockSkills, isBootstrapping, textInput],
|
[textInput, sendSelectSkill, threadId, searchParams],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Suggestions
|
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
|
||||||
className="min-h-16 w-fit items-start"
|
|
||||||
data-testid="welcome-suggestions"
|
|
||||||
>
|
|
||||||
{promptSuggestions.map((suggestion) => (
|
{promptSuggestions.map((suggestion) => (
|
||||||
<Suggestion
|
<Suggestion
|
||||||
key={suggestion.suggestion}
|
key={suggestion.suggestion}
|
||||||
|
|
@ -649,60 +644,25 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryButton({
|
|
||||||
className,
|
|
||||||
router,
|
|
||||||
threadId,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
router: AppRouterInstance;
|
|
||||||
threadId: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
return (
|
|
||||||
<Tooltip content={t.inputBox.history}>
|
|
||||||
<PromptInputButton
|
|
||||||
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
|
||||||
onClick={() =>
|
|
||||||
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="[&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
width="24px"
|
|
||||||
fill="#1f1f1f"
|
|
||||||
>
|
|
||||||
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
|
|
||||||
</svg>
|
|
||||||
</PromptInputButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 启动iframeSkillDialog
|
// 启动iframeSkillDialog
|
||||||
function IframeSkillDialogButton({
|
function IframeSkillDialogButton({
|
||||||
className,
|
className,
|
||||||
selectedSkills,
|
selectedSkill,
|
||||||
isBootstrapping,
|
|
||||||
openSkillDialog,
|
openSkillDialog,
|
||||||
clearSkill,
|
clearSkill,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedSkills: Array<{ skill_id: string; title: string }>;
|
selectedSkill: { skill_id: string; title: string } | null;
|
||||||
isBootstrapping: boolean;
|
|
||||||
openSkillDialog: () => void;
|
openSkillDialog: () => void;
|
||||||
clearSkill: (skillId?: string) => void;
|
clearSkill: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip content={t.inputBox.selectSkill}>
|
<Tooltip content={t.inputBox.selectSkill}>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)}
|
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
||||||
onClick={openSkillDialog}
|
onClick={openSkillDialog}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -718,38 +678,20 @@ function IframeSkillDialogButton({
|
||||||
</svg>
|
</svg>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isBootstrapping ? (
|
{selectedSkill && (
|
||||||
<Tag className="bg-background text-muted-foreground gap-2 border">
|
<Badge
|
||||||
<Loader2Icon className="size-3 animate-spin" />
|
variant="secondary"
|
||||||
{t.common.loading}
|
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]"
|
||||||
</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;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{selectedSkills.map((skill, index) => (
|
{selectedSkill.title}
|
||||||
<Tag
|
|
||||||
key={`${skill.skill_id}-${skill.title}-${index}`}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{skill.title}
|
|
||||||
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => clearSkill(skill.skill_id)}
|
onClick={clearSkill}
|
||||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<XIcon className="size-3" />
|
<XIcon className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</Tag>
|
</Badge>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { BundledLanguage } from "shiki";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
|
|
@ -40,8 +39,6 @@ import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
|
||||||
const TOOL_CONTENT_COLLAPSE_THRESHOLD = 320;
|
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
className,
|
className,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -79,10 +76,6 @@ export function MessageGroup({
|
||||||
return filteredSteps[filteredSteps.length - 1];
|
return filteredSteps[filteredSteps.length - 1];
|
||||||
}
|
}
|
||||||
}, [lastToolCallStep, steps]);
|
}, [lastToolCallStep, steps]);
|
||||||
const totalToolStepCount =
|
|
||||||
aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
|
|
||||||
const shouldShowToolSteps =
|
|
||||||
!!lastToolCallStep && (showAbove || aboveLastToolCallSteps.length === 0);
|
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
return (
|
return (
|
||||||
<ChainOfThought
|
<ChainOfThought
|
||||||
|
|
@ -94,17 +87,14 @@ export function MessageGroup({
|
||||||
key="above"
|
key="above"
|
||||||
className="w-full items-start justify-start text-left"
|
className="w-full items-start justify-start text-left"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={(event) => {
|
onClick={() => setShowAbove(!showAbove)}
|
||||||
event.stopPropagation();
|
|
||||||
setShowAbove((prev) => !prev);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
label={
|
label={
|
||||||
<span className="opacity-60">
|
<span className="opacity-60">
|
||||||
{showAbove
|
{showAbove
|
||||||
? t.toolCalls.lessSteps
|
? t.toolCalls.lessSteps
|
||||||
: t.toolCalls.moreSteps(totalToolStepCount)}
|
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
|
|
@ -118,7 +108,7 @@ export function MessageGroup({
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{shouldShowToolSteps && (
|
{lastToolCallStep && (
|
||||||
<ChainOfThoughtContent className="px-4 pb-2">
|
<ChainOfThoughtContent className="px-4 pb-2">
|
||||||
{showAbove &&
|
{showAbove &&
|
||||||
aboveLastToolCallSteps.map((step) =>
|
aboveLastToolCallSteps.map((step) =>
|
||||||
|
|
@ -155,10 +145,7 @@ export function MessageGroup({
|
||||||
key={lastReasoningStep.id}
|
key={lastReasoningStep.id}
|
||||||
className="w-full items-start justify-start text-left"
|
className="w-full items-start justify-start text-left"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={(event) => {
|
onClick={() => setShowLastThinking(!showLastThinking)}
|
||||||
event.stopPropagation();
|
|
||||||
setShowLastThinking((prev) => !prev);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
|
|
@ -216,33 +203,6 @@ function ToolCall({
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||||
useArtifacts();
|
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") {
|
if (name === "web_search") {
|
||||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
|
|
@ -417,34 +377,18 @@ function ToolCall({
|
||||||
return t.toolCalls.executeCommand;
|
return t.toolCalls.executeCommand;
|
||||||
}
|
}
|
||||||
const command: string | undefined = (args as { command: string })?.command;
|
const command: string | undefined = (args as { command: string })?.command;
|
||||||
const shouldCollapse =
|
|
||||||
!!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={id}
|
key={id}
|
||||||
label={description}
|
label={description}
|
||||||
icon={SquareTerminalIcon}
|
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 && (
|
{command && (
|
||||||
<ExpandableToolContent
|
<CodeBlock
|
||||||
content={command}
|
className="mx-0 cursor-pointer border-none px-0"
|
||||||
expanded={isCommandExpanded}
|
showLineNumbers={false}
|
||||||
|
language="bash"
|
||||||
|
code={command}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,7 @@ function MessageImage({
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
src.startsWith("/mnt/") && threadId
|
src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src;
|
||||||
? resolveArtifactURL(src, threadId)
|
|
||||||
: src;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
|
|
||||||
|
|
@ -210,9 +210,7 @@ export function MessageList({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{thread.isLoading && messages.length > 0 && (
|
{thread.isLoading && messages.length > 0 && <StreamingIndicator className="my-4" />}
|
||||||
<StreamingIndicator className="my-4" />
|
|
||||||
)}
|
|
||||||
<div style={{ height: `${paddingBottom}px` }} />
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
{/* showScrollToBottomButton */}
|
{/* showScrollToBottomButton */}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ import {
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function RecentChatList() {
|
export function RecentChatList() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -120,7 +119,7 @@ export function RecentChatList() {
|
||||||
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
||||||
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`;
|
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`;
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
toast.success(t.clipboard.linkCopied);
|
toast.success(t.clipboard.linkCopied);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
|
@ -179,7 +178,7 @@ export function RecentChatList() {
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
href={`${pathOfThread(thread.thread_id)}?is_chatting=true`}
|
href={pathOfThread(thread.thread_id)}
|
||||||
>
|
>
|
||||||
{titleOfThread(thread)}
|
{titleOfThread(thread)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-primary ml-2 cursor-default font-serif">
|
<div className="text-primary ml-2 cursor-default font-serif">
|
||||||
{/* TODO: 测试标识 */}
|
XClaw(测试专用侧边栏。)
|
||||||
XClaw <span className="text-sm text-[#000000c5]">v3.2.4</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ export function WorkspaceSidebar({
|
||||||
<WorkspaceNavChatList />
|
<WorkspaceNavChatList />
|
||||||
{isSidebarOpen && <RecentChatList />}
|
{isSidebarOpen && <RecentChatList />}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>{/* <WorkspaceNavMenu /> */}</SidebarFooter>
|
<SidebarFooter>
|
||||||
|
{/* <WorkspaceNavMenu /> */}
|
||||||
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ export const enUS: Translations = {
|
||||||
sendMessagePrice:
|
sendMessagePrice:
|
||||||
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
|
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
|
||||||
addAttachments: "Add attachments",
|
addAttachments: "Add attachments",
|
||||||
history: "History",
|
|
||||||
selectSkill: "Select Skill",
|
selectSkill: "Select Skill",
|
||||||
mode: "Mode",
|
mode: "Mode",
|
||||||
flashMode: "Flash",
|
flashMode: "Flash",
|
||||||
|
|
@ -278,8 +277,6 @@ export const enUS: Translations = {
|
||||||
writeFile: "Write file",
|
writeFile: "Write file",
|
||||||
clickToViewContent: "Click to view file content",
|
clickToViewContent: "Click to view file content",
|
||||||
writeTodos: "Update to-do list",
|
writeTodos: "Update to-do list",
|
||||||
expandContent: "Expand",
|
|
||||||
collapseContent: "Collapse",
|
|
||||||
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export interface SelectedSkillPayloadItem {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
// Locale meta
|
// Locale meta
|
||||||
locale: {
|
locale: {
|
||||||
|
|
@ -71,7 +72,6 @@ export interface Translations {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
createSkillPrompt: string;
|
createSkillPrompt: string;
|
||||||
addAttachments: string;
|
addAttachments: string;
|
||||||
history: string;
|
|
||||||
selectSkill: string;
|
selectSkill: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
flashMode: string;
|
flashMode: string;
|
||||||
|
|
@ -208,8 +208,6 @@ export interface Translations {
|
||||||
writeFile: string;
|
writeFile: string;
|
||||||
clickToViewContent: string;
|
clickToViewContent: string;
|
||||||
writeTodos: string;
|
writeTodos: string;
|
||||||
expandContent: string;
|
|
||||||
collapseContent: string;
|
|
||||||
skillInstallTooltip: string;
|
skillInstallTooltip: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
import type { Translations } from "./types";
|
import type { Translations } from "./types";
|
||||||
|
|
||||||
export const zhCN: Translations = {
|
export const zhCN: Translations = {
|
||||||
// 隐蔽版本标识:Tag:v3.2.1 feat: 宿主页复制
|
|
||||||
// Locale meta
|
// Locale meta
|
||||||
locale: {
|
locale: {
|
||||||
localName: "中文",
|
localName: "中文",
|
||||||
|
|
@ -58,7 +57,8 @@ export const zhCN: Translations = {
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
welcome: {
|
welcome: {
|
||||||
greeting: "轻办公 · XClaw",
|
// TODO: 测试环境标识
|
||||||
|
greeting: "轻办公 · XClaw Tag:v3.2.0 --- Skill功能施工中 --- refactor(frontend): 将 SELECT_SKILL 重命名为 SELECT_SKILLS.",
|
||||||
description:
|
description:
|
||||||
"欢迎使用 🦌 DeerFlow,一个完全开源的超级智能体。通过内置和自定义的 Skills,\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等,几乎可以做任何事情。",
|
"欢迎使用 🦌 DeerFlow,一个完全开源的超级智能体。通过内置和自定义的 Skills,\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等,几乎可以做任何事情。",
|
||||||
|
|
||||||
|
|
@ -83,7 +83,6 @@ export const zhCN: Translations = {
|
||||||
sendMessagePrice:
|
sendMessagePrice:
|
||||||
"请注意,此功能将消耗token,请保证账户余额大于200可学豆。",
|
"请注意,此功能将消耗token,请保证账户余额大于200可学豆。",
|
||||||
addAttachments: "添加附件",
|
addAttachments: "添加附件",
|
||||||
history: "历史记录",
|
|
||||||
selectSkill: "选择Skill",
|
selectSkill: "选择Skill",
|
||||||
mode: "模式",
|
mode: "模式",
|
||||||
flashMode: "闪速",
|
flashMode: "闪速",
|
||||||
|
|
@ -266,8 +265,6 @@ export const zhCN: Translations = {
|
||||||
writeFile: "写入文件",
|
writeFile: "写入文件",
|
||||||
clickToViewContent: "点击查看文件内容",
|
clickToViewContent: "点击查看文件内容",
|
||||||
writeTodos: "更新 To-do 列表",
|
writeTodos: "更新 To-do 列表",
|
||||||
expandContent: "展开",
|
|
||||||
collapseContent: "收起",
|
|
||||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ export const POST_MESSAGE_TYPES = {
|
||||||
FULLSCREEN: "fullscreen",
|
FULLSCREEN: "fullscreen",
|
||||||
// 会话是否处于聊天态
|
// 会话是否处于聊天态
|
||||||
IS_CHATTING: "isChatting",
|
IS_CHATTING: "isChatting",
|
||||||
// 请求宿主页执行复制
|
|
||||||
COPY_TO_CLIPBOARD: "copyToClipboard",
|
|
||||||
// 选择预定义 skill
|
// 选择预定义 skill
|
||||||
SELECT_SKILLS: "selectedSkills",
|
SELECT_SKILLS: "selectedSkills",
|
||||||
// 打开 skill 选择对话框
|
// 打开 skill 选择对话框
|
||||||
|
|
@ -23,8 +21,6 @@ export const POST_MESSAGE_TYPES = {
|
||||||
export const RECEIVE_MESSAGE_TYPES = {
|
export const RECEIVE_MESSAGE_TYPES = {
|
||||||
// 选中的 skill 数据
|
// 选中的 skill 数据
|
||||||
SELECTED_SKILL: "selectedSkill",
|
SELECTED_SKILL: "selectedSkill",
|
||||||
// 选中的 skills 数据(数组)
|
|
||||||
SELECTED_SKILLS: "selectedSkills",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 消息类型
|
// 消息类型
|
||||||
|
|
@ -44,11 +40,6 @@ export interface IsChattingMessage {
|
||||||
isChatting: boolean;
|
isChatting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CopyToClipboardMessage {
|
|
||||||
type: typeof POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectSkillMessage {
|
export interface SelectSkillMessage {
|
||||||
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
|
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
selectedSkills: SelectedSkillPayloadItem[];
|
||||||
|
|
@ -79,9 +70,7 @@ function asRecord(value: unknown): UnknownRecord | null {
|
||||||
return value as UnknownRecord;
|
return value as UnknownRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSelectedSkillMessage(
|
export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMessage {
|
||||||
value: unknown,
|
|
||||||
): value is SelectedSkillMessage {
|
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -91,33 +80,11 @@ export function isSelectedSkillMessage(
|
||||||
return isValidId && typeof title === "string" && title.trim().length > 0;
|
return isValidId && typeof title === "string" && title.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSelectedSkillsMessage(
|
|
||||||
value: unknown,
|
|
||||||
): value is SelectSkillMessage {
|
|
||||||
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(
|
export function sendToParent(
|
||||||
message:
|
message:
|
||||||
| FullscreenMessage
|
| FullscreenMessage
|
||||||
| IsChattingMessage
|
| IsChattingMessage
|
||||||
| CopyToClipboardMessage
|
|
||||||
| SelectSkillMessage
|
| SelectSkillMessage
|
||||||
| OpenSkillDialogMessage,
|
| OpenSkillDialogMessage,
|
||||||
): void {
|
): void {
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,5 @@ export function getLocalSettings(): LocalSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLocalSettings(settings: LocalSettings) {
|
export function saveLocalSettings(settings: LocalSettings) {
|
||||||
void settings;
|
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
// 注释了,因为本地存储会污染模型配置
|
|
||||||
console.log("localStorage设置,已经注释");
|
|
||||||
localStorage.removeItem(LOCAL_SETTINGS_KEY);
|
|
||||||
// localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,7 @@ export async function materializeSkillYaml(
|
||||||
): Promise<MaterializeSkillYamlResponse> {
|
): Promise<MaterializeSkillYamlResponse> {
|
||||||
console.log("[skills/api] ========== materializeSkillYaml START ==========");
|
console.log("[skills/api] ========== materializeSkillYaml START ==========");
|
||||||
console.log("[skills/api] request:", JSON.stringify(request, null, 2));
|
console.log("[skills/api] request:", JSON.stringify(request, null, 2));
|
||||||
console.log(
|
console.log("[skills/api] API URL:", `${getBackendBaseURL()}/api/skills/materialize-yaml`);
|
||||||
"[skills/api] API URL:",
|
|
||||||
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
`${getBackendBaseURL()}/api/skills/materialize-yaml`,
|
||||||
|
|
@ -117,11 +114,7 @@ export async function materializeSkillYaml(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log("[skills/api] response status:", response.status, response.statusText);
|
||||||
"[skills/api] response status:",
|
|
||||||
response.status,
|
|
||||||
response.statusText,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
|
||||||
|
|
@ -46,104 +46,27 @@ export type LegacyThreadStreamOptions = {
|
||||||
useSubmitThread?: boolean;
|
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 {
|
function getStreamErrorMessage(error: unknown): string {
|
||||||
const directMessage = readMessageCandidate(error);
|
if (typeof error === "string" && error.trim()) {
|
||||||
if (directMessage) {
|
return error;
|
||||||
return directMessage;
|
|
||||||
}
|
}
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
const visited = new Set<object>();
|
return error.message;
|
||||||
const queue: unknown[] = [error];
|
|
||||||
const preferredKeys = ["message", "detail", "error"];
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.shift();
|
|
||||||
if (current == null) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
if (typeof error === "object" && error !== null) {
|
||||||
const message = readMessageCandidate(current);
|
const message = Reflect.get(error, "message");
|
||||||
if (message) {
|
if (typeof message === "string" && message.trim()) {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
const nestedError = Reflect.get(error, "error");
|
||||||
if (typeof current !== "object") {
|
if (nestedError instanceof Error && nestedError.message.trim()) {
|
||||||
continue;
|
return nestedError.message;
|
||||||
}
|
}
|
||||||
|
if (typeof nestedError === "string" && nestedError.trim()) {
|
||||||
if (visited.has(current)) {
|
return nestedError;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "Request failed.";
|
||||||
if (Array.isArray(current)) {
|
|
||||||
queue.push(...current);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const value of Object.values(current)) {
|
|
||||||
if (value && typeof value === "object") {
|
|
||||||
queue.push(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
export function useThreadStreamLegacy({
|
||||||
|
|
@ -219,10 +142,6 @@ export function useThreadStream({
|
||||||
// and to allow access to the current thread id in onUpdateEvent
|
// and to allow access to the current thread id in onUpdateEvent
|
||||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const lastErrorToastRef = useRef<{
|
|
||||||
message: string;
|
|
||||||
timestamp: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
onStart,
|
onStart,
|
||||||
|
|
@ -236,14 +155,12 @@ export function useThreadStream({
|
||||||
}, [onStart, onFinish, onToolEnd]);
|
}, [onStart, onFinish, onToolEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedThreadId = normalizeThreadId(threadId) ?? null;
|
const normalizedThreadId = threadId ?? null;
|
||||||
if (!normalizedThreadId) {
|
if (!normalizedThreadId) {
|
||||||
// Just reset for new thread creation when threadId becomes null/undefined
|
// Just reset for new thread creation when threadId becomes null/undefined
|
||||||
startedRef.current = false;
|
startedRef.current = false;
|
||||||
|
setOnStreamThreadId(normalizedThreadId);
|
||||||
}
|
}
|
||||||
setOnStreamThreadId((prev) =>
|
|
||||||
prev === normalizedThreadId ? prev : normalizedThreadId,
|
|
||||||
);
|
|
||||||
threadIdRef.current = normalizedThreadId;
|
threadIdRef.current = normalizedThreadId;
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
|
|
||||||
|
|
@ -254,28 +171,6 @@ 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(
|
const handleStreamStart = useCallback(
|
||||||
(_threadId: string) => {
|
(_threadId: string) => {
|
||||||
threadIdRef.current = _threadId;
|
threadIdRef.current = _threadId;
|
||||||
|
|
@ -355,7 +250,7 @@ export function useThreadStream({
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
showStreamErrorToast(error);
|
toast.error(getStreamErrorMessage(error));
|
||||||
},
|
},
|
||||||
onFinish(state) {
|
onFinish(state) {
|
||||||
listeners.current.onFinish?.(state.values);
|
listeners.current.onFinish?.(state.values);
|
||||||
|
|
@ -380,13 +275,6 @@ export function useThreadStream({
|
||||||
}
|
}
|
||||||
}, [thread.messages.length, optimisticMessages.length]);
|
}, [thread.messages.length, optimisticMessages.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!thread.error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showStreamErrorToast(thread.error);
|
|
||||||
}, [thread.error, showStreamErrorToast]);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (
|
async (
|
||||||
threadId: string | undefined,
|
threadId: string | undefined,
|
||||||
|
|
@ -400,9 +288,7 @@ export function useThreadStream({
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const resolvedThreadId =
|
const resolvedThreadId =
|
||||||
normalizeThreadId(threadId) ??
|
threadId ?? threadIdRef.current ?? undefined;
|
||||||
normalizeThreadId(threadIdRef.current) ??
|
|
||||||
undefined;
|
|
||||||
if (resolvedThreadId === "new") {
|
if (resolvedThreadId === "new") {
|
||||||
toast.error("Invalid thread id 'new'. Please refresh and retry.");
|
toast.error("Invalid thread id 'new'. Please refresh and retry.");
|
||||||
sendInFlightRef.current = false;
|
sendInFlightRef.current = false;
|
||||||
|
|
@ -455,14 +341,8 @@ export function useThreadStream({
|
||||||
try {
|
try {
|
||||||
// 新会话模式下,仅在本地已有历史消息时才重置旧线程。
|
// 新会话模式下,仅在本地已有历史消息时才重置旧线程。
|
||||||
// 对于全新 thread_id,避免多发一次 DELETE /threads/{id}(通常会 404)。
|
// 对于全新 thread_id,避免多发一次 DELETE /threads/{id}(通常会 404)。
|
||||||
if (
|
if (createNewSession && resolvedThreadId && thread.messages.length > 0) {
|
||||||
createNewSession &&
|
await apiClient.threads.delete(resolvedThreadId).catch(() => undefined);
|
||||||
resolvedThreadId &&
|
|
||||||
thread.messages.length > 0
|
|
||||||
) {
|
|
||||||
await apiClient.threads
|
|
||||||
.delete(resolvedThreadId)
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload files first if any
|
// Upload files first if any
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ function normalizeThreadId(value?: string | null): string | undefined {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function textOfMessage(message: Message) {
|
export function textOfMessage(message: Message) {
|
||||||
if (typeof message.content === "string") {
|
if (typeof message.content === "string") {
|
||||||
return message.content;
|
return message.content;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import {
|
||||||
Paragraph,
|
Paragraph,
|
||||||
TextRun,
|
TextRun,
|
||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
ImageRun,
|
|
||||||
type ParagraphChild,
|
|
||||||
} from "docx";
|
} from "docx";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
|
@ -59,10 +57,6 @@ export interface DocxOptions {
|
||||||
* @default 22 (11pt)
|
* @default 22 (11pt)
|
||||||
*/
|
*/
|
||||||
codeFontSize?: number;
|
codeFontSize?: number;
|
||||||
/**
|
|
||||||
* 解析 Markdown 里的资源路径(如图片相对路径)
|
|
||||||
*/
|
|
||||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -86,18 +80,10 @@ export async function downloadMarkdownAsDocx(
|
||||||
filename: string,
|
filename: string,
|
||||||
options: DocxOptions = {},
|
options: DocxOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const { codeFont = "Courier New", codeFontSize = 22 } = options;
|
||||||
codeFont = "Courier New",
|
|
||||||
codeFontSize = 22,
|
|
||||||
resolveAssetUrl,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const tokens = marked.lexer(markdown);
|
const tokens = marked.lexer(markdown);
|
||||||
const children = await parseTokensToDocx(tokens, {
|
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
|
||||||
codeFont,
|
|
||||||
codeFontSize,
|
|
||||||
resolveAssetUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const doc = new DocxDocument({
|
const doc = new DocxDocument({
|
||||||
sections: [{ children }],
|
sections: [{ children }],
|
||||||
|
|
@ -126,11 +112,7 @@ export async function downloadMarkdownAsDocx(
|
||||||
export async function downloadMarkdownAsPdf(
|
export async function downloadMarkdownAsPdf(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
options: PdfOptions & {
|
options: PdfOptions = {},
|
||||||
resolveAssetUrl?: (
|
|
||||||
rawPath: string,
|
|
||||||
) => string | null | Promise<string | null>;
|
|
||||||
} = {},
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const html2pdf = await loadHtml2Pdf();
|
const html2pdf = await loadHtml2Pdf();
|
||||||
|
|
||||||
|
|
@ -139,16 +121,10 @@ export async function downloadMarkdownAsPdf(
|
||||||
format = "a4",
|
format = "a4",
|
||||||
orientation = "portrait",
|
orientation = "portrait",
|
||||||
scale = 2,
|
scale = 2,
|
||||||
resolveAssetUrl,
|
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const normalizedMarkdown = await rewriteMarkdownImageSources(
|
|
||||||
markdown,
|
|
||||||
resolveAssetUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 解析 Markdown 为 HTML
|
// 解析 Markdown 为 HTML
|
||||||
const htmlContent = await marked.parse(normalizedMarkdown);
|
const htmlContent = await marked.parse(markdown);
|
||||||
|
|
||||||
// 创建容器并应用样式
|
// 创建容器并应用样式
|
||||||
const container = createStyledContainer(htmlContent);
|
const container = createStyledContainer(htmlContent);
|
||||||
|
|
@ -333,17 +309,16 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void {
|
||||||
/**
|
/**
|
||||||
* 解析 Markdown Token 为 DOCX Paragraph
|
* 解析 Markdown Token 为 DOCX Paragraph
|
||||||
*/
|
*/
|
||||||
async function parseTokensToDocx(
|
function parseTokensToDocx(
|
||||||
tokens: MarkdownToken[],
|
tokens: MarkdownToken[],
|
||||||
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
|
options: Required<DocxOptions>,
|
||||||
Pick<DocxOptions, "resolveAssetUrl">,
|
): Paragraph[] {
|
||||||
): Promise<Paragraph[]> {
|
|
||||||
const paragraphs: Paragraph[] = [];
|
const paragraphs: Paragraph[] = [];
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
case "heading": {
|
case "heading": {
|
||||||
const runs = await parseInlineTokens(token.tokens ?? [], options);
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
paragraphs.push(
|
paragraphs.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: runs,
|
children: runs,
|
||||||
|
|
@ -355,7 +330,7 @@ async function parseTokensToDocx(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "paragraph": {
|
case "paragraph": {
|
||||||
const runs = await parseInlineTokens(token.tokens ?? [], options);
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
paragraphs.push(
|
paragraphs.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: runs.length > 0 ? runs : [new TextRun("")],
|
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||||
|
|
@ -386,8 +361,8 @@ async function parseTokensToDocx(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
for (const item of token.items ?? []) {
|
token.items?.forEach((item: MarkdownToken) => {
|
||||||
const runs = await parseInlineTokens(
|
const runs = parseInlineTokens(
|
||||||
item.tokens?.[0]?.tokens ?? [],
|
item.tokens?.[0]?.tokens ?? [],
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
@ -398,12 +373,12 @@ async function parseTokensToDocx(
|
||||||
spacing: { after: 80 },
|
spacing: { after: 80 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "blockquote": {
|
case "blockquote": {
|
||||||
const runs = await parseInlineTokens(
|
const runs = parseInlineTokens(
|
||||||
token.tokens?.[0]?.tokens ?? [],
|
token.tokens?.[0]?.tokens ?? [],
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
@ -432,19 +407,6 @@ async function parseTokensToDocx(
|
||||||
paragraphs.push(new Paragraph({ children: [] }));
|
paragraphs.push(new Paragraph({ children: [] }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "image": {
|
|
||||||
const imageRun = await createImageRunFromToken(token, options);
|
|
||||||
if (imageRun) {
|
|
||||||
paragraphs.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [imageRun],
|
|
||||||
spacing: { after: 200 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,12 +416,11 @@ async function parseTokensToDocx(
|
||||||
/**
|
/**
|
||||||
* 解析行内 Token 为 TextRun
|
* 解析行内 Token 为 TextRun
|
||||||
*/
|
*/
|
||||||
async function parseInlineTokens(
|
function parseInlineTokens(
|
||||||
tokens: MarkdownToken[],
|
tokens: MarkdownToken[],
|
||||||
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
|
options: Required<DocxOptions>,
|
||||||
Pick<DocxOptions, "resolveAssetUrl">,
|
): TextRun[] {
|
||||||
): Promise<ParagraphChild[]> {
|
const runs: TextRun[] = [];
|
||||||
const runs: ParagraphChild[] = [];
|
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
|
|
@ -499,14 +460,6 @@ async function parseInlineTokens(
|
||||||
runs.push(new TextRun({ text: "", break: 1 }));
|
runs.push(new TextRun({ text: "", break: 1 }));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "image": {
|
|
||||||
const imageRun = await createImageRunFromToken(token, options);
|
|
||||||
if (imageRun) {
|
|
||||||
runs.push(imageRun);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
runs.push(new TextRun(token.raw ?? ""));
|
runs.push(new TextRun(token.raw ?? ""));
|
||||||
}
|
}
|
||||||
|
|
@ -515,157 +468,6 @@ async function parseInlineTokens(
|
||||||
return runs;
|
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,11 +1,6 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
downloadMarkdownAsDocx,
|
|
||||||
downloadMarkdownAsPdf,
|
|
||||||
type DocxOptions,
|
|
||||||
type PdfOptions,
|
|
||||||
} from "./converter";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown 下载 Hook 配置选项
|
* Markdown 下载 Hook 配置选项
|
||||||
|
|
@ -36,23 +31,11 @@ export interface UseMarkdownDownloadReturn {
|
||||||
/**
|
/**
|
||||||
* 下载为 DOCX
|
* 下载为 DOCX
|
||||||
*/
|
*/
|
||||||
downloadAsDocx: (
|
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
|
||||||
markdown: string,
|
|
||||||
filename: string,
|
|
||||||
options?: DocxOptions,
|
|
||||||
) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* 下载为 PDF
|
* 下载为 PDF
|
||||||
*/
|
*/
|
||||||
downloadAsPdf: (
|
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
|
||||||
markdown: string,
|
|
||||||
filename: string,
|
|
||||||
options?: PdfOptions & {
|
|
||||||
resolveAssetUrl?: (
|
|
||||||
rawPath: string,
|
|
||||||
) => string | null | Promise<string | null>;
|
|
||||||
},
|
|
||||||
) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* 是否可以下载(没有正在进行的下载)
|
* 是否可以下载(没有正在进行的下载)
|
||||||
*/
|
*/
|
||||||
|
|
@ -99,14 +82,14 @@ export function useMarkdownDownload(
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadAsDocx = useCallback(
|
const downloadAsDocx = useCallback(
|
||||||
async (markdown: string, filename: string, options?: DocxOptions) => {
|
async (markdown: string, filename: string) => {
|
||||||
if (isDownloading) return;
|
if (isDownloading) return;
|
||||||
|
|
||||||
setIsDownloading("docx");
|
setIsDownloading("docx");
|
||||||
onDownloadStart?.("docx");
|
onDownloadStart?.("docx");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadMarkdownAsDocx(markdown, filename, options);
|
await downloadMarkdownAsDocx(markdown, filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError?.(
|
onError?.(
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
|
@ -121,22 +104,14 @@ export function useMarkdownDownload(
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadAsPdf = useCallback(
|
const downloadAsPdf = useCallback(
|
||||||
async (
|
async (markdown: string, filename: string) => {
|
||||||
markdown: string,
|
|
||||||
filename: string,
|
|
||||||
options?: PdfOptions & {
|
|
||||||
resolveAssetUrl?: (
|
|
||||||
rawPath: string,
|
|
||||||
) => string | null | Promise<string | null>;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
if (isDownloading) return;
|
if (isDownloading) return;
|
||||||
|
|
||||||
setIsDownloading("pdf");
|
setIsDownloading("pdf");
|
||||||
onDownloadStart?.("pdf");
|
onDownloadStart?.("pdf");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadMarkdownAsPdf(markdown, filename, options);
|
await downloadMarkdownAsPdf(markdown, filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError?.(
|
onError?.(
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
POST_MESSAGE_TYPES,
|
POST_MESSAGE_TYPES,
|
||||||
RECEIVE_MESSAGE_TYPES,
|
RECEIVE_MESSAGE_TYPES,
|
||||||
isSelectedSkillMessage,
|
isSelectedSkillMessage,
|
||||||
isSelectedSkillsMessage,
|
|
||||||
type SelectedSkillPayloadItem,
|
type SelectedSkillPayloadItem,
|
||||||
sendToParent,
|
sendToParent,
|
||||||
} from "@/core/iframe-messages";
|
} from "@/core/iframe-messages";
|
||||||
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
|
||||||
|
|
||||||
// Skill 数据类型
|
// Skill 数据类型
|
||||||
interface SkillData {
|
interface SkillData {
|
||||||
|
|
@ -18,127 +15,22 @@ interface SkillData {
|
||||||
title: string;
|
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 返回类型
|
// Hook 返回类型
|
||||||
interface UseIframeSkillReturn {
|
interface UseIframeSkillReturn {
|
||||||
selectedSkill: SkillData | null;
|
selectedSkill: SkillData | null;
|
||||||
selectedSkills: SkillData[];
|
|
||||||
isBootstrapping: boolean;
|
|
||||||
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||||
bootstrapAndLockSkills: (params: {
|
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
|
||||||
title: string;
|
|
||||||
}) => Promise<boolean>;
|
|
||||||
openSkillDialog: () => void;
|
openSkillDialog: () => void;
|
||||||
clearSkill: (skillId?: string) => void;
|
clearSkill: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseIframeSkillOptions {
|
export function useIframeSkill(): UseIframeSkillReturn {
|
||||||
threadId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIframeSkill(
|
|
||||||
options?: UseIframeSkillOptions,
|
|
||||||
): UseIframeSkillReturn {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const threadIdFromQuery = searchParams.get("thread_id");
|
const threadIdFromQuery = searchParams.get("thread_id");
|
||||||
const threadId = options?.threadId?.trim() || threadIdFromQuery;
|
|
||||||
const isChattingFromQuery = searchParams.get("is_chatting");
|
const isChattingFromQuery = searchParams.get("is_chatting");
|
||||||
const lastThreadIdRef = useRef<string | null>(null);
|
const lastThreadIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | 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 参数变化(临时禁用)
|
// 1. 监听 query 参数变化(临时禁用)
|
||||||
// TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。
|
// TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。
|
||||||
|
|
@ -151,188 +43,37 @@ export function useIframeSkill(
|
||||||
// }, [searchParams]);
|
// }, [searchParams]);
|
||||||
|
|
||||||
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
|
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (!threadId) return;
|
if (!threadIdFromQuery) return;
|
||||||
// if (isChattingFromQuery !== "true") return;
|
if (isChattingFromQuery !== "true") return;
|
||||||
// if (lastThreadIdRef.current === threadId) return;
|
if (lastThreadIdRef.current === threadIdFromQuery) return;
|
||||||
// lastThreadIdRef.current = threadId;
|
lastThreadIdRef.current = threadIdFromQuery;
|
||||||
// router.replace(`/workspace/chats/${threadId}`);
|
router.replace(`/workspace/chats/${threadIdFromQuery}`);
|
||||||
// }, [isChattingFromQuery, router, threadId]);
|
}, [isChattingFromQuery, router, threadIdFromQuery]);
|
||||||
|
|
||||||
// 2. 监听宿主页 postMessage
|
// 2. 监听宿主页 postMessage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (isSelectedSkillMessage(event.data)) {
|
if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSelectedSkillMessage(event.data)) {
|
||||||
|
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { id, title } = event.data;
|
const { id, title } = event.data;
|
||||||
const singleSkill = { skill_id: String(id), title };
|
setSelectedSkill({ skill_id: String(id), title });
|
||||||
setSelectedSkill(singleSkill);
|
|
||||||
setSelectedSkills([singleSkill]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
|
||||||
console.warn(
|
|
||||||
"[useIframeSkill] 忽略非法 selectedSkill 消息",
|
|
||||||
event.data,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
window.addEventListener("message", handleMessage);
|
window.addEventListener("message", handleMessage);
|
||||||
return () => window.removeEventListener("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
|
// 发送选择预定义 skill
|
||||||
const sendSelectSkill = useCallback(
|
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
|
||||||
(selectedSkills: SelectedSkillPayloadItem[]) => {
|
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
|
||||||
const message = {
|
|
||||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
|
||||||
selectedSkills,
|
|
||||||
};
|
|
||||||
console.log("[useIframeSkill] sendSelectSkill:", message);
|
console.log("[useIframeSkill] sendSelectSkill:", message);
|
||||||
sendToParent(message);
|
sendToParent(message);
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bootstrapAndLockSkills = useCallback(
|
|
||||||
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 选择对话框
|
// 打开 skill 选择对话框
|
||||||
const openSkillDialog = useCallback(() => {
|
const openSkillDialog = useCallback(() => {
|
||||||
|
|
@ -345,66 +86,13 @@ export function useIframeSkill(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 清除选中并发送空 selectedSkills 数组给主页
|
// 清除选中并发送空 selectedSkills 数组给主页
|
||||||
const clearSkill = useCallback(
|
const clearSkill = useCallback(() => {
|
||||||
(skillId?: string) => {
|
setSelectedSkill(null);
|
||||||
const removeAll = !skillId;
|
// 发送空数组给主页,通知取消选择
|
||||||
const nextSelectedSkills = removeAll
|
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
|
||||||
? []
|
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
||||||
: selectedSkills.filter((skill) => skill.skill_id !== String(skillId));
|
|
||||||
|
|
||||||
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);
|
sendToParent(message);
|
||||||
},
|
}, []);
|
||||||
[selectedSkills, threadId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||||||
selectedSkill,
|
|
||||||
selectedSkills,
|
|
||||||
isBootstrapping,
|
|
||||||
sendSelectSkill,
|
|
||||||
bootstrapAndLockSkills,
|
|
||||||
openSkillDialog,
|
|
||||||
clearSkill,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@ import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useCallback, useState, useRef } from "react";
|
import { useEffect, useCallback, useState, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import { isSelectedSkillMessage } from "@/core/iframe-messages";
|
||||||
isSelectedSkillMessage,
|
|
||||||
isSelectedSkillsMessage,
|
|
||||||
type SelectedSkillPayloadItem,
|
|
||||||
} from "@/core/iframe-messages";
|
|
||||||
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
||||||
|
|
||||||
/** 技能基础数据 */
|
/** 技能基础数据 */
|
||||||
|
|
@ -55,20 +51,14 @@ export function useSelectedSkillListener({
|
||||||
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const performBootstrap = useCallback(
|
const performBootstrap = useCallback(
|
||||||
async (skills: SelectedSkillPayloadItem[], title: string) => {
|
async (id: number | string, title: string) => {
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
const contentIds = Array.from(
|
const contentId = Number(id);
|
||||||
new Set(
|
if (!Number.isFinite(contentId) || contentId <= 0) {
|
||||||
skills
|
console.warn("[useSelectedSkillListener] 忽略非法 skill id", id);
|
||||||
.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({
|
setSkillError({
|
||||||
title: `技能「${title}」加载失败`,
|
title: `技能「${title}」加载失败`,
|
||||||
message: "非法 skill_id 数组",
|
message: `非法 skill id: ${String(id)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -78,13 +68,13 @@ export function useSelectedSkillListener({
|
||||||
searchParams.get("language_type")?.trim();
|
searchParams.get("language_type")?.trim();
|
||||||
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
||||||
|
|
||||||
const initKey = `${threadId}:${contentIds.join(",")}:${languageType}`;
|
const initKey = `${threadId}:${id}:${languageType}`;
|
||||||
if (skillBootstrappedKeyRef.current === initKey) {
|
if (skillBootstrappedKeyRef.current === initKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`,
|
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`,
|
||||||
);
|
);
|
||||||
setIsBootstrapping(true);
|
setIsBootstrapping(true);
|
||||||
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
|
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
|
||||||
|
|
@ -92,7 +82,7 @@ export function useSelectedSkillListener({
|
||||||
try {
|
try {
|
||||||
const result = await bootstrapRemoteSkill({
|
const result = await bootstrapRemoteSkill({
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
content_ids: contentIds,
|
content_ids: [contentId],
|
||||||
language_type: languageType,
|
language_type: languageType,
|
||||||
target_dir: "/mnt/user-data/uploads/skill",
|
target_dir: "/mnt/user-data/uploads/skill",
|
||||||
clear_target: true,
|
clear_target: true,
|
||||||
|
|
@ -133,39 +123,23 @@ export function useSelectedSkillListener({
|
||||||
if (skillIdFromQuery && titleFromQuery) {
|
if (skillIdFromQuery && titleFromQuery) {
|
||||||
isFirstLoadRef.current = true;
|
isFirstLoadRef.current = true;
|
||||||
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
|
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
|
||||||
void performBootstrap(
|
void performBootstrap(skillIdFromQuery, titleFromQuery);
|
||||||
[{ id: skillIdFromQuery, name: titleFromQuery }],
|
|
||||||
titleFromQuery,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [threadId, searchParams, performBootstrap]);
|
}, [threadId, searchParams, performBootstrap]);
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(event: MessageEvent) => {
|
(event: MessageEvent) => {
|
||||||
if (isSelectedSkillMessage(event.data)) {
|
if (!isSelectedSkillMessage(event.data)) return;
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
|
|
||||||
const { id, title } = data;
|
const { id, title } = data;
|
||||||
console.log(
|
console.log(
|
||||||
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
|
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
setSelectedSkill({ skill_id: String(id), title });
|
|
||||||
void performBootstrap([{ id, name: title }], title);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelectedSkillsMessage(event.data)) {
|
setSelectedSkill({ skill_id: String(id), title });
|
||||||
const { selectedSkills } = event.data;
|
void performBootstrap(id, title);
|
||||||
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],
|
[performBootstrap],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|
@ -19,14 +18,14 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
||||||
export async function copyToClipboard(text: string): Promise<void> {
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
const isInIframe = window.self !== window.top;
|
const isInIframe = window.self !== window.top;
|
||||||
const message = {
|
const message = {
|
||||||
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
type: "copyToClipboard",
|
||||||
text,
|
text,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
if (isInIframe) {
|
if (isInIframe && window.parent) {
|
||||||
try {
|
try {
|
||||||
// Request parent window to copy
|
// Request parent window to copy
|
||||||
sendToParent(message);
|
window.parent.postMessage(message, "*");
|
||||||
console.log(
|
console.log(
|
||||||
"[copyToClipboard] iframe mode → postMessage to parent",
|
"[copyToClipboard] iframe mode → postMessage to parent",
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -411,13 +411,6 @@
|
||||||
--container-width-lg: calc(var(--spacing) * 256);
|
--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
|
Streamdown Markdown Styles
|
||||||
使用 data-streamdown 属性选择器统一定义
|
使用 data-streamdown 属性选择器统一定义
|
||||||
|
|
@ -438,8 +431,7 @@ code,
|
||||||
kbd,
|
kbd,
|
||||||
samp,
|
samp,
|
||||||
pre {
|
pre {
|
||||||
font-family:
|
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
|
||||||
"Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表项 - 14px */
|
/* 列表项 - 14px */
|
||||||
|
|
@ -460,9 +452,9 @@ pre {
|
||||||
font-size: calc(16px * var(--zoom-scale));
|
font-size: calc(16px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 代码块 - 14px */
|
/* 二三级标题 - 16px */
|
||||||
[data-streamdown="code-block"] pre {
|
[data-streamdown="code-block"] pre {
|
||||||
font-size: calc(14px * var(--zoom-scale));
|
font-size: calc(16px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-line {
|
.cm-line {
|
||||||
|
|
@ -493,11 +485,3 @@ pre {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pptx-preview-wrap {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pptx-preview-wrap .pptx-preview-wrapper {
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
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,13 +1,11 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PRIMARY_THREAD_ID,
|
|
||||||
THREAD_WITH_ARTIFACTS,
|
THREAD_WITH_ARTIFACTS,
|
||||||
THREAD_WITH_HTML_ARTIFACT,
|
THREAD_WITH_HTML_ARTIFACT,
|
||||||
THREAD_WITH_IMAGE_ARTIFACT,
|
THREAD_WITH_IMAGE_ARTIFACT,
|
||||||
openChat,
|
openChat,
|
||||||
reuseThreadChatEntry,
|
reuseThreadChatEntry,
|
||||||
sendMessage,
|
|
||||||
skipIfMissingThread,
|
skipIfMissingThread,
|
||||||
waitForAnyMessages,
|
waitForAnyMessages,
|
||||||
waitForMessageListReady,
|
waitForMessageListReady,
|
||||||
|
|
@ -53,9 +51,7 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
|
|
||||||
await page.getByTestId("artifacts-open-button").click();
|
await page.getByTestId("artifacts-open-button").click();
|
||||||
const imageFile = page
|
const imageFile = page
|
||||||
.locator(
|
.locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']")
|
||||||
"[data-testid='artifact-file-list'] [data-testid='artifact-file-card']",
|
|
||||||
)
|
|
||||||
.filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i })
|
.filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i })
|
||||||
.first();
|
.first();
|
||||||
testInfo.skip(
|
testInfo.skip(
|
||||||
|
|
@ -84,31 +80,15 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
|
|
||||||
await page.getByTestId("artifacts-open-button").click();
|
await page.getByTestId("artifacts-open-button").click();
|
||||||
const htmlFile = page
|
const htmlFile = page
|
||||||
.locator(
|
.locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']")
|
||||||
"[data-testid='artifact-file-list'] [data-testid='artifact-file-card']",
|
|
||||||
)
|
|
||||||
.filter({ hasText: /\.html?/i })
|
.filter({ hasText: /\.html?/i })
|
||||||
.first();
|
.first();
|
||||||
testInfo.skip(
|
testInfo.skip(
|
||||||
(await htmlFile.count()) === 0,
|
(await htmlFile.count()) === 0,
|
||||||
"当前线程没有 HTML artifact。",
|
"当前线程没有 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();
|
await htmlFile.click();
|
||||||
|
|
||||||
const htmlArtifactResponse = await htmlArtifactResponsePromise;
|
|
||||||
expect(
|
|
||||||
htmlArtifactResponse.headers()["content-disposition"] ?? "",
|
|
||||||
).toContain("attachment;");
|
|
||||||
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
|
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -137,31 +117,4 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
||||||
await expect(page.getByTestId("artifacts-open-button")).toBeVisible();
|
await expect(page.getByTestId("artifacts-open-button")).toBeVisible();
|
||||||
await expect(page.getByRole("log").first()).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,7 +10,11 @@ import {
|
||||||
|
|
||||||
test.describe("聊天工作台 / 输入区与发送", () => {
|
test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
|
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!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -28,7 +32,11 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
|
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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 openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -44,17 +52,18 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
|
await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
|
||||||
await expect.poll(inputHeight).toBeGreaterThan(120);
|
await expect.poll(inputHeight).toBeGreaterThan(120);
|
||||||
|
|
||||||
await page
|
await page.getByRole("main").first().click({ position: { x: 20, y: 20 } });
|
||||||
.getByRole("main")
|
|
||||||
.first()
|
|
||||||
.click({ position: { x: 20, y: 20 } });
|
|
||||||
await expect.poll(inputHeight).toBeLessThan(110);
|
await expect.poll(inputHeight).toBeLessThan(110);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
|
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const suggestions = page.getByTestId("welcome-suggestions");
|
const suggestions = page.getByTestId("welcome-suggestions");
|
||||||
|
|
@ -67,7 +76,11 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => {
|
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!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -82,7 +95,11 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
|
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
@ -102,7 +119,11 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
|
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ import {
|
||||||
waitForMessageListReady,
|
waitForMessageListReady,
|
||||||
} from "./support/chat-helpers";
|
} from "./support/chat-helpers";
|
||||||
|
|
||||||
async function waitForAnyMessages(
|
async function waitForAnyMessages(page: Parameters<typeof openChat>[0], timeoutMs = 15_000) {
|
||||||
page: Parameters<typeof openChat>[0],
|
|
||||||
timeoutMs = 15_000,
|
|
||||||
) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const count = await page.locator(".is-user, .is-assistant").count();
|
const count = await page.locator(".is-user, .is-assistant").count();
|
||||||
|
|
@ -79,10 +76,7 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
||||||
const target = element as HTMLElement;
|
const target = element as HTMLElement;
|
||||||
return target.scrollHeight - target.clientHeight > 20;
|
return target.scrollHeight - target.clientHeight > 20;
|
||||||
});
|
});
|
||||||
testInfo.skip(
|
testInfo.skip(canScroll === false, "当前线程消息区高度不足,无法触发滚动到底部按钮。");
|
||||||
canScroll === false,
|
|
||||||
"当前线程消息区高度不足,无法触发滚动到底部按钮。",
|
|
||||||
);
|
|
||||||
|
|
||||||
await messageLog.hover();
|
await messageLog.hover();
|
||||||
await page.mouse.wheel(0, -1200);
|
await page.mouse.wheel(0, -1200);
|
||||||
|
|
@ -121,10 +115,7 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
expect(afterUsers.length).toBe(beforeUsers.length);
|
expect(afterUsers.length).toBe(beforeUsers.length);
|
||||||
for (const sample of beforeUsers.slice(
|
for (const sample of beforeUsers.slice(0, Math.min(3, beforeUsers.length))) {
|
||||||
0,
|
|
||||||
Math.min(3, beforeUsers.length),
|
|
||||||
)) {
|
|
||||||
expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy();
|
expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -141,10 +132,7 @@ test.describe("聊天工作台 / 消息区与历史", () => {
|
||||||
await waitForMessageListReady(page, { requireMessages: false });
|
await waitForMessageListReady(page, { requireMessages: false });
|
||||||
|
|
||||||
const todoButton = page.getByRole("button", { name: /To-dos/i });
|
const todoButton = page.getByRole("button", { name: /To-dos/i });
|
||||||
testInfo.skip(
|
testInfo.skip((await todoButton.count()) === 0, "当前线程未展示 To-dos 入口。");
|
||||||
(await todoButton.count()) === 0,
|
|
||||||
"当前线程未展示 To-dos 入口。",
|
|
||||||
);
|
|
||||||
await expect(todoButton).toBeVisible();
|
await expect(todoButton).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,9 @@ function envThread(name: string) {
|
||||||
export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined;
|
export const PRIMARY_THREAD_ID = rawPrimaryThreadId ?? undefined;
|
||||||
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
|
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
|
||||||
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
|
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
|
||||||
export const THREAD_WITH_MARKDOWN = envThread(
|
export const THREAD_WITH_MARKDOWN = envThread("FRONTEND_E2E_MARKDOWN_THREAD_ID");
|
||||||
"FRONTEND_E2E_MARKDOWN_THREAD_ID",
|
|
||||||
);
|
|
||||||
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
|
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
|
||||||
export const THREAD_WITH_ARTIFACTS = envThread(
|
export const THREAD_WITH_ARTIFACTS = envThread("FRONTEND_E2E_ARTIFACTS_THREAD_ID");
|
||||||
"FRONTEND_E2E_ARTIFACTS_THREAD_ID",
|
|
||||||
);
|
|
||||||
export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
|
export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
|
||||||
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
|
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
|
||||||
);
|
);
|
||||||
|
|
@ -112,9 +108,10 @@ export async function waitForMessageListReady(
|
||||||
await expect(page.getByRole("main").first()).toBeVisible();
|
await expect(page.getByRole("main").first()).toBeVisible();
|
||||||
if (requireMessages) {
|
if (requireMessages) {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await page.locator(".is-user, .is-assistant").count(), {
|
.poll(
|
||||||
timeout: 30_000,
|
async () => await page.locator(".is-user, .is-assistant").count(),
|
||||||
})
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
.toBeGreaterThan(minMessages - 1);
|
.toBeGreaterThan(minMessages - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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,26 +1,17 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
THREAD_FOR_WELCOME,
|
THREAD_FOR_WELCOME,
|
||||||
newChatEntry,
|
newChatEntry,
|
||||||
openChat,
|
openChat,
|
||||||
reuseThreadChatEntry,
|
reuseThreadChatEntry,
|
||||||
sendMessage,
|
|
||||||
skipIfMissingThread,
|
skipIfMissingThread,
|
||||||
waitForAnyMessages,
|
waitForAnyMessages,
|
||||||
waitForMessageListReady,
|
waitForMessageListReady,
|
||||||
} from "./support/chat-helpers";
|
} from "./support/chat-helpers";
|
||||||
|
|
||||||
test.use({
|
|
||||||
screenshot: "on",
|
|
||||||
video: "on",
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("线程路由(无 isnew)", () => {
|
test.describe("线程路由(无 isnew)", () => {
|
||||||
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({
|
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({ page }, testInfo) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
|
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
@ -35,73 +26,7 @@ test.describe("线程路由(无 isnew)", () => {
|
||||||
const messageCount = await waitForAnyMessages(page);
|
const messageCount = await waitForAnyMessages(page);
|
||||||
testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。");
|
testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。");
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`));
|
||||||
new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`),
|
|
||||||
);
|
|
||||||
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
|
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
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,7 +15,11 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
|
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
const suggestions = page.getByTestId("welcome-suggestions");
|
const suggestions = page.getByTestId("welcome-suggestions");
|
||||||
|
|
@ -27,7 +31,11 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
|
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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 openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
|
|
@ -37,7 +45,9 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({ page }) => {
|
test("DF-ROUTE-003 /new 缺少 thread_id 时进入欢迎态", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto(invalidNewChatUrl());
|
await page.goto(invalidNewChatUrl());
|
||||||
|
|
||||||
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
||||||
|
|
@ -47,7 +57,11 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
|
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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 openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
await waitForMessageListReady(page);
|
await waitForMessageListReady(page);
|
||||||
|
|
||||||
|
|
@ -62,7 +76,11 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
|
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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 openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
await waitForMessageListReady(page);
|
await waitForMessageListReady(page);
|
||||||
|
|
||||||
|
|
@ -78,7 +96,11 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
|
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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 openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
await waitForMessageListReady(page);
|
await waitForMessageListReady(page);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue