增加onepage页面

This commit is contained in:
史悦
2025-11-06 12:03:58 +08:00
parent 21209ba1a8
commit f4014bd25d
4 changed files with 281 additions and 3 deletions

View File

@@ -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:
'你是一个可靠的智能助手,请直接回答用户的问题并提供结构化输出。'
};

View File

@@ -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(/<!DOCTYPE|<html|<body/i);
if (fallbackIndex !== -1) {
startIndex = fallbackIndex;
beforeText = fullContent.substring(0, fallbackIndex);
}
}
if (startIndex !== null && startIndex >= 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 = `
<div class="chat-bubble-ai relative streaming-text" data-message-id="${container.dataset.messageId}">
<div>
${beforeHtml}
<div class="svg-drawing-placeholder" data-temp-id="${ctx.artifactId}">
🏗️ 正在构建${label} HTML…
</div>
<div class="typing-cursor"></div>
</div>
</div>
`;
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 = `
<div class="flex items-center justify-center w-full h-full">
<div class="text-center text-gray-600">
<iconify-icon icon="ph:spinner-gap" class="text-5xl text-purple-500 animate-spin"></iconify-icon>
<p class="mt-4 font-bold">正在绘制${label}...</p>
<p class="mt-4 font-bold">正在${verb}${label}...</p>
</div>
</div>
`;
@@ -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 =
'<span class="m-3 px-3 py-1 text-xs font-semibold bg-yellow-300/90 text-gray-900 border border-yellow-600 rounded-full shadow-sm">内容生成中…</span>';
wrapper.appendChild(banner);
}
this.el.viewer.appendChild(wrapper);
}
prepareHtmlDocument(htmlContent) {
const raw = typeof htmlContent === 'string' ? htmlContent.trim() : '';
if (!raw) {
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>空白页面</title></head><body><section style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#6b7280;background:#f9fafb;">暂无可展示内容</section></body></html>';
}
const hasDocumentTag = /<!DOCTYPE|<html[\s>]/i.test(raw);
if (hasDocumentTag) {
return raw;
}
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Generated Page</title></head><body>${raw}</body></html>`;
}
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);

85
js/modules/onepage.js Normal file
View File

@@ -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(/<!DOCTYPE|<html|<body/i);
if (htmlIndex !== -1) {
return {
htmlContent: text.substring(htmlIndex).trim(),
beforeText: text.substring(0, htmlIndex).trim(),
afterText: ''
};
}
return {
htmlContent: fallback,
beforeText: '',
afterText: ''
};
};
global.ModuleRegistry.register({
id: 'onepage',
label: '落地页生成',
icon: 'ph:browser-duotone',
renderer: 'html',
promptKey: 'onepage',
storageNamespace: 'module:onepage',
chat: {
placeholder:
'请输入品牌定位、目标客群、核心卖点等信息,我会生成完整的落地页 HTML…',
streamStartToken: '```html',
contextWindow: 6
},
artifact: {
type: 'html',
fence: 'html',
startPattern: START_PATTERN,
parser: parseResponse
},
hooks: {},
exports: {
allowSvg: false,
allowPng: false,
allowClipboard: false,
allowCode: true
},
ui: {
placeholderText: '生成的落地页预览将在此处显示',
quickActions: [
{ label: 'SaaS 产品页', value: '为 B2B SaaS 产品生成官方落地页;' },
{ label: '新品发布', value: '为科技新品发布会设计预告落地页;' },
{ label: '在线课程', value: '为在线课程制作报名落地页;' },
{ label: '活动报名', value: '为线下品牌活动创建报名落地页;' }
]
}
});
})(window);