@@ -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 : 360 px 1 fr ;
height : 100 vh ;
height : 100 dvh ;
}
/* ---- sidebar ---- */
. sidebar {
display : flex ;
flex - direction : column ;
min - height : 0 ;
background : var ( -- bg - soft ) ;
border - right : 1 px solid var ( -- border ) ;
}
. brand {
display : flex ;
align - items : center ;
gap : 10 px ;
padding : 14 px 16 px ;
border - bottom : 1 px solid var ( -- border ) ;
}
. logo {
width : 28 px ;
height : 28 px ;
border - radius : 8 px ;
background : linear - gradient ( 135 deg , var ( -- accent ) , var ( -- accent - 2 ) ) ;
box - shadow : 0 0 14 px rgba ( 124 , 58 , 237 , 0.5 ) ;
}
. brand strong { display : block ; font - size : 1 rem ; }
. brand small { color : var ( -- fg - dim ) ; font - size : 0.78 rem ; }
. thread {
flex : 1 ;
min - height : 0 ;
overflow - y : auto ;
padding : 14 px ;
display : flex ;
flex - direction : column ;
gap : 10 px ;
}
. welcome { color : var ( -- fg - dim ) ; font - size : 0.9 rem ; }
. welcome p { margin - bottom : 12 px ; }
. chips { display : flex ; flex - direction : column ; gap : 8 px ; }
. chip {
text - align : left ;
background : rgba ( 255 , 255 , 255 , 0.04 ) ;
border : 1 px solid var ( -- border ) ;
color : var ( -- fg ) ;
border - radius : 10 px ;
padding : 9 px 12 px ;
font - size : 0.86 rem ;
cursor : pointer ;
transition : background 0.15 s , border - color 0.15 s , transform 0.1 s ;
}
. chip : hover : not ( : disabled ) { background : rgba ( 124 , 58 , 237 , 0.16 ) ; border - color : var ( -- accent ) ; transform : translateX ( 2 px ) ; }
. chip : disabled { opacity : 0.5 ; cursor : default ; }
. bubble {
max - width : 92 % ;
padding : 9 px 12 px ;
border - radius : 12 px ;
line - height : 1.45 ;
font - size : 0.9 rem ;
white - space : pre - wrap ;
word - break : break - word ;
}
. bubble . user { align - self : flex - end ; background : linear - gradient ( 135 deg , var ( -- accent ) , # 4 f46e5 ) ; color : # fff ; }
. bubble . assistant { align - self : flex - start ; background : rgba ( 255 , 255 , 255 , 0.07 ) ; border : 1 px solid var ( -- border ) ; }
. bubble . typing { display : inline - flex ; align - items : center ; gap : 5 px ; color : var ( -- fg - dim ) ; }
. bubble . typing span {
width : 6 px ; height : 6 px ; border - radius : 50 % ;
background : var ( -- accent - 2 ) ; animation : bounce 1.2 s infinite ;
}
. bubble . typing span : nth - child ( 2 ) { animation - delay : 0.15 s ; }
. bubble . typing span : nth - child ( 3 ) { animation - delay : 0.3 s ; }
@ keyframes bounce { 0 % , 60 % , 100 % { transform : translateY ( 0 ) ; opacity : 0.5 ; } 30 % { transform : translateY ( - 5 px ) ; opacity : 1 ; } }
. composer {
display : flex ;
gap : 8 px ;
padding : 12 px ;
border - top : 1 px solid var ( -- border ) ;
align - items : flex - end ;
}
. composer textarea {
flex : 1 ;
resize : none ;
background : rgba ( 255 , 255 , 255 , 0.05 ) ;
border : 1 px solid var ( -- border ) ;
border - radius : 10 px ;
padding : 9 px 11 px ;
color : inherit ;
font : inherit ;
font - size : 0.9 rem ;
line - height : 1.4 ;
}
. composer textarea : focus { outline : 2 px solid var ( -- accent ) ; outline - offset : - 1 px ; }
. send {
background : linear - gradient ( 135 deg , var ( -- accent ) , var ( -- accent - 2 ) ) ;
color : # fff ; border : none ; border - radius : 10 px ;
padding : 11 px 18 px ; 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 : 6 px ;
padding : 8 px 12 px ;
border - bottom : 1 px solid var ( -- border ) ;
}
. tabs > button {
background : transparent ;
border : 1 px solid transparent ;
color : var ( -- fg - dim ) ;
padding : 7 px 14 px ;
border - radius : 8 px ;
font - size : 0.9 rem ;
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 : 6 px ;
font - size : 0.72 rem ;
color : var ( -- fg - dim ) ;
font - weight : 500 ;
}
. spacer { flex : 1 ; }
. ghost {
background : transparent ;
border : 1 px solid var ( -- border ) ;
color : var ( -- fg - dim ) ;
padding : 6 px 11 px ;
border - radius : 8 px ;
font - size : 0.82 rem ;
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 : # 0 a0c12 ; }
. code {
margin : 0 ;
padding : 16 px 18 px ;
font - family : ui - monospace , 'SF Mono' , 'Cascadia Code' , Menlo , Consolas , monospace ;
font - size : 0.82 rem ;
line - height : 1.55 ;
color : # c8d3e8 ;
white - space : pre - wrap ;
word - break : break - word ;
tab - size : 2 ;
}
. caret {
display : inline - block ;
width : 8 px ; height : 1.05 em ;
vertical - align : text - bottom ;
background : var ( -- accent - 2 ) ;
animation : blink 1 s 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 : 12 px ; color : var ( -- fg - dim ) ; text - align : center ; padding : 24 px ;
}
. placeholder . big { font - size : 3.2 rem ; opacity : 0.35 ; }
. placeholder . small { position : static ; height : 100 % ; }
. errorbar {
padding : 9 px 14 px ;
background : rgba ( 251 , 113 , 133 , 0.14 ) ;
border - top : 1 px solid var ( -- rose ) ;
color : var ( -- rose ) ;
font - size : 0.85 rem ;
}
@ media ( max - width : 860 px ) {
. layout { grid - template - columns : 1 fr ; grid - template - rows : 1 fr auto ; }
. sidebar { order : 2 ; border - right : none ; border - top : 1 px solid var ( -- border ) ; max - height : 46 vh ; }
. main { order : 1 ; }
}
< / style >