webgl: 新 app — 说需求→gemma 流式生成可交互 WebGL 小程序
deploy articulate / build-and-deploy (push) Successful in 2m7s
deploy cube / build-and-deploy (push) Successful in 2m36s
deploy karaoke / build-and-deploy (push) Successful in 1m38s
deploy llm-proxy / build-and-deploy (push) Successful in 3m29s
deploy music / build-and-deploy (push) Successful in 3m45s
deploy notes / build-and-deploy (push) Successful in 2m43s
deploy simpleasm / build-and-deploy (push) Successful in 1m54s
deploy webgl / build-and-deploy (push) Successful in 1m50s
deploy werewolf / build-and-deploy (push) Successful in 1m27s
deploy write / build-and-deploy (push) Successful in 2m18s

左 sidebar 聊天说需求,右主区 运行/代码 两 tab。后端 SSE 流式把
gemma 生成的自包含纯原生 WebGL HTML 一段段回吐,前端实时显示代码、
写完丢进沙箱 iframe 跑。完全无状态(迭代靠前端回传 current_code)。
给儿子体验 WebGL。
This commit is contained in:
Fam Zheng
2026-06-03 22:21:33 +01:00
parent cf360f0193
commit 4336cde189
20 changed files with 2574 additions and 0 deletions
+462
View File
@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { extractHtml } from './lib/extract'
interface Msg {
role: 'user' | 'assistant'
content: string
}
const EXAMPLES = [
'一个会转的彩色立方体,鼠标拖动可以转',
'满屏的星空粒子,慢慢往前飞',
'能用鼠标在画布上画发光线条',
'一片随风起伏的彩虹色波浪',
'点一下屏幕就放一个会弹跳的小球',
'一个跟着鼠标转的霓虹甜甜圈',
]
const messages = ref<Msg[]>([])
const input = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
// 流式累积的原始文本(代码 tab 实时显示),以及结束后提取的可运行 HTML
const code = ref('')
const runningHtml = ref('')
const tab = ref<'player' | 'code'>('player')
const iframeKey = ref(0)
const threadEl = ref<HTMLElement | null>(null)
const codeEl = ref<HTMLElement | null>(null)
const iframeEl = ref<HTMLIFrameElement | null>(null)
const canSend = computed(() => !busy.value && input.value.trim().length > 0)
const hasProgram = computed(() => runningHtml.value.length > 0)
const lineCount = computed(() => (code.value ? code.value.split('\n').length : 0))
watch([messages, busy], async () => {
await nextTick()
threadEl.value?.scrollTo({ top: threadEl.value.scrollHeight, behavior: 'smooth' })
}, { deep: true })
// 流式时让代码视图自动滚到底,像在“看它写代码”
watch(code, async () => {
if (tab.value !== 'code') return
await nextTick()
codeEl.value?.scrollTo({ top: codeEl.value.scrollHeight })
})
function useExample(ex: string) {
if (busy.value) return
input.value = ex
send()
}
async function send() {
if (!canSend.value) return
const prompt = input.value.trim()
input.value = ''
error.value = null
messages.value.push({ role: 'user', content: prompt })
busy.value = true
code.value = ''
tab.value = 'code' // 切到代码 tab 看它一行行生成
const payload: { prompt: string; current_code?: string } = { prompt }
if (runningHtml.value) payload.current_code = runningHtml.value
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok || !res.body) {
throw new Error((await res.text().catch(() => '')) || `HTTP ${res.status}`)
}
await consumeSse(res.body)
} catch (e) {
const msg = (e as Error).message
error.value = msg
messages.value.push({ role: 'assistant', content: `出错了:${msg}` })
} finally {
busy.value = false
}
}
/** 读 fetch 流,解析 SSE 帧(event/data),驱动 code / runningHtml。 */
async function consumeSse(body: ReadableStream<Uint8Array>) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let sep: number
while ((sep = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, sep)
buf = buf.slice(sep + 2)
handleFrame(frame)
}
}
}
function handleFrame(frame: string) {
let event = 'message'
const dataLines: string[] = []
for (const raw of frame.split('\n')) {
const line = raw.replace(/\r$/, '')
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''))
}
const data = dataLines.join('\n')
if (event === 'error') {
let msg = data
try { msg = JSON.parse(data).message ?? data } catch { /* keep raw */ }
error.value = msg
messages.value.push({ role: 'assistant', content: `出错了:${msg}` })
return
}
if (event === 'done') {
finishGeneration()
return
}
// 普通文本片段
try {
const t = JSON.parse(data).t
if (typeof t === 'string') code.value += t
} catch { /* 忽略坏帧 */ }
}
function finishGeneration() {
const html = extractHtml(code.value)
if (!html) {
error.value = '生成的内容里没找到可运行的 HTML,再换个说法试试?'
messages.value.push({ role: 'assistant', content: error.value })
return
}
runningHtml.value = html
iframeKey.value++
tab.value = 'player'
messages.value.push({ role: 'assistant', content: '✓ 生成好了,已经在运行 — 想改就接着说。' })
}
function rerun() {
iframeKey.value++
}
function goFullscreen() {
iframeEl.value?.requestFullscreen?.()
}
async function copyCode() {
try {
await navigator.clipboard.writeText(code.value)
} catch { /* 不支持就算了 */ }
}
function downloadHtml() {
const blob = new Blob([runningHtml.value || code.value], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'webgl-program.html'
a.click()
URL.revokeObjectURL(url)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
</script>
<template>
<div class="layout">
<!-- 左侧聊天 说需求 -->
<aside class="sidebar">
<header class="brand">
<span class="logo" />
<div>
<strong>WebGL Studio</strong>
<small>说一句AI 现场生成</small>
</div>
</header>
<div class="thread" ref="threadEl">
<div v-if="messages.length === 0" class="welcome">
<p>想做个什么小程序用大白话说出来就行 👇</p>
<div class="chips">
<button v-for="ex in EXAMPLES" :key="ex" class="chip" :disabled="busy" @click="useExample(ex)">
{{ ex }}
</button>
</div>
</div>
<div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
{{ m.content }}
</div>
<div v-if="busy" class="bubble assistant typing">
<span /><span /><span /> 正在生成
</div>
</div>
<footer class="composer">
<textarea
v-model="input"
:disabled="busy"
@keydown="onKeydown"
rows="2"
:placeholder="hasProgram ? '接着说想怎么改…(Enter 发送)' : '描述你想要的程序…(Enter 发送)'"
/>
<button class="send" :disabled="!canSend" @click="send">{{ hasProgram ? '' : '生成' }}</button>
</footer>
</aside>
<!-- 右侧主区 tabs -->
<main class="main">
<nav class="tabs">
<button :class="{ active: tab === 'player' }" @click="tab = 'player'"> 运行</button>
<button :class="{ active: tab === 'code' }" @click="tab = 'code'">
&lt;/&gt; 代码<span v-if="lineCount" class="count">{{ lineCount }} </span>
</button>
<div class="spacer" />
<template v-if="tab === 'player' && hasProgram">
<button class="ghost" @click="rerun" title="重新运行"> 重跑</button>
<button class="ghost" @click="goFullscreen" title="全屏"> 全屏</button>
</template>
<template v-else-if="tab === 'code' && code">
<button class="ghost" @click="copyCode"> 复制</button>
<button class="ghost" @click="downloadHtml"> 下载</button>
</template>
</nav>
<section class="stage">
<!-- Player -->
<div v-show="tab === 'player'" class="player">
<iframe
v-if="hasProgram"
:key="iframeKey"
ref="iframeEl"
:srcdoc="runningHtml"
sandbox="allow-scripts allow-pointer-lock"
allow="fullscreen; accelerometer; gyroscope; xr-spatial-tracking"
title="生成的 WebGL 程序"
/>
<div v-else class="placeholder">
<div class="big"></div>
<p v-if="busy">正在生成写完自动在这里跑起来</p>
<p v-else>左边说个需求生成好的程序会在这里运行</p>
</div>
</div>
<!-- Code -->
<div v-show="tab === 'code'" class="codewrap" ref="codeEl">
<pre v-if="code" class="code"><code>{{ code }}</code><span v-if="busy" class="caret" /></pre>
<div v-else class="placeholder small">
<p>这里会实时显示 AI 写的代码</p>
</div>
</div>
</section>
<div v-if="error" class="errorbar">{{ error }}</div>
</main>
</div>
</template>
<style scoped>
.layout {
display: grid;
grid-template-columns: 360px 1fr;
height: 100vh;
height: 100dvh;
}
/* ---- sidebar ---- */
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-soft);
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.logo {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 14px rgba(124, 58, 237, 0.5);
}
.brand strong { display: block; font-size: 1rem; }
.brand small { color: var(--fg-dim); font-size: 0.78rem; }
.thread {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.welcome { color: var(--fg-dim); font-size: 0.9rem; }
.welcome p { margin-bottom: 12px; }
.chips { display: flex; flex-direction: column; gap: 8px; }
.chip {
text-align: left;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
color: var(--fg);
border-radius: 10px;
padding: 9px 12px;
font-size: 0.86rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.chip:hover:not(:disabled) { background: rgba(124, 58, 237, 0.16); border-color: var(--accent); transform: translateX(2px); }
.chip:disabled { opacity: 0.5; cursor: default; }
.bubble {
max-width: 92%;
padding: 9px 12px;
border-radius: 12px;
line-height: 1.45;
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
}
.bubble.user { align-self: flex-end; background: linear-gradient(135deg, var(--accent), #4f46e5); color: #fff; }
.bubble.assistant { align-self: flex-start; background: rgba(255, 255, 255, 0.07); border: 1px solid var(--border); }
.bubble.typing { display: inline-flex; align-items: center; gap: 5px; color: var(--fg-dim); }
.bubble.typing span {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent-2); animation: bounce 1.2s infinite;
}
.bubble.typing span:nth-child(2) { animation-delay: 0.15s; }
.bubble.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%,60%,100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-5px); opacity: 1; } }
.composer {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--border);
align-items: flex-end;
}
.composer textarea {
flex: 1;
resize: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
border-radius: 10px;
padding: 9px 11px;
color: inherit;
font: inherit;
font-size: 0.9rem;
line-height: 1.4;
}
.composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.send {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; border: none; border-radius: 10px;
padding: 11px 18px; font-weight: 700; cursor: pointer; white-space: nowrap;
}
.send:disabled { background: rgba(255, 255, 255, 0.1); color: var(--fg-dim); cursor: not-allowed; }
/* ---- main ---- */
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.tabs > button {
background: transparent;
border: 1px solid transparent;
color: var(--fg-dim);
padding: 7px 14px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.tabs > button.active { background: rgba(255, 255, 255, 0.07); color: var(--fg); border-color: var(--border); }
.tabs .count {
margin-left: 6px;
font-size: 0.72rem;
color: var(--fg-dim);
font-weight: 500;
}
.spacer { flex: 1; }
.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--fg-dim);
padding: 6px 11px;
border-radius: 8px;
font-size: 0.82rem;
cursor: pointer;
}
.ghost:hover { color: var(--fg); border-color: var(--accent); }
.stage { flex: 1; min-height: 0; position: relative; }
.player, .codewrap { position: absolute; inset: 0; }
.player iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
.codewrap { overflow: auto; background: #0a0c12; }
.code {
margin: 0;
padding: 16px 18px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.55;
color: #c8d3e8;
white-space: pre-wrap;
word-break: break-word;
tab-size: 2;
}
.caret {
display: inline-block;
width: 8px; height: 1.05em;
vertical-align: text-bottom;
background: var(--accent-2);
animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; color: var(--fg-dim); text-align: center; padding: 24px;
}
.placeholder .big { font-size: 3.2rem; opacity: 0.35; }
.placeholder.small { position: static; height: 100%; }
.errorbar {
padding: 9px 14px;
background: rgba(251, 113, 133, 0.14);
border-top: 1px solid var(--rose);
color: var(--rose);
font-size: 0.85rem;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.sidebar { order: 2; border-right: none; border-top: 1px solid var(--border); max-height: 46vh; }
.main { order: 1; }
}
</style>
+36
View File
@@ -0,0 +1,36 @@
/**
* 从 LLM 生成的原始文本里抠出可运行的 HTML 文档。
*
* gemma 大多数时候会乖乖只吐 HTML,但偶尔会:
* - 用 ```html ... ``` 围栏包起来
* - 在前面/后面加一两句寒暄
* 这里尽量稳地把中间那坨 HTML 提出来。提不出来就返回空串(让调用方报错)。
*/
export function extractHtml(raw: string): string {
if (!raw) return ''
let s = raw.trim()
// 1. 优先取 markdown 代码围栏里的内容
const fence = s.match(/```(?:html|HTML)?\s*\n([\s\S]*?)```/)
if (fence && fence[1].trim()) {
s = fence[1].trim()
} else if (s.startsWith('```')) {
// 围栏没闭合(流式被截断 / 只有开头)—— 去掉开头那行围栏
s = s.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/```\s*$/, '').trim()
}
// 2. 从第一个 <!doctype 或 <html 开始截(丢掉前面的寒暄)
const startMatch = s.match(/<!doctype html>|<html[\s>]/i)
if (startMatch && startMatch.index !== undefined && startMatch.index > 0) {
s = s.slice(startMatch.index)
}
// 3. 截到最后一个 </html> 结束(丢掉后面的寒暄)
const endIdx = s.toLowerCase().lastIndexOf('</html>')
if (endIdx !== -1) {
s = s.slice(0, endIdx + '</html>'.length)
}
// 兜底:至少得像个 HTML(有标签),否则当作没提到
return /<[a-z!][\s\S]*>/i.test(s) ? s.trim() : ''
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+31
View File
@@ -0,0 +1,31 @@
:root {
--bg: #0b0d14;
--bg-soft: #11141d;
--fg: #e6e8ee;
--fg-dim: #8b93a7;
--accent: #7c3aed;
--accent-2: #06b6d4;
--border: #1f2433;
--green: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, 'Helvetica Neue', sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #app {
height: 100%;
}
body {
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}