diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index 76740cdc..2e607197 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -30,14 +30,14 @@ export function useThreadChat() { return undefined; } const stored = window.sessionStorage.getItem("workspace.thread_id"); - return stored && stored !== "new" ? stored : undefined; + return isValidThreadId(stored) ? stored : undefined; }; const searchParams = useSearchParams(); // 读取 query 的 thread_id(先用 hook,必要时用 window 兜底)。 const readQueryThreadId = () => { const fromHook = searchParams.get("thread_id")?.trim(); - if (fromHook && fromHook !== "new") { + if (isValidThreadId(fromHook)) { return fromHook; } if (typeof window === "undefined") { @@ -46,7 +46,7 @@ export function useThreadChat() { const fromLocation = new URLSearchParams(window.location.search).get( "thread_id", ); - if (fromLocation && fromLocation !== "new") { + if (isValidThreadId(fromLocation)) { return fromLocation.trim(); } return undefined; @@ -113,3 +113,14 @@ export function useThreadChat() { invalidNewRoute, }; } + +function isValidThreadId(value?: string | null): value is string { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return ( + normalized.length > 0 && + normalized !== "new" && + normalized !== "undefined" && + normalized !== "null" + ); +} diff --git a/frontend/src/components/workspace/export-trigger.tsx b/frontend/src/components/workspace/export-trigger.tsx index b75d4e45..db83e19b 100644 --- a/frontend/src/components/workspace/export-trigger.tsx +++ b/frontend/src/components/workspace/export-trigger.tsx @@ -21,7 +21,7 @@ import type { AgentThread } from "@/core/threads/types"; import { useThread } from "./messages/context"; import { Tooltip } from "./tooltip"; -export function ExportTrigger({ threadId }: { threadId: string }) { +export function ExportTrigger({ threadId }: { threadId?: string }) { const { t } = useI18n(); const { thread } = useThread(); @@ -39,17 +39,23 @@ export function ExportTrigger({ threadId }: { threadId: string }) { values: thread.values, } as AgentThread; - if (format === "markdown") { - exportThreadAsMarkdown(agentThread, messages); - } else { - exportThreadAsJSON(agentThread, messages); + try { + if (format === "markdown") { + exportThreadAsMarkdown(agentThread, messages); + } else { + exportThreadAsJSON(agentThread, messages); + } + toast.success(t.common.exportSuccess); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to export"; + toast.error(message); } - toast.success(t.common.exportSuccess); }, [messages, thread.values, threadId, t], ); - if (messages.length === 0) { + if (!threadId || messages.length === 0) { return null; } diff --git a/frontend/src/core/iframe-messages.ts b/frontend/src/core/iframe-messages.ts new file mode 100644 index 00000000..fe238ed6 --- /dev/null +++ b/frontend/src/core/iframe-messages.ts @@ -0,0 +1,90 @@ +/** + * iframe 与宿主页通信消息类型常量 + * + * 消息格式:{ type: MESSAGE_TYPE, ...其他字段 } + * 发送方式:window.parent.postMessage(message, "*") + */ + +// 发送给宿主页的消息类型 +export const POST_MESSAGE_TYPES = { + // 全屏切换 + FULLSCREEN: "fullscreen", + // XClaw 使用状态 + XCLAW_USED: "XClawUsed", + // 选择预定义 skill + SELECT_SKILL: "selectSkill", + // 打开 skill 选择对话框 + OPEN_SKILL_DIALOG: "openSkillDialog", +} as const; + +// 接收来自宿主页的消息类型 +export const RECEIVE_MESSAGE_TYPES = { + // 选中的 skill 数据 + SELECTED_SKILL: "selectedSkill", +} as const; + +// 消息类型 +export type PostMessageType = + (typeof POST_MESSAGE_TYPES)[keyof typeof POST_MESSAGE_TYPES]; +export type ReceiveMessageType = + (typeof RECEIVE_MESSAGE_TYPES)[keyof typeof RECEIVE_MESSAGE_TYPES]; + +// 消息数据类型 +export interface FullscreenMessage { + type: typeof POST_MESSAGE_TYPES.FULLSCREEN; + fullscreen: boolean; +} + +export interface XClawUsedMessage { + type: typeof POST_MESSAGE_TYPES.XCLAW_USED; + XClawUsed: boolean; +} + +export interface SelectSkillMessage { + type: typeof POST_MESSAGE_TYPES.SELECT_SKILL; + skill_id: string; +} + +export interface OpenSkillDialogMessage { + type: typeof POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG; + openSkillDialog: true; +} + +export interface SelectedSkillMessage { + type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL; + id: string | number; + title: string; +} + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + if (typeof value !== "object" || value === null) { + return null; + } + return value as UnknownRecord; +} + +export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMessage { + const record = asRecord(value); + if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { + return false; + } + const { id, title } = record; + const isValidId = typeof id === "string" || typeof id === "number"; + return isValidId && typeof title === "string" && title.trim().length > 0; +} + +// 发送消息的辅助函数 +export function sendToParent( + message: + | FullscreenMessage + | XClawUsedMessage + | SelectSkillMessage + | OpenSkillDialogMessage, +): void { + console.log("[iframe] sendToParent:", message); + if (window.parent !== window) { + window.parent.postMessage(message, "*"); + } +} diff --git a/frontend/src/core/threads/export.ts b/frontend/src/core/threads/export.ts index cf1f92e4..cd6fbf7c 100644 --- a/frontend/src/core/threads/export.ts +++ b/frontend/src/core/threads/export.ts @@ -113,15 +113,21 @@ export function downloadAsFile( filename: string, mimeType: string, ) { + if (typeof document === "undefined") { + throw new Error("Download is only supported in browser environment."); + } const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + try { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } finally { + URL.revokeObjectURL(url); + } } export function exportThreadAsMarkdown( diff --git a/frontend/src/core/utils/markdown-download/converter.ts b/frontend/src/core/utils/markdown-download/converter.ts new file mode 100644 index 00000000..df81bbb2 --- /dev/null +++ b/frontend/src/core/utils/markdown-download/converter.ts @@ -0,0 +1,507 @@ +import { + Document as DocxDocument, + Packer, + Paragraph, + TextRun, + HeadingLevel, +} from "docx"; +import { marked } from "marked"; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Markdown Token 类型(简化版) + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MarkdownToken = any; + +/** + * PDF 转换选项 + */ +export interface PdfOptions { + /** + * 页边距 [上, 右, 下, 左],单位 mm + * @default [15, 15, 15, 15] + */ + margin?: [number, number, number, number]; + /** + * 页面格式 + * @default "a4" + */ + format?: "a3" | "a4" | "a5" | "letter" | "legal"; + /** + * 页面方向 + * @default "portrait" + */ + orientation?: "portrait" | "landscape"; + /** + * 缩放比例 + * @default 2 + */ + scale?: number; +} + +/** + * DOCX 转换选项 + */ +export interface DocxOptions { + /** + * 代码块字体 + * @default "Courier New" + */ + codeFont?: string; + /** + * 代码块字号(半磅) + * @default 22 (11pt) + */ + codeFontSize?: number; +} + +// ============================================================================ +// DOCX Converter +// ============================================================================ + +/** + * 将 Markdown 内容转换为 DOCX 文件并下载 + * + * @param markdown - Markdown 文本内容 + * @param filename - 文件名(不含扩展名,或包含 .md 扩展名) + * @param options - 转换选项 + * + * @example + * ```ts + * await downloadMarkdownAsDocx("# Hello World", "document"); + * ``` + */ +export async function downloadMarkdownAsDocx( + markdown: string, + filename: string, + options: DocxOptions = {}, +): Promise { + const { codeFont = "Courier New", codeFontSize = 22 } = options; + + const tokens = marked.lexer(markdown); + const children = parseTokensToDocx(tokens, { codeFont, codeFontSize }); + + const doc = new DocxDocument({ + sections: [{ children }], + }); + + const blob = await Packer.toBlob(doc); + downloadBlob(blob, normalizeFilename(filename, ".docx")); +} + +// ============================================================================ +// PDF Converter +// ============================================================================ + +/** + * 将 Markdown 内容转换为 PDF 文件并下载 + * + * @param markdown - Markdown 文本内容 + * @param filename - 文件名(不含扩展名,或包含 .md 扩展名) + * @param options - 转换选项 + * + * @example + * ```ts + * await downloadMarkdownAsPdf("# Hello World", "document"); + * ``` + */ +export async function downloadMarkdownAsPdf( + markdown: string, + filename: string, + options: PdfOptions = {}, +): Promise { + const html2pdf = await loadHtml2Pdf(); + + const { + margin = [15, 15, 15, 15], + format = "a4", + orientation = "portrait", + scale = 2, + } = options; + + // 解析 Markdown 为 HTML + const htmlContent = await marked.parse(markdown); + + // 创建容器并应用样式 + const container = createStyledContainer(htmlContent); + + // 配置 html2pdf + const opt = { + margin, + filename: normalizeFilename(filename, ".pdf"), + image: { type: "jpeg" as const, quality: 0.98 }, + html2canvas: { + scale, + useCORS: true, + logging: false, + onclone: fixColorsForHtml2Canvas, + }, + jsPDF: { unit: "mm" as const, format, orientation }, + }; + + await html2pdf().set(opt).from(container).save(); +} + +// ============================================================================ +// Internal Utilities +// ============================================================================ + +/** + * 动态加载 html2pdf.js + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +async function loadHtml2Pdf(): Promise { + const html2pdf = await import("html2pdf.js"); + return html2pdf.default; +} + +/** + * 创建带样式的 HTML 容器 + */ +function createStyledContainer(htmlContent: string): HTMLDivElement { + const container = document.createElement("div"); + container.innerHTML = htmlContent; + + // 容器基础样式 + container.style.cssText = ` + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.6; + padding: 20px; + max-width: 800px; + color: #333333; + background-color: #ffffff; + `; + + // 应用元素样式 + applyElementStyles(container); + + return container; +} + +/** + * 应用元素样式 + */ +function applyElementStyles(container: HTMLElement): void { + // 标题 + container.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => { + const el = h as HTMLElement; + el.style.marginTop = "1.5em"; + el.style.marginBottom = "0.5em"; + el.style.fontWeight = "600"; + el.style.color = "#1a1a1a"; + }); + + // 段落 + container.querySelectorAll("p").forEach((p) => { + (p as HTMLElement).style.marginBottom = "1em"; + }); + + // 代码块 + container.querySelectorAll("pre, code").forEach((code) => { + const el = code as HTMLElement; + el.style.fontFamily = "'SF Mono', 'Fira Code', Consolas, monospace"; + el.style.backgroundColor = "#f5f5f5"; + el.style.color = "#333333"; + el.style.fontSize = "13px"; + if (code.tagName === "PRE") { + el.style.padding = "12px"; + el.style.borderRadius = "6px"; + el.style.overflow = "auto"; + } else { + el.style.padding = "2px 4px"; + el.style.borderRadius = "3px"; + } + }); + + // 列表 + container.querySelectorAll("ul, ol").forEach((list) => { + const el = list as HTMLElement; + el.style.marginBottom = "1em"; + el.style.paddingLeft = "2em"; + }); + + // 引用块 + container.querySelectorAll("blockquote").forEach((bq) => { + const el = bq as HTMLElement; + el.style.borderLeft = "4px solid #dddddd"; + el.style.marginLeft = "0"; + el.style.paddingLeft = "16px"; + el.style.color = "#666666"; + }); + + // 表格 + container.querySelectorAll("table").forEach((table) => { + const el = table as HTMLElement; + el.style.borderCollapse = "collapse"; + el.style.width = "100%"; + el.style.marginBottom = "1em"; + }); + + container.querySelectorAll("th, td").forEach((cell) => { + const el = cell as HTMLElement; + el.style.border = "1px solid #dddddd"; + el.style.padding = "8px"; + }); + + // 链接 + container.querySelectorAll("a").forEach((link) => { + const el = link as HTMLElement; + el.style.color = "#0066cc"; + el.style.textDecoration = "underline"; + }); + + // 分割线 + container.querySelectorAll("hr").forEach((hr) => { + const el = hr as HTMLElement; + el.style.border = "none"; + el.style.borderTop = "1px solid #dddddd"; + el.style.margin = "2em 0"; + }); +} + +/** + * 修复 html2canvas 不支持的颜色函数 + */ +function fixColorsForHtml2Canvas(clonedDoc: Document): void { + // 移除外部样式表(可能包含 lab、oklab 等不支持的颜色) + clonedDoc + .querySelectorAll< + HTMLStyleElement | HTMLLinkElement + >('link[rel="stylesheet"], style') + .forEach((sheet) => sheet.remove()); + + // 重置所有元素的颜色属性为安全值 + clonedDoc.querySelectorAll("*").forEach((el) => { + const props = [ + "color", + "background-color", + "border-color", + "border-top-color", + "border-bottom-color", + "border-left-color", + "border-right-color", + "outline-color", + "text-decoration-color", + "caret-color", + "column-rule-color", + "accent-color", + "fill", + "stroke", + ]; + + props.forEach((prop) => el.style.removeProperty(prop)); + + el.style.color = "#333333"; + el.style.backgroundColor = "transparent"; + }); + + // 设置 body 背景 + const body = clonedDoc.body; + body.style.color = "#333333"; + body.style.backgroundColor = "#ffffff"; +} + +/** + * 解析 Markdown Token 为 DOCX Paragraph + */ +function parseTokensToDocx( + tokens: MarkdownToken[], + options: Required, +): Paragraph[] { + const paragraphs: Paragraph[] = []; + + for (const token of tokens) { + switch (token.type) { + case "heading": { + const runs = parseInlineTokens(token.tokens ?? [], options); + paragraphs.push( + new Paragraph({ + children: runs, + heading: getHeadingLevel(token.depth), + spacing: { before: 240, after: 120 }, + }), + ); + break; + } + + case "paragraph": { + const runs = parseInlineTokens(token.tokens ?? [], options); + paragraphs.push( + new Paragraph({ + children: runs.length > 0 ? runs : [new TextRun("")], + spacing: { after: 200 }, + }), + ); + break; + } + + case "code": { + const lines = token.text.split("\n"); + lines.forEach((line: string) => { + paragraphs.push( + new Paragraph({ + children: [ + new TextRun({ + text: line.length > 0 ? line : " ", + font: options.codeFont, + size: options.codeFontSize, + }), + ], + shading: { fill: "F5F5F5" }, + }), + ); + }); + paragraphs.push(new Paragraph({ children: [] })); + break; + } + + case "list": { + token.items?.forEach((item: MarkdownToken) => { + const runs = parseInlineTokens( + item.tokens?.[0]?.tokens ?? [], + options, + ); + paragraphs.push( + new Paragraph({ + children: runs.length > 0 ? runs : [new TextRun("")], + bullet: { level: 0 }, + spacing: { after: 80 }, + }), + ); + }); + break; + } + + case "blockquote": { + const runs = parseInlineTokens( + token.tokens?.[0]?.tokens ?? [], + options, + ); + paragraphs.push( + new Paragraph({ + children: runs.length > 0 ? runs : [new TextRun("")], + indent: { left: 720 }, + border: { left: { style: "single", size: 12, color: "CCCCCC" } }, + spacing: { after: 200 }, + }), + ); + break; + } + + case "hr": { + paragraphs.push( + new Paragraph({ + children: [new TextRun({ text: "─".repeat(50), color: "CCCCCC" })], + spacing: { before: 200, after: 200 }, + }), + ); + break; + } + + case "space": { + paragraphs.push(new Paragraph({ children: [] })); + break; + } + } + } + + return paragraphs; +} + +/** + * 解析行内 Token 为 TextRun + */ +function parseInlineTokens( + tokens: MarkdownToken[], + options: Required, +): TextRun[] { + const runs: TextRun[] = []; + + for (const token of tokens) { + switch (token.type) { + case "text": + runs.push(new TextRun(token.raw ?? token.text ?? "")); + break; + + case "strong": + runs.push(new TextRun({ text: token.text, bold: true })); + break; + + case "em": + runs.push(new TextRun({ text: token.text, italics: true })); + break; + + case "codespan": + runs.push( + new TextRun({ + text: token.text, + font: options.codeFont, + shading: { fill: "F0F0F0" }, + }), + ); + break; + + case "link": + runs.push( + new TextRun({ + text: token.text, + color: "0066CC", + underline: {}, + }), + ); + break; + + case "br": + runs.push(new TextRun({ text: "", break: 1 })); + break; + + default: + runs.push(new TextRun(token.raw ?? "")); + } + } + + return runs; +} + +/** + * 获取标题级别 + */ +function getHeadingLevel( + depth: number, +): (typeof HeadingLevel)[keyof typeof HeadingLevel] | undefined { + const levels = [ + HeadingLevel.HEADING_1, + HeadingLevel.HEADING_2, + HeadingLevel.HEADING_3, + HeadingLevel.HEADING_4, + HeadingLevel.HEADING_5, + HeadingLevel.HEADING_6, + ]; + return levels[depth - 1]; +} + +/** + * 规范化文件名 + */ +function normalizeFilename(filename: string, extension: string): string { + return filename.replace(/\.md$/i, "") + extension; +} + +/** + * 触发 Blob 下载 + */ +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/core/utils/markdown-download/index.ts b/frontend/src/core/utils/markdown-download/index.ts new file mode 100644 index 00000000..1211441e --- /dev/null +++ b/frontend/src/core/utils/markdown-download/index.ts @@ -0,0 +1,50 @@ +/** + * Markdown 文档下载工具 + * + * @description + * 将 Markdown 内容转换为 DOCX 或 PDF 格式并下载。 + * 可在任何 React + TypeScript 项目中使用。 + * + * @example + * ```tsx + * // React Hook 使用方式 + * import { useMarkdownDownload } from "./markdown-download"; + * + * function MyComponent() { + * const { downloadAsDocx, downloadAsPdf, isDownloading } = useMarkdownDownload(); + * + * return ( + *
+ * + * + *
+ * ); + * } + * ``` + * + * @example + * ```ts + * // 非 React 环境直接使用转换函数 + * import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./markdown-download"; + * + * await downloadMarkdownAsDocx("# Hello World", "document"); + * await downloadMarkdownAsPdf("# Hello World", "document", { format: "a4" }); + * ``` + */ + +// React Hook +export { useMarkdownDownload } from "./use-markdown-download"; + +// 类型 +export type { + UseMarkdownDownloadOptions, + UseMarkdownDownloadReturn, +} from "./use-markdown-download"; +export type { PdfOptions, DocxOptions } from "./converter"; + +// 转换函数(供非 React 环境使用) +export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter"; diff --git a/frontend/src/core/utils/markdown-download/use-markdown-download.ts b/frontend/src/core/utils/markdown-download/use-markdown-download.ts new file mode 100644 index 00000000..a4f2fb28 --- /dev/null +++ b/frontend/src/core/utils/markdown-download/use-markdown-download.ts @@ -0,0 +1,137 @@ +import { useCallback, useState } from "react"; + +import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter"; + +/** + * Markdown 下载 Hook 配置选项 + */ +export interface UseMarkdownDownloadOptions { + /** + * 下载开始时的回调 + */ + onDownloadStart?: (format: "docx" | "pdf") => void; + /** + * 下载完成时的回调 + */ + onDownloadEnd?: (format: "docx" | "pdf") => void; + /** + * 下载失败时的回调 + */ + onError?: (error: Error, format: "docx" | "pdf") => void; +} + +/** + * Markdown 下载 Hook 返回值 + */ +export interface UseMarkdownDownloadReturn { + /** + * 当前下载状态 + */ + isDownloading: "docx" | "pdf" | null; + /** + * 下载为 DOCX + */ + downloadAsDocx: (markdown: string, filename: string) => Promise; + /** + * 下载为 PDF + */ + downloadAsPdf: (markdown: string, filename: string) => Promise; + /** + * 是否可以下载(没有正在进行的下载) + */ + canDownload: boolean; +} + +/** + * Markdown 文档下载 Hook + * + * @description + * 将 Markdown 内容转换为 DOCX 或 PDF 格式并下载。 + * 可在任何 React + TypeScript 项目中使用。 + * + * @example + * ```tsx + * import { useMarkdownDownload } from "./hooks/use-markdown-download"; + * + * function MyComponent() { + * const { downloadAsDocx, downloadAsPdf, isDownloading, canDownload } = useMarkdownDownload({ + * onError: (error, format) => { + * console.error(`Failed to download as ${format}:`, error); + * }, + * }); + * + * const handleDownload = () => { + * downloadAsDocx("# Hello World", "document"); + * }; + * + * return ( + * + * ); + * } + * ``` + */ +export function useMarkdownDownload( + options: UseMarkdownDownloadOptions = {}, +): UseMarkdownDownloadReturn { + const { onDownloadStart, onDownloadEnd, onError } = options; + + const [isDownloading, setIsDownloading] = useState<"docx" | "pdf" | null>( + null, + ); + + const downloadAsDocx = useCallback( + async (markdown: string, filename: string) => { + if (isDownloading) return; + + setIsDownloading("docx"); + onDownloadStart?.("docx"); + + try { + await downloadMarkdownAsDocx(markdown, filename); + } catch (error) { + onError?.( + error instanceof Error ? error : new Error(String(error)), + "docx", + ); + } finally { + setIsDownloading(null); + onDownloadEnd?.("docx"); + } + }, + [isDownloading, onDownloadStart, onDownloadEnd, onError], + ); + + const downloadAsPdf = useCallback( + async (markdown: string, filename: string) => { + if (isDownloading) return; + + setIsDownloading("pdf"); + onDownloadStart?.("pdf"); + + try { + await downloadMarkdownAsPdf(markdown, filename); + } catch (error) { + onError?.( + error instanceof Error ? error : new Error(String(error)), + "pdf", + ); + } finally { + setIsDownloading(null); + onDownloadEnd?.("pdf"); + } + }, + [isDownloading, onDownloadStart, onDownloadEnd, onError], + ); + + return { + isDownloading, + downloadAsDocx, + downloadAsPdf, + canDownload: isDownloading === null, + }; +} + +// 导出转换函数,供非 React 环境使用 +export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter"; diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts index f33bdabb..e506e7fc 100644 --- a/frontend/src/hooks/use-iframe-skill.ts +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -4,8 +4,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { POST_MESSAGE_TYPES, RECEIVE_MESSAGE_TYPES, + isSelectedSkillMessage, sendToParent, - type SelectedSkillMessage, } from "@/core/iframe-messages"; // Skill 数据类型 @@ -53,10 +53,15 @@ export function useIframeSkill(): UseIframeSkillReturn { // 2. 监听宿主页 postMessage useEffect(() => { const handleMessage = (event: MessageEvent) => { - if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { - const { id, title } = event.data as SelectedSkillMessage; - setSelectedSkill({ skill_id: String(id), title }); + 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; + setSelectedSkill({ skill_id: String(id), title }); }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); diff --git a/frontend/src/hooks/use-selected-skill-listener.ts b/frontend/src/hooks/use-selected-skill-listener.ts index 6769d971..bba41577 100644 --- a/frontend/src/hooks/use-selected-skill-listener.ts +++ b/frontend/src/hooks/use-selected-skill-listener.ts @@ -2,15 +2,9 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useCallback, useState, useRef } from "react"; import { toast } from "sonner"; +import { isSelectedSkillMessage } from "@/core/iframe-messages"; import { bootstrapRemoteSkill } from "@/core/skills/api"; -/** 宿主页发过来的 selectedSkill 消息结构 */ -interface SelectedSkillMessage { - type: "selectedSkill"; - id: number | string; - title: string; -} - /** 技能基础数据 */ interface SkillData { skill_id: string; @@ -59,6 +53,15 @@ export function useSelectedSkillListener({ const performBootstrap = useCallback( async (id: number | string, title: string) => { if (!threadId) return; + const contentId = Number(id); + if (!Number.isFinite(contentId) || contentId <= 0) { + console.warn("[useSelectedSkillListener] 忽略非法 skill id", id); + setSkillError({ + title: `技能「${title}」加载失败`, + message: `非法 skill id: ${String(id)}`, + }); + return; + } const languageTypeRaw = searchParams.get("languageType")?.trim() ?? @@ -79,7 +82,7 @@ export function useSelectedSkillListener({ try { const result = await bootstrapRemoteSkill({ thread_id: threadId, - content_ids: [Number(id)], + content_ids: [contentId], language_type: languageType, target_dir: "/mnt/user-data/uploads/skill", clear_target: true, @@ -126,8 +129,8 @@ export function useSelectedSkillListener({ const handleMessage = useCallback( (event: MessageEvent) => { - const data = event.data as SelectedSkillMessage; - if (data?.type !== "selectedSkill") return; + if (!isSelectedSkillMessage(event.data)) return; + const data = event.data; const { id, title } = data; console.log( diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index f8ff63a2..45b5fc2e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -10,3 +10,79 @@ export const externalLinkClass = "text-primary underline underline-offset-2 hover:no-underline"; /** Link style without underline by default (e.g. for streaming/loading). */ export const externalLinkClassNoUnderline = "text-primary hover:underline"; + +/** + * Copy text to clipboard, using postMessage when in iframe. + * In iframe context, sends message to parent window to handle clipboard operation. + */ +export async function copyToClipboard(text: string): Promise { + const isInIframe = window.self !== window.top; + const message = { + type: "copyToClipboard", + text, + }; + + if (isInIframe && window.parent) { + try { + // Request parent window to copy + window.parent.postMessage(message, "*"); + console.log( + "[copyToClipboard] iframe mode → postMessage to parent", + message, + ); + return; + } catch (error) { + console.warn("[copyToClipboard] iframe postMessage failed", error); + } + } + + // Direct clipboard access when not in iframe + console.log("[copyToClipboard] direct mode", message); + await navigator.clipboard.writeText(text); +} + +/** + * 计算字符串的视觉宽度(中文算2,英文算1) + */ +export function getVisualWidth(text: string): number { + let width = 0; + for (const char of text) { + // 中文字符范围:\u4e00-\u9fff(基本汉字) + width += /[\u4e00-\u9fff]/.test(char) ? 2 : 1; + } + return width; +} + +/** + * 截断字符串中间部分,保留开头和结尾 + * 例如: "very-long-file-name.txt" -> "very-lon...me.txt" + * 中文按视觉宽度计算(中文算2,英文算1) + */ +export function truncateMiddle(text: string, maxVisualWidth = 30): string { + const visualWidth = getVisualWidth(text); + if (visualWidth <= maxVisualWidth) return text; + + const startWidth = Math.ceil(maxVisualWidth * 0.6); + const endWidth = Math.floor(maxVisualWidth * 0.4) - 3; // -3 for "..." + + let startPart = ""; + let currentWidth = 0; + for (const char of text) { + const charWidth = /[\u4e00-\u9fff]/.test(char) ? 2 : 1; + if (currentWidth + charWidth > startWidth) break; + startPart += char; + currentWidth += charWidth; + } + + let endPart = ""; + currentWidth = 0; + for (let i = text.length - 1; i >= 0; i--) { + const char = text[i]!; + const charWidth = /[\u4e00-\u9fff]/.test(char) ? 2 : 1; + if (currentWidth + charWidth > endWidth) break; + endPart = char + endPart; + currentWidth += charWidth; + } + + return `${startPart}...${endPart}`; +}