From f4014bd25d36238a590793c13841545d1ae343db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Thu, 6 Nov 2025 12:03:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0onepage=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 1 + js/apiclient.js | 5 +- js/core/app-shell.js | 193 +++++++++++++++++++++++++++++++++++++++++- js/modules/onepage.js | 85 +++++++++++++++++++ 4 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 js/modules/onepage.js diff --git a/index.html b/index.html index 458db28..cd54558 100644 --- a/index.html +++ b/index.html @@ -233,6 +233,7 @@ + diff --git a/js/apiclient.js b/js/apiclient.js index 1aa922b..59eda64 100644 --- a/js/apiclient.js +++ b/js/apiclient.js @@ -14,7 +14,8 @@ class APIClient { canvas: 'prompts/canvas-prompt.txt', swot: 'prompts/swot-prompt.txt', echarts: 'prompts/echarts-prompt.txt', - mermaid: 'prompts/mermaid-prompt.txt' + mermaid: 'prompts/mermaid-prompt.txt', + onepage: 'prompts/onepage-prompt.txt' }; this.promptFallbacks = { canvas: '你是一个专业的产品战略分析师,擅长创建产品画布。', @@ -23,6 +24,8 @@ class APIClient { '你是一个资深的数据可视化专家,精通将自然语言需求转化为 ECharts 配置对象,请输出结构化 JSON option。', mermaid: '你是一个资深的可视化工程师,擅长用 Mermaid 语法创建清晰的图示,请只输出一个 ```mermaid 代码块。', + onepage: + '你是一名资深的落地页架构师,请基于需求生成完整的单文件 HTML(含 Tailwind 样式与必要的原生脚本),并使用 ```html 代码块封装输出。', default: '你是一个可靠的智能助手,请直接回答用户的问题并提供结构化输出。' }; diff --git a/js/core/app-shell.js b/js/core/app-shell.js index 3f049ed..17add05 100644 --- a/js/core/app-shell.js +++ b/js/core/app-shell.js @@ -762,7 +762,8 @@ manifestId: manifest.id, messageId, container, - svg: null + svg: null, + html: null }; this.streamState = streamState; @@ -805,6 +806,8 @@ this.processSvgStreamChunk(manifest, fullContent, streamState); } else if (manifest.artifact?.type === 'mermaid') { this.processMermaidStreamChunk(manifest, fullContent, streamState); + } else if (manifest.artifact?.type === 'html') { + this.processHtmlStreamChunk(manifest, fullContent, streamState); } }; @@ -927,6 +930,19 @@ messageId, timestamp }; + } else if ( + manifest.artifact.type === 'html' && + parsedResult.htmlContent + ) { + artifactId = + streamContext?.html?.artifactId || Utils.generateId('html'); + artifactPayload = { + id: artifactId, + type: manifest.artifact.type, + content: parsedResult.htmlContent.trim(), + messageId, + timestamp + }; } } catch (error) { console.warn('解析助手内容失败:', error); @@ -1025,6 +1041,11 @@ const after = trim(parsedResult.afterText); if (before) segments.push(before); if (after) segments.push(after); + } else if (manifest.artifact?.type === 'html') { + const before = trim(parsedResult.beforeText); + const after = trim(parsedResult.afterText); + if (before) segments.push(before); + if (after) segments.push(after); } } @@ -1092,6 +1113,92 @@ } } + processHtmlStreamChunk(manifest, fullContent, streamState) { + if (!streamState) return; + if (!streamState.html) { + streamState.html = { + started: false, + artifactId: null, + startIndex: null, + beforeText: '', + latestHtml: '', + completed: false, + completedRendered: false, + nextRenderAt: null + }; + } + const ctx = streamState.html; + const startPattern = + manifest.artifact?.startPattern || /```(?:html|htm)/i; + if (!ctx.started) { + const match = fullContent.match(startPattern); + let startIndex = null; + let beforeText = ''; + if (match) { + startIndex = match.index + match[0].length; + beforeText = fullContent.substring(0, match.index); + } else { + const fallbackIndex = fullContent.search(/= 0) { + ctx.started = true; + ctx.artifactId = ctx.artifactId || Utils.generateId('html'); + ctx.startIndex = startIndex; + ctx.beforeText = beforeText; + this.updateHtmlPlaceholder(streamState.container, manifest, ctx); + this.showViewerStreaming(manifest); + } + } + if (!ctx.started) { + return; + } + if (typeof ctx.startIndex !== 'number' || ctx.startIndex < 0) { + return; + } + let htmlSection = fullContent.substring(ctx.startIndex); + const closingFenceIndex = htmlSection.indexOf('```'); + if (closingFenceIndex !== -1) { + ctx.completed = true; + htmlSection = htmlSection.substring(0, closingFenceIndex); + } + const cleaned = htmlSection.trim(); + const hasContent = !!cleaned; + const contentChanged = hasContent && cleaned !== ctx.latestHtml; + const shouldRenderFinal = ctx.completed && !ctx.completedRendered; + if (!hasContent && !shouldRenderFinal) { + return; + } + if (!contentChanged && !shouldRenderFinal) { + return; + } + + const now = Date.now(); + if (!ctx.completed) { + if (ctx.nextRenderAt && now < ctx.nextRenderAt) { + return; + } + ctx.nextRenderAt = now + 250; + } else { + ctx.nextRenderAt = null; + } + + if (contentChanged) { + ctx.latestHtml = cleaned; + } + const htmlForPreview = contentChanged ? cleaned : ctx.latestHtml; + streamState.html.htmlContent = htmlForPreview; + this.renderHtmlPreview(htmlForPreview, manifest, { + partial: !ctx.completed + }); + if (ctx.completed) { + ctx.completedRendered = true; + } + } + updateStreamingBubbleSvgPlaceholder(container, manifest, svgCtx) { if (!container) return; const beforeHtml = this.parseMarkdownContent(svgCtx.beforeText || ''); @@ -1110,14 +1217,33 @@ Utils.scrollToBottom(this.el.chatHistory); } + updateHtmlPlaceholder(container, manifest, ctx) { + if (!container) return; + const beforeHtml = this.parseMarkdownContent(ctx.beforeText || ''); + const label = manifest.label || '页面'; + container.innerHTML = ` +
+
+ ${beforeHtml} +
+ 🏗️ 正在构建${label} HTML… +
+
+
+
+ `; + Utils.scrollToBottom(this.el.chatHistory); + } + showViewerStreaming(manifest) { if (!this.el.viewer) return; const label = manifest.label || '图表'; + const verb = manifest.artifact?.type === 'html' ? '构建' : '绘制'; this.el.viewer.innerHTML = `
-

正在绘制${label}...

+

正在${verb}${label}...

`; @@ -1875,6 +2001,9 @@ } else if (artifact.type === 'echarts-option') { this.destroyMermaidPanZoom(); this.renderEChartsArtifact(artifact); + } else if (artifact.type === 'html') { + this.destroyMermaidPanZoom(); + this.renderHtmlArtifact(artifact, manifest); } this.highlightActivePlaceholder(); this.updateToolbarState(); @@ -1940,6 +2069,64 @@ this.echartsInstance.setOption(artifact.option, true); } + renderHtmlArtifact(artifact, manifest) { + if (!manifest) { + manifest = this.getActiveManifest(); + } + if (!artifact || !artifact.content) { + this.showViewerPlaceholder(manifest?.ui?.placeholderText || ''); + return; + } + this.renderHtmlPreview(artifact.content, manifest, { partial: false }); + } + + renderHtmlPreview(htmlContent, manifest, options = {}) { + if (!this.el.viewer) return; + const { partial = false } = options; + const preparedHtml = this.prepareHtmlDocument(htmlContent); + this.el.viewer.innerHTML = ''; + + const wrapper = document.createElement('div'); + wrapper.className = + 'w-full h-full relative rounded-xl overflow-hidden shadow-lg bg-white border border-purple-200'; + + const iframe = document.createElement('iframe'); + iframe.className = 'w-full h-full border-0 bg-white'; + iframe.setAttribute( + 'title', + `${manifest?.label || '页面'}预览` + ); + iframe.setAttribute( + 'sandbox', + 'allow-forms allow-pointer-lock allow-same-origin allow-scripts' + ); + iframe.srcdoc = preparedHtml; + wrapper.appendChild(iframe); + + if (partial) { + const banner = document.createElement('div'); + banner.className = + 'absolute inset-x-0 top-0 flex justify-end pointer-events-none'; + banner.innerHTML = + '内容生成中…'; + wrapper.appendChild(banner); + } + + this.el.viewer.appendChild(wrapper); + } + + prepareHtmlDocument(htmlContent) { + const raw = typeof htmlContent === 'string' ? htmlContent.trim() : ''; + if (!raw) { + return '空白页面
暂无可展示内容
'; + } + const hasDocumentTag = /]/i.test(raw); + if (hasDocumentTag) { + return raw; + } + return `Generated Page${raw}`; + } + adjustZoom(delta) { const manifest = this.getActiveManifest(); if (!this.isZoomableManifest(manifest)) return; @@ -2168,6 +2355,8 @@ content = artifact.code || artifact.content || ''; } else if (artifact.type === 'echarts-option') { content = artifact.optionText || JSON.stringify(artifact.option, null, 2); + } else if (artifact.type === 'html') { + content = artifact.content || ''; } this.openCodeModal(content); diff --git a/js/modules/onepage.js b/js/modules/onepage.js new file mode 100644 index 0000000..adf6c4a --- /dev/null +++ b/js/modules/onepage.js @@ -0,0 +1,85 @@ +(function registerOnepageModule(global) { + 'use strict'; + + if (!global.ModuleRegistry) { + throw new Error('ModuleRegistry 未初始化'); + } + + const HTML_FENCE = /```(?:html|htm)?\s*([\s\S]*?)```/i; + const START_PATTERN = /```(?:html|htm)/i; + + const parseResponse = (content = '') => { + const text = + typeof content === 'string' ? content : String(content || ''); + const match = text.match(HTML_FENCE); + if (match) { + const beforeText = text.substring(0, match.index).trim(); + const afterText = text + .substring(match.index + match[0].length) + .trim(); + return { + htmlContent: match[1].trim(), + beforeText, + afterText + }; + } + const fallback = text.trim(); + if (!fallback) { + return { + htmlContent: '', + beforeText: '', + afterText: '' + }; + } + const htmlIndex = text.search(/