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
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:
@@ -68,5 +68,12 @@
|
||||
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。",
|
||||
"url": "https://notes.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "webgl",
|
||||
"name": "webgl",
|
||||
"description": "说需求,gemma 现场生成可交互的 WebGL 小程序(给小孩玩)。左边聊天说需求,右边 运行/代码 两个 tab,沙箱 iframe 跑、能看代码。无状态一次性。",
|
||||
"url": "https://webgl.famzheng.me",
|
||||
"status": "live"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "webgl"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "webgl.famzheng.me — 说需求,gemma 现场生成可交互 WebGL 小程序(给儿子玩)"
|
||||
|
||||
[dependencies]
|
||||
cube-core = { path = "../../crates/cube-core" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
@@ -0,0 +1,6 @@
|
||||
# webgl — webgl.famzheng.me
|
||||
FROM scratch
|
||||
COPY target/x86_64-unknown-linux-musl/release/webgl /webgl
|
||||
COPY apps/webgl/frontend/dist /dist
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/webgl"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGL Studio — 说一句,现场生成</title>
|
||||
<meta name="description" content="说需求,AI 现场生成可交互的 WebGL 小程序 · webgl.famzheng.me" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1500
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "webgl-studio",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0b0d14"/>
|
||||
<g fill="none" stroke-width="3.4" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M32 8 L54 20 L54 44 L32 56 L10 44 L10 20 Z" stroke="#7c3aed"/>
|
||||
<path d="M32 8 L32 32 M32 32 L54 20 M32 32 L10 20" stroke="#06b6d4"/>
|
||||
<path d="M32 32 L32 56" stroke="#34d399"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -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'">
|
||||
</> 代码<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>
|
||||
@@ -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() : ''
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
target: 'es2020',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cube-webgl
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: webgl
|
||||
namespace: cube-webgl
|
||||
labels:
|
||||
app: webgl
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: webgl
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: webgl
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-creds
|
||||
containers:
|
||||
- name: webgl
|
||||
image: registry.famzheng.me/mochi/webgl:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
# secret `llm-credentials`(含 LLM_API_TOKEN)由 kubectl 手工创建,
|
||||
# 不进 git。值跟 cube portal 的 chat-credentials 同一个 gemma token。
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: llm-credentials
|
||||
env:
|
||||
- name: LLM_GATEWAY
|
||||
value: "http://3.135.65.204:8848/v1"
|
||||
- name: LLM_MODEL
|
||||
value: "gemma-4-31b-it"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 64Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: webgl
|
||||
namespace: cube-webgl
|
||||
spec:
|
||||
selector:
|
||||
app: webgl
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: webgl
|
||||
namespace: cube-webgl
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: webgl.famzheng.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: webgl
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,242 @@
|
||||
//! `/api/generate` — 浏览器需求 → LLM gateway → SSE 流式回吐生成的 HTML。
|
||||
//!
|
||||
//! 上游是 OpenAI 兼容的 gateway(gemma-4-31b-it)。我们开 `stream: true`,逐块解析
|
||||
//! 上游的 `data: {...}` SSE 帧,把 delta.content 里的文本片段重新封装成我们自己的
|
||||
//! 干净 SSE 事件发给前端:
|
||||
//! - `data: {"t": "<片段>"}` —— 一段生成文本
|
||||
//! - `event: done` `data: {}` —— 正常结束
|
||||
//! - `event: error` `data: {"message": "..."}` —— 出错
|
||||
//!
|
||||
//! 前端把所有 `t` 拼起来就是完整 HTML,结束后剥掉可能的 ```html fence 丢进 iframe。
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
Json,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub gateway: String, // http://3.135.65.204:8848/v1
|
||||
pub llm_token: String, // Bearer for LLM gateway
|
||||
pub llm_model: String, // gemma-4-31b-it
|
||||
pub max_tokens: u32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
gateway: env_or("LLM_GATEWAY", "http://3.135.65.204:8848/v1"),
|
||||
llm_token: env_or("LLM_API_TOKEN", ""),
|
||||
llm_model: env_or("LLM_MODEL", "gemma-4-31b-it"),
|
||||
max_tokens: env_or("LLM_MAX_TOKENS", "8192")
|
||||
.parse()
|
||||
.unwrap_or(8192),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or(key: &str, fallback: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| fallback.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GenerateRequest {
|
||||
/// 用户这次说的需求 / 修改要求。
|
||||
pub prompt: String,
|
||||
/// 可选:当前程序的完整代码。带上它 = 在现有程序上迭代修改,否则 = 从零生成。
|
||||
#[serde(default)]
|
||||
pub current_code: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统提示词 —— 逼 gemma 吐一个自包含、纯原生 WebGL、能在沙箱 iframe 离线跑的 HTML。
|
||||
pub fn system_prompt() -> String {
|
||||
"你是一个 WebGL 创意编程助手,专门为一个小朋友生成**可交互的图形小程序**。\n\
|
||||
用户用自然语言描述想要什么,你直接输出一个完整、独立、能立刻运行的 HTML 文档。\n\
|
||||
\n\
|
||||
硬性要求:\n\
|
||||
1. 只输出**一个完整的 HTML 文档**,从 `<!doctype html>` 开始到 `</html>` 结束。\n\
|
||||
2. 全部代码内联:CSS 写在 `<style>`,JS 写在 `<script>`。**绝对不要**引用任何外部\n\
|
||||
资源 / CDN / 库(不准用 three.js、不准 import、不准 <script src=...>)——必须用\n\
|
||||
**原生 WebGL / WebGL2** 手写。程序要能在没有网络的沙箱 iframe 里直接跑。\n\
|
||||
3. 用一个铺满窗口的 `<canvas>`,背景默认深色,body/html 设 margin:0、overflow:hidden。\n\
|
||||
监听 resize 让 canvas 跟着窗口和 devicePixelRatio 变。\n\
|
||||
4. **一定要可交互**:鼠标 / 触摸 / 键盘至少支持一种,让小朋友能玩起来\n\
|
||||
(拖动旋转、点击生成、按键变色…随主题而定)。视觉要鲜艳、好玩、有动画。\n\
|
||||
5. 代码要稳:着色器编译、program link 失败时不要整页崩,能力所及地容错。\n\
|
||||
6. 适当写注释(中文或英文都行),因为小朋友会看代码学习。\n\
|
||||
\n\
|
||||
只输出 HTML 代码本身,不要任何解释性文字,不要 markdown 代码围栏。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
State(cfg): State<Arc<Config>>,
|
||||
Json(req): Json<GenerateRequest>,
|
||||
) -> Sse<ReceiverStream<Result<Event, Infallible>>> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(msg) = stream_generation(&cfg, &req, &tx).await {
|
||||
let _ = tx
|
||||
.send(Ok(Event::default()
|
||||
.event("error")
|
||||
.data(json!({ "message": msg }).to_string())))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// 拼用户 prompt:从零生成 vs 在现有代码上改。
|
||||
fn user_content(req: &GenerateRequest) -> String {
|
||||
match req.current_code.as_deref().map(str::trim) {
|
||||
Some(code) if !code.is_empty() => format!(
|
||||
"这是当前程序的完整代码:\n\n```html\n{code}\n```\n\n\
|
||||
请在它的基础上做如下修改,并重新输出**完整**的 HTML 文档:\n{}",
|
||||
req.prompt.trim(),
|
||||
code = code,
|
||||
),
|
||||
_ => req.prompt.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_generation(
|
||||
cfg: &Config,
|
||||
req: &GenerateRequest,
|
||||
tx: &tokio::sync::mpsc::Sender<Result<Event, Infallible>>,
|
||||
) -> Result<(), String> {
|
||||
let body = json!({
|
||||
"model": cfg.llm_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system_prompt() },
|
||||
{ "role": "user", "content": user_content(req) },
|
||||
],
|
||||
"stream": true,
|
||||
"temperature": 0.8,
|
||||
"max_tokens": cfg.max_tokens,
|
||||
});
|
||||
|
||||
let endpoint = format!("{}/chat/completions", cfg.gateway.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&endpoint)
|
||||
.bearer_auth(&cfg.llm_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("调用 LLM 失败:{e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("LLM 返回 {status}:{}", txt.chars().take(300).collect::<String>()));
|
||||
}
|
||||
|
||||
// 逐块读上游 SSE,按行解析 `data:` 帧。
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buf = String::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("读 LLM 流出错:{e}"))?;
|
||||
buf.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// 处理所有完整行(以 \n 结尾的)。
|
||||
while let Some(nl) = buf.find('\n') {
|
||||
let line: String = buf.drain(..=nl).collect();
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
let payload = match line.strip_prefix("data:") {
|
||||
Some(p) => p.trim_start(),
|
||||
None => continue, // 空行 / 注释 / 其它字段,忽略
|
||||
};
|
||||
if payload == "[DONE]" {
|
||||
let _ = tx.send(Ok(Event::default().event("done").data("{}"))).await;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(text) = parse_delta(payload) {
|
||||
if !text.is_empty() {
|
||||
let ev = Event::default().data(json!({ "t": text }).to_string());
|
||||
if tx.send(Ok(ev)).await.is_err() {
|
||||
return Ok(()); // 客户端断开,安静退出
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上游没发 [DONE] 就结束了,也算正常收尾。
|
||||
let _ = tx.send(Ok(Event::default().event("done").data("{}"))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从一帧 OpenAI streaming JSON 里抠出 `choices[0].delta.content`。
|
||||
/// 解析不出来就返回 None(容错:坏帧不该让整条流挂掉)。
|
||||
fn parse_delta(payload: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(payload).ok()?;
|
||||
v.pointer("/choices/0/delta/content")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_prompt_forbids_external_libs() {
|
||||
let p = system_prompt();
|
||||
assert!(p.contains("WebGL"));
|
||||
assert!(p.contains("CDN"));
|
||||
assert!(p.contains("<!doctype html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delta_extracts_content() {
|
||||
let frame = r#"{"choices":[{"delta":{"content":"<canvas>"}}]}"#;
|
||||
assert_eq!(parse_delta(frame).as_deref(), Some("<canvas>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delta_handles_role_only_frame() {
|
||||
// 首帧通常只有 role,没有 content
|
||||
let frame = r#"{"choices":[{"delta":{"role":"assistant"}}]}"#;
|
||||
assert_eq!(parse_delta(frame), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delta_handles_garbage() {
|
||||
assert_eq!(parse_delta("not json"), None);
|
||||
assert_eq!(parse_delta("[DONE]"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_content_plain_when_no_code() {
|
||||
let req = GenerateRequest { prompt: " 一个会转的立方体 ".into(), current_code: None };
|
||||
assert_eq!(user_content(&req), "一个会转的立方体");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_content_embeds_existing_code_for_iteration() {
|
||||
let req = GenerateRequest {
|
||||
prompt: "让它转快点".into(),
|
||||
current_code: Some("<!doctype html><html></html>".into()),
|
||||
};
|
||||
let c = user_content(&req);
|
||||
assert!(c.contains("当前程序的完整代码"));
|
||||
assert!(c.contains("<!doctype html>"));
|
||||
assert!(c.contains("让它转快点"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_content_ignores_blank_code() {
|
||||
let req = GenerateRequest { prompt: "画个圆".into(), current_code: Some(" ".into()) };
|
||||
assert_eq!(user_content(&req), "画个圆");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! webgl.famzheng.me — 说需求 → gemma 现场生成可交互 WebGL 小程序。
|
||||
//!
|
||||
//! - 静态 SPA + SPA fallback 由 cube-core::base 处理。
|
||||
//! - `POST /api/generate` 把需求 + 系统提示词喂给 LLM gateway,**SSE 流式**把
|
||||
//! 生成的 HTML 一段段回吐给前端(前端边收边显示代码,写完丢进沙箱 iframe 跑)。
|
||||
//!
|
||||
//! 完全无状态:没有 DB、没有会话存储。要迭代修改就让前端把当前代码当 context
|
||||
//! 一起发回来。
|
||||
|
||||
mod generate;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
cube_core::init_tracing();
|
||||
let dist = std::env::var("WEBGL_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||||
let cfg = Arc::new(generate::Config::from_env());
|
||||
|
||||
let api = axum::Router::new()
|
||||
.route("/generate", axum::routing::post(generate::handle))
|
||||
.with_state(cfg);
|
||||
|
||||
let app = cube_core::base(dist).nest("/api", api);
|
||||
cube_core::serve(app, 8080).await
|
||||
}
|
||||
Reference in New Issue
Block a user