feat(04): stabilize iframe messaging and markdown export flows

This commit is contained in:
肖应宇 2026-04-07 14:34:01 +08:00
parent 931c418c87
commit 643b61d15a
10 changed files with 922 additions and 31 deletions

View File

@ -30,14 +30,14 @@ export function useThreadChat() {
return undefined; return undefined;
} }
const stored = window.sessionStorage.getItem("workspace.thread_id"); const stored = window.sessionStorage.getItem("workspace.thread_id");
return stored && stored !== "new" ? stored : undefined; return isValidThreadId(stored) ? stored : undefined;
}; };
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// 读取 query 的 thread_id先用 hook必要时用 window 兜底)。 // 读取 query 的 thread_id先用 hook必要时用 window 兜底)。
const readQueryThreadId = () => { const readQueryThreadId = () => {
const fromHook = searchParams.get("thread_id")?.trim(); const fromHook = searchParams.get("thread_id")?.trim();
if (fromHook && fromHook !== "new") { if (isValidThreadId(fromHook)) {
return fromHook; return fromHook;
} }
if (typeof window === "undefined") { if (typeof window === "undefined") {
@ -46,7 +46,7 @@ export function useThreadChat() {
const fromLocation = new URLSearchParams(window.location.search).get( const fromLocation = new URLSearchParams(window.location.search).get(
"thread_id", "thread_id",
); );
if (fromLocation && fromLocation !== "new") { if (isValidThreadId(fromLocation)) {
return fromLocation.trim(); return fromLocation.trim();
} }
return undefined; return undefined;
@ -113,3 +113,14 @@ export function useThreadChat() {
invalidNewRoute, 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"
);
}

View File

@ -21,7 +21,7 @@ import type { AgentThread } from "@/core/threads/types";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
export function ExportTrigger({ threadId }: { threadId: string }) { export function ExportTrigger({ threadId }: { threadId?: string }) {
const { t } = useI18n(); const { t } = useI18n();
const { thread } = useThread(); const { thread } = useThread();
@ -39,17 +39,23 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
values: thread.values, values: thread.values,
} as AgentThread; } as AgentThread;
try {
if (format === "markdown") { if (format === "markdown") {
exportThreadAsMarkdown(agentThread, messages); exportThreadAsMarkdown(agentThread, messages);
} else { } else {
exportThreadAsJSON(agentThread, messages); exportThreadAsJSON(agentThread, messages);
} }
toast.success(t.common.exportSuccess); toast.success(t.common.exportSuccess);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export";
toast.error(message);
}
}, },
[messages, thread.values, threadId, t], [messages, thread.values, threadId, t],
); );
if (messages.length === 0) { if (!threadId || messages.length === 0) {
return null; return null;
} }

View File

@ -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<string, unknown>;
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, "*");
}
}

View File

@ -113,15 +113,21 @@ export function downloadAsFile(
filename: string, filename: string,
mimeType: 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 blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = filename; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
} finally {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}
} }
export function exportThreadAsMarkdown( export function exportThreadAsMarkdown(

View File

@ -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<void> {
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<void> {
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<Function> {
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<HTMLElement>("*").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<DocxOptions>,
): 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<DocxOptions>,
): 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);
}

View File

@ -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 (
* <div>
* <button onClick={() => downloadAsDocx("# Hello", "doc")}>
* Download DOCX
* </button>
* <button onClick={() => downloadAsPdf("# Hello", "doc")}>
* Download PDF
* </button>
* </div>
* );
* }
* ```
*
* @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";

View File

@ -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<void>;
/**
* PDF
*/
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
/**
*
*/
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 (
* <button onClick={handleDownload} disabled={!canDownload}>
* {isDownloading === "docx" ? "Converting..." : "Download DOCX"}
* </button>
* );
* }
* ```
*/
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";

View File

@ -4,8 +4,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { import {
POST_MESSAGE_TYPES, POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES, RECEIVE_MESSAGE_TYPES,
isSelectedSkillMessage,
sendToParent, sendToParent,
type SelectedSkillMessage,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
// Skill 数据类型 // Skill 数据类型
@ -53,10 +53,15 @@ export function useIframeSkill(): UseIframeSkillReturn {
// 2. 监听宿主页 postMessage // 2. 监听宿主页 postMessage
useEffect(() => { useEffect(() => {
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
const { id, title } = event.data as SelectedSkillMessage; return;
setSelectedSkill({ skill_id: String(id), title });
} }
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); window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage);

View File

@ -2,15 +2,9 @@ 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 { isSelectedSkillMessage } from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api"; import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 宿主页发过来的 selectedSkill 消息结构 */
interface SelectedSkillMessage {
type: "selectedSkill";
id: number | string;
title: string;
}
/** 技能基础数据 */ /** 技能基础数据 */
interface SkillData { interface SkillData {
skill_id: string; skill_id: string;
@ -59,6 +53,15 @@ export function useSelectedSkillListener({
const performBootstrap = useCallback( const performBootstrap = useCallback(
async (id: number | string, title: string) => { async (id: number | string, title: string) => {
if (!threadId) return; 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 = const languageTypeRaw =
searchParams.get("languageType")?.trim() ?? searchParams.get("languageType")?.trim() ??
@ -79,7 +82,7 @@ export function useSelectedSkillListener({
try { try {
const result = await bootstrapRemoteSkill({ const result = await bootstrapRemoteSkill({
thread_id: threadId, thread_id: threadId,
content_ids: [Number(id)], 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,
@ -126,8 +129,8 @@ export function useSelectedSkillListener({
const handleMessage = useCallback( const handleMessage = useCallback(
(event: MessageEvent) => { (event: MessageEvent) => {
const data = event.data as SelectedSkillMessage; if (!isSelectedSkillMessage(event.data)) return;
if (data?.type !== "selectedSkill") return; const data = event.data;
const { id, title } = data; const { id, title } = data;
console.log( console.log(

View File

@ -10,3 +10,79 @@ export const externalLinkClass =
"text-primary underline underline-offset-2 hover:no-underline"; "text-primary underline underline-offset-2 hover:no-underline";
/** Link style without underline by default (e.g. for streaming/loading). */ /** Link style without underline by default (e.g. for streaming/loading). */
export const externalLinkClassNoUnderline = "text-primary hover:underline"; 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<void> {
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);
}
/**
* 21
*/
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"
* 21
*/
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}`;
}