diff --git a/app/(chat)/ai-cad/page.tsx b/app/(chat)/ai-cad/page.tsx index d360c7de1..39dc438d4 100644 --- a/app/(chat)/ai-cad/page.tsx +++ b/app/(chat)/ai-cad/page.tsx @@ -26,7 +26,6 @@ export default async function AiCadPage() { return ( ); diff --git a/components/ai-cad/zoo-design-studio-frame.tsx b/components/ai-cad/zoo-design-studio-frame.tsx index 1d66629fd..088a1df80 100644 --- a/components/ai-cad/zoo-design-studio-frame.tsx +++ b/components/ai-cad/zoo-design-studio-frame.tsx @@ -1,45 +1,13 @@ "use client"; -import { AlertTriangle, RefreshCw } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { AlertTriangle } from "lucide-react"; type ZooDesignStudioFrameProps = { isReady: boolean; - sourceLabel: string; src: string; }; -export function ZooDesignStudioFrame({ - isReady, - sourceLabel, - src, -}: ZooDesignStudioFrameProps) { - const [loaded, setLoaded] = useState(false); - const iframeRef = useRef(null); - - useEffect(() => { - if (!isReady) { - return; - } - - const iframe = iframeRef.current; - if (!iframe) { - return; - } - - const markLoaded = () => setLoaded(true); - iframe.addEventListener("load", markLoaded); - try { - if (iframe.contentDocument?.readyState === "complete") { - markLoaded(); - } - } catch { - // The regular load event remains the fallback if access is unavailable. - } - - return () => iframe.removeEventListener("load", markLoaded); - }, [isReady, src]); - +export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) { if (!isReady) { return ( @@ -65,20 +33,10 @@ export function ZooDesignStudioFrame({ } return ( - - {!loaded && ( - - - - 正在加载 {sourceLabel} - - - )} + setLoaded(true)} - ref={iframeRef} referrerPolicy="strict-origin-when-cross-origin" sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts" src={src} diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs index f2508929a..c5fa34c8b 100644 --- a/scripts/vendor-zoo-modeling-app.mjs +++ b/scripts/vendor-zoo-modeling-app.mjs @@ -25,6 +25,7 @@ const workspaceDir = path.join(root, ".zoo-modeling-app"); const sourceDir = path.join(workspaceDir, "modeling-app"); const sourceRefFile = path.join(sourceDir, ".hicad-source-ref"); const outputDir = path.join(root, "public/vendor/zoo-modeling-app"); +const customizationDir = path.join(root, "vendor/zoo-modeling-app"); const proxyToken = "hicad-session"; function run(command, args, options = {}) { @@ -109,6 +110,14 @@ function replaceSource(relativePath, before, after) { } function patchOfficialSource() { + copyFileSync( + path.join(customizationDir, "Logo.tsx"), + path.join(sourceDir, "src/components/Logo.tsx") + ); + copyFileSync( + path.join(customizationDir, "localization.ts"), + path.join(sourceDir, "src/lib/hicadLocalization.ts") + ); replaceSource( "index.html", ' ', @@ -204,6 +213,26 @@ function patchOfficialSource() { " new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '')", " new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '', window.location.href)" ); + replaceSource( + "src/index.tsx", + "import { markOnce } from '@src/lib/performance'\n", + "import { installHiCadLocalization } from '@src/lib/hicadLocalization'\nimport { markOnce } from '@src/lib/performance'\n" + ); + replaceSource( + "src/index.tsx", + "function launchApp(app: App) {\n initSingletonBehavior(app)", + "function launchApp(app: App) {\n installHiCadLocalization()\n initSingletonBehavior(app)" + ); + replaceSource( + "src/routes/Onboarding/utils.tsx", + ` + To view your account, manage payment methods, or change tiers, simply{' '}`, + ` + To view your account, manage payment methods, or change tiers, simply{' '}` + ); } function ensureDependencies() { diff --git a/vendor/zoo-modeling-app/Logo.tsx b/vendor/zoo-modeling-app/Logo.tsx new file mode 100644 index 000000000..659699233 --- /dev/null +++ b/vendor/zoo-modeling-app/Logo.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState, type ImgHTMLAttributes } from 'react' + +type BrandingSettings = { + siteLogo?: string | null + siteLogoDark?: string | null + siteName?: string | null +} + +const fallbackLogo = '/default-favicon.ico' + +export const Logo = ({ + className = 'w-auto h-5', + ...props +}: Omit, 'alt' | 'src'>) => { + const [branding, setBranding] = useState({}) + + useEffect(() => { + const controller = new AbortController() + + fetch('/api/site-settings?scope=branding', { + credentials: 'same-origin', + signal: controller.signal, + }) + .then((response) => (response.ok ? response.json() : null)) + .then((settings: BrandingSettings | null) => { + if (settings) setBranding(settings) + }) + .catch(() => undefined) + + return () => controller.abort() + }, []) + + const logo = branding.siteLogoDark || branding.siteLogo || fallbackLogo + const siteName = branding.siteName?.trim() || 'HiCAD' + + return ( + { + if (!event.currentTarget.src.endsWith(fallbackLogo)) { + event.currentTarget.src = fallbackLogo + } + }} + src={logo} + /> + ) +} diff --git a/vendor/zoo-modeling-app/localization.ts b/vendor/zoo-modeling-app/localization.ts new file mode 100644 index 000000000..a5aa1744b --- /dev/null +++ b/vendor/zoo-modeling-app/localization.ts @@ -0,0 +1,182 @@ +const translations = new Map([ + ['3D View', '3D 视图'], + ['Add file to project', '添加项目文件'], + ['All', '全部'], + ['Angularity', '倾斜度'], + ['Ask me anything', '有问题尽管问我'], + ['Back to HiCAD', '返回 HiCAD'], + ['Bodies', '实体'], + ['Chamfer', '倒角'], + ['Choose your adventure', '选择一种建模方式'], + ['Circular Pattern', '圆周阵列'], + ['Code', '代码'], + ['Code Editor', '代码编辑器'], + ['Code-driven CAD', '代码驱动 CAD'], + ['Commands', '命令'], + ['Connected', '已连接'], + ['Connecting and setting up scene...', '正在连接并初始化场景...'], + ['Create a new project', '新建项目'], + ['Design using traditional point-and-click', '使用传统点击方式设计'], + ['Dismiss', '关闭'], + ['Edit', '编辑'], + ['Export', '导出'], + ['Export part', '导出零件'], + ['Extrude', '拉伸'], + ['Feature Tree', '特征树'], + ['Fillet', '圆角'], + ['Flip Surface', '翻转曲面'], + ['Front plane', '前视基准面'], + ['Helix', '螺旋线'], + ['Hello there, I\u2019m Zookeeper.', '你好,我是 HiCAD AI 助手。'], + ["Hello there, I'm Zookeeper.", '你好,我是 HiCAD AI 助手。'], + ['Help', '帮助'], + ['Hole', '孔'], + ['How would you like to start?', '想从哪里开始?'], + ['Insert', '插入'], + ['Logs', '日志'], + ['Loft', '放样'], + ['Mirror', '镜像'], + ['No selection', '未选择'], + ['No selection.', '未选择。'], + ['Offset Plane', '偏移基准面'], + ['Open project', '打开项目'], + ['Project Files', '项目文件'], + ['Publish', '发布'], + ['Redo', '重做'], + ['Revolve', '旋转'], + ['Settings', '设置'], + ['Shell', '抽壳'], + ['Side plane', '侧视基准面'], + ['Sketch', '草图'], + ['Split', '分割'], + ['Start Sketch', '新建草图'], + ['Sweep', '扫掠'], + ['Top plane', '顶视基准面'], + ['Translate', '平移'], + ['Undo', '撤销'], + ['Union', '并集'], + ['Variables', '变量'], + ['View', '视图'], + ['View settings', '视图设置'], + ['Zookeeper', 'HiCAD AI 助手'], + ['Zookeeper, our conversational CAD agent', 'HiCAD 对话式 CAD 助手'], + ['Zoom to fit', '缩放至合适大小'], +]) + +const translatableAttributes = ['aria-label', 'placeholder', 'title'] as const +const excludedSelector = [ + 'code', + 'pre', + 'script', + 'style', + 'textarea', + 'input', + '[contenteditable="true"]', + '.cm-editor', + '.cm-content', + '.cm-line', +].join(',') + +function isExcluded(node: Node) { + const element = + node.nodeType === Node.ELEMENT_NODE + ? (node as Element) + : node.parentElement + return Boolean(element?.closest(excludedSelector)) +} + +function translateTextNode(node: Text) { + if (isExcluded(node)) return + const value = node.nodeValue || '' + const normalized = value.trim() + const translated = translations.get(normalized) + if (!translated || translated === normalized) return + + const leadingWhitespace = value.match(/^\s*/)?.[0] || '' + const trailingWhitespace = value.match(/\s*$/)?.[0] || '' + node.nodeValue = `${leadingWhitespace}${translated}${trailingWhitespace}` +} + +function translateElement(element: Element) { + if (isExcluded(element)) return + for (const attribute of translatableAttributes) { + const value = element.getAttribute(attribute)?.trim() + if (!value) continue + const translated = translations.get(value) + if (translated) element.setAttribute(attribute, translated) + } +} + +function translateTree(root: Node) { + if (root.nodeType === Node.TEXT_NODE) { + translateTextNode(root as Text) + return + } + + if (root.nodeType === Node.ELEMENT_NODE) { + translateElement(root as Element) + } + + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + ) + let current = walker.nextNode() + while (current) { + if (current.nodeType === Node.TEXT_NODE) { + translateTextNode(current as Text) + } else { + translateElement(current as Element) + } + current = walker.nextNode() + } +} + +export function installHiCadLocalization() { + document.documentElement.lang = 'zh-CN' + document.documentElement.dataset.hicadEmbedded = 'true' + + const style = document.createElement('style') + style.textContent = ` + html[data-hicad-embedded='true'] body { + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + } + html[data-hicad-embedded='true'] [data-testid='download-desktop-app'], + html[data-hicad-embedded='true'] [data-testid='billing-remaining-bar'], + html[data-hicad-embedded='true'] [data-hicad-official-account-link] { + display: none !important; + } + ` + document.head.appendChild(style) + + translateTree(document.documentElement) + + let frame = 0 + const pendingRoots = new Set() + const flush = () => { + frame = 0 + for (const root of pendingRoots) translateTree(root) + pendingRoots.clear() + } + const schedule = (root: Node) => { + pendingRoots.add(root) + if (!frame) frame = window.requestAnimationFrame(flush) + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + for (const node of mutation.addedNodes) schedule(node) + } else { + schedule(mutation.target) + } + } + }) + observer.observe(document.documentElement, { + attributeFilter: [...translatableAttributes], + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) +}
+ To view your account, manage payment methods, or change tiers, simply{' '}`, + `
+ To view your account, manage payment methods, or change tiers, simply{' '}` + ); } function ensureDependencies() { diff --git a/vendor/zoo-modeling-app/Logo.tsx b/vendor/zoo-modeling-app/Logo.tsx new file mode 100644 index 000000000..659699233 --- /dev/null +++ b/vendor/zoo-modeling-app/Logo.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState, type ImgHTMLAttributes } from 'react' + +type BrandingSettings = { + siteLogo?: string | null + siteLogoDark?: string | null + siteName?: string | null +} + +const fallbackLogo = '/default-favicon.ico' + +export const Logo = ({ + className = 'w-auto h-5', + ...props +}: Omit, 'alt' | 'src'>) => { + const [branding, setBranding] = useState({}) + + useEffect(() => { + const controller = new AbortController() + + fetch('/api/site-settings?scope=branding', { + credentials: 'same-origin', + signal: controller.signal, + }) + .then((response) => (response.ok ? response.json() : null)) + .then((settings: BrandingSettings | null) => { + if (settings) setBranding(settings) + }) + .catch(() => undefined) + + return () => controller.abort() + }, []) + + const logo = branding.siteLogoDark || branding.siteLogo || fallbackLogo + const siteName = branding.siteName?.trim() || 'HiCAD' + + return ( + { + if (!event.currentTarget.src.endsWith(fallbackLogo)) { + event.currentTarget.src = fallbackLogo + } + }} + src={logo} + /> + ) +} diff --git a/vendor/zoo-modeling-app/localization.ts b/vendor/zoo-modeling-app/localization.ts new file mode 100644 index 000000000..a5aa1744b --- /dev/null +++ b/vendor/zoo-modeling-app/localization.ts @@ -0,0 +1,182 @@ +const translations = new Map([ + ['3D View', '3D 视图'], + ['Add file to project', '添加项目文件'], + ['All', '全部'], + ['Angularity', '倾斜度'], + ['Ask me anything', '有问题尽管问我'], + ['Back to HiCAD', '返回 HiCAD'], + ['Bodies', '实体'], + ['Chamfer', '倒角'], + ['Choose your adventure', '选择一种建模方式'], + ['Circular Pattern', '圆周阵列'], + ['Code', '代码'], + ['Code Editor', '代码编辑器'], + ['Code-driven CAD', '代码驱动 CAD'], + ['Commands', '命令'], + ['Connected', '已连接'], + ['Connecting and setting up scene...', '正在连接并初始化场景...'], + ['Create a new project', '新建项目'], + ['Design using traditional point-and-click', '使用传统点击方式设计'], + ['Dismiss', '关闭'], + ['Edit', '编辑'], + ['Export', '导出'], + ['Export part', '导出零件'], + ['Extrude', '拉伸'], + ['Feature Tree', '特征树'], + ['Fillet', '圆角'], + ['Flip Surface', '翻转曲面'], + ['Front plane', '前视基准面'], + ['Helix', '螺旋线'], + ['Hello there, I\u2019m Zookeeper.', '你好,我是 HiCAD AI 助手。'], + ["Hello there, I'm Zookeeper.", '你好,我是 HiCAD AI 助手。'], + ['Help', '帮助'], + ['Hole', '孔'], + ['How would you like to start?', '想从哪里开始?'], + ['Insert', '插入'], + ['Logs', '日志'], + ['Loft', '放样'], + ['Mirror', '镜像'], + ['No selection', '未选择'], + ['No selection.', '未选择。'], + ['Offset Plane', '偏移基准面'], + ['Open project', '打开项目'], + ['Project Files', '项目文件'], + ['Publish', '发布'], + ['Redo', '重做'], + ['Revolve', '旋转'], + ['Settings', '设置'], + ['Shell', '抽壳'], + ['Side plane', '侧视基准面'], + ['Sketch', '草图'], + ['Split', '分割'], + ['Start Sketch', '新建草图'], + ['Sweep', '扫掠'], + ['Top plane', '顶视基准面'], + ['Translate', '平移'], + ['Undo', '撤销'], + ['Union', '并集'], + ['Variables', '变量'], + ['View', '视图'], + ['View settings', '视图设置'], + ['Zookeeper', 'HiCAD AI 助手'], + ['Zookeeper, our conversational CAD agent', 'HiCAD 对话式 CAD 助手'], + ['Zoom to fit', '缩放至合适大小'], +]) + +const translatableAttributes = ['aria-label', 'placeholder', 'title'] as const +const excludedSelector = [ + 'code', + 'pre', + 'script', + 'style', + 'textarea', + 'input', + '[contenteditable="true"]', + '.cm-editor', + '.cm-content', + '.cm-line', +].join(',') + +function isExcluded(node: Node) { + const element = + node.nodeType === Node.ELEMENT_NODE + ? (node as Element) + : node.parentElement + return Boolean(element?.closest(excludedSelector)) +} + +function translateTextNode(node: Text) { + if (isExcluded(node)) return + const value = node.nodeValue || '' + const normalized = value.trim() + const translated = translations.get(normalized) + if (!translated || translated === normalized) return + + const leadingWhitespace = value.match(/^\s*/)?.[0] || '' + const trailingWhitespace = value.match(/\s*$/)?.[0] || '' + node.nodeValue = `${leadingWhitespace}${translated}${trailingWhitespace}` +} + +function translateElement(element: Element) { + if (isExcluded(element)) return + for (const attribute of translatableAttributes) { + const value = element.getAttribute(attribute)?.trim() + if (!value) continue + const translated = translations.get(value) + if (translated) element.setAttribute(attribute, translated) + } +} + +function translateTree(root: Node) { + if (root.nodeType === Node.TEXT_NODE) { + translateTextNode(root as Text) + return + } + + if (root.nodeType === Node.ELEMENT_NODE) { + translateElement(root as Element) + } + + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + ) + let current = walker.nextNode() + while (current) { + if (current.nodeType === Node.TEXT_NODE) { + translateTextNode(current as Text) + } else { + translateElement(current as Element) + } + current = walker.nextNode() + } +} + +export function installHiCadLocalization() { + document.documentElement.lang = 'zh-CN' + document.documentElement.dataset.hicadEmbedded = 'true' + + const style = document.createElement('style') + style.textContent = ` + html[data-hicad-embedded='true'] body { + font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; + } + html[data-hicad-embedded='true'] [data-testid='download-desktop-app'], + html[data-hicad-embedded='true'] [data-testid='billing-remaining-bar'], + html[data-hicad-embedded='true'] [data-hicad-official-account-link] { + display: none !important; + } + ` + document.head.appendChild(style) + + translateTree(document.documentElement) + + let frame = 0 + const pendingRoots = new Set() + const flush = () => { + frame = 0 + for (const root of pendingRoots) translateTree(root) + pendingRoots.clear() + } + const schedule = (root: Node) => { + pendingRoots.add(root) + if (!frame) frame = window.requestAnimationFrame(flush) + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + for (const node of mutation.addedNodes) schedule(node) + } else { + schedule(mutation.target) + } + } + }) + observer.observe(document.documentElement, { + attributeFilter: [...translatableAttributes], + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) +}