抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

最近一直关注网站搭建的相关讯息,前面学习了Reflex框架,其核心是通过FastAPI作为后端,然后前端通过nextjs渲染静态页面并调取后端数据完成交互,是一个开箱即用的包。但是我在使用过程中也发现了一个问题,那就是所有的交互都依赖于后端,如果网络连接不顺畅,或者你距离后端服务器太远,那么用户的交互是非常卡顿的。

我举个简单的例子,一个滑动组件,当用户滑动的时候,前面页面需要实时显示滑动到的数字,如果使用Reflex, 那么过程是:前端滑动到一个位置,把数据发送到后端,后端计算出新的数据,更新到前端(基于 WebSocket)。但其实这个过程完全不需要进入后端的,例如Vue的数据双向绑定,直接前端完成整个过程会相对更加流畅。

最近,我发现了几个轻量化的库,一个是Robyn, 这个库是一个基于Rust的Http服务器,其性能很强,据官网测评是远强于FastAPI的。而且相比于FastAPI, Robyn更简单,文档就几页,拿来就可以用。
对于前端的交互,Alpine.js则是VueJS的替代,我们既然使用了Python来搭建网站,那么最好就是尽量少碰前端框架,像Vuejs或者React更倾向于用前端的技术栈来搭建全栈服务,这意味着你要起一个nodejs的服务和后端交互,这无疑增加了复杂度。

image

Alpine.js则是一个轻量级的库,其核心是使用JavaScript来完成前端的交互,我们设置只需要添加一行 CDN在我们的html文件里就可以使用它的功能了。虽然 Alpine.js可以使用 Javascript来使用 Ajax获取后端数据,但是HTMX则更简单,我们甚至不需要写太多的JS代码即可以完成页面的交互以及数据的获取。那么接下来,我们用一个简单的例子来看看如何整个这三个库,来创建一个AI绘画页面。

创建一个AI绘画页面

1. 创建一个Robyn项目

$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install robyn
$ python -m robyn --create

这会开启一个交互式对话,我们一次选择即可创建一个项目:

$ python3 -m robyn --create
? Directory Path: .
? Need Docker? (Y/N) Y
? Please select project type (Mongo/Postgres/Sqlalchemy/Prisma):
❯ No DB
Sqlite
Postgres
MongoDB
SqlAlchemy
Prisma

2. 用AI帮我们写一个纯HTML的前端页面

我们像Gemini Pro 2.5提问:

使用 DaisyUI 创建一个AI 绘画的 HTML 页面,该页面包含 Navbar, Body 和 Footer, Body 分为两栏,分为 Sidebar 和 View aera, 其中 SideBar 负责收集用户提交的参数(Propmt, Steps, Button),而 View aera则负责画面呈现,要求生成的图可以被用户点击放大预览。要求整个页面的 UI 现代化,简约但不失美观。”

然后,我们创建一个templates的目录,并创建一个index.html文件,把AI生成的代码粘贴进去。

$ mkdir -p templates
$ touch templates/index.html

我们得到的html文件如下:

查看代码
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 绘画</title>
<style>
/* Google Font for a more modern look - optional */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');

body {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: 'Inter', sans-serif; /* Apply modern font */
background: linear-gradient(135deg, hsl(var(--b2)) 0%, hsl(var(--b3)) 100%);
}
.main-content-wrapper {
flex-grow: 1;
}
.modal-image-preview {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 0.5rem;
}
/* Custom scrollbar for prompt textarea (optional, but nice for aesthetics) */
.prompt-textarea::-webkit-scrollbar { width: 8px; }
.prompt-textarea::-webkit-scrollbar-thumb { background-color: hsl(var(--bc) / 0.4); border-radius: 4px; }
.prompt-textarea::-webkit-scrollbar-track { background-color: hsl(var(--b1)); border-radius: 4px; }

#generated_image_display {
max-width: 100%;
max-height: 65vh; /* Adjust max height in main view area */
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
transition: transform 0.3s ease;
}
#generated_image_display:hover {
transform: scale(1.02);
}
#initial_placeholder_text {
color: hsl(var(--bc) / 0.6);
}

/* 侧边栏磨砂效果 */
.sidebar {
transition: width 0.3s ease-in-out;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
}

/* 深色主题下的侧边栏 */
[data-theme="dark"] .sidebar,
[data-theme="synthwave"] .sidebar {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
}

/* 自定义滑块样式 */
.custom-range {
-webkit-appearance: none;
appearance: none;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, hsl(var(--p)) 0%, hsl(var(--p)) var(--range-value, 20%), hsl(var(--bc) / 0.2) var(--range-value, 20%), hsl(var(--bc) / 0.2) 100%);
outline: none;
transition: all 0.3s ease;
}

.custom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: hsl(var(--p));
cursor: pointer;
border: 3px solid hsl(var(--b1));
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}

.custom-range::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}

.custom-range::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: hsl(var(--p));
cursor: pointer;
border: 3px solid hsl(var(--b1));
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}

.custom-range::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}

/* 滑块轨道样式 */
.range-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: hsl(var(--bc) / 0.6);
margin-top: 0.5rem;
padding: 0 0.25rem;
}

/* 主题切换优化 */
.theme-toggle {
transition: all 0.3s ease;
}

.theme-toggle:checked {
background-color: hsl(var(--p));
}
</style>
</head>
<body>

<div class="navbar bg-base-100/80 backdrop-blur-md shadow-lg sticky top-0 z-50 border-b border-base-300/50">
<div class="flex-1">
<a class="btn btn-ghost text-xl normal-case">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-2"><path d="M12 3c.302 0 .59.03.871.089A7.008 7.008 0 0 1 21 7a3 3 0 0 1-3 3h-1.26a4 4 0 1 0-7.48 0H8a3 3 0 0 1-3-3 7.008 7.008 0 0 1 7.129-3.911A3.979 3.979 0 0 0 12 3Z"></path><path d="M12 18c-3.5 0-6.243-2.594-6.921-6h13.842c-.678 3.406-3.421 6-6.921 6Z"></path></svg>
AI 绘画工坊
</a>
</div>
<div class="flex-none">
<label class="flex cursor-pointer gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
<input type="checkbox" class="toggle theme-controller theme-toggle toggle-sm" id="theme-toggle"/>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>
</label>
</div>
</div>

<div class="main-content-wrapper container mx-auto p-4 sm:p-6 lg:p-8">
<div class="flex flex-col md:flex-row gap-6 lg:gap-8">

<aside class="md:w-[320px] lg:w-[380px] w-full sidebar p-6 rounded-xl space-y-6 flex-shrink-0">
<h2 class="text-2xl font-semibold mb-4 border-b border-base-300/50 pb-3 text-primary">创作参数</h2>

<form class="space-y-6">
<div>
<label for="prompt" class="label">
<span class="label-text text-base font-medium">魔法咒语 (Prompt) ✨</span>
</label>
<textarea name="prompt" id="prompt" class="textarea textarea-bordered textarea-lg w-full h-36 prompt-textarea bg-base-100/50 backdrop-blur-sm" placeholder="例如:一只戴着宇航员头盔的猫漂浮在宇宙中,背景是绚丽的星云,数字艺术" required></textarea>
<p id="prompt_error_msg" class="text-error text-sm mt-1 h-4"></p>
</div>

<div >
<label for="num_steps" class="label justify-between">
<span class="label-text text-base font-medium">绘画步数 (Steps) 🖼️</span>
<span class="label-text-alt text-lg font-semibold text-primary" id="steps_value_display">4</span>
</label>
<input type="range" name="num_steps" min="1" max="8" value="4" step="1" class="custom-range w-full" id="steps_input" />
<div class="range-labels">
<span>快速 (1)</span>
<span>精细 (8)</span>
</div>
</div>

<button type="submit" class="btn btn-primary btn-lg w-full mt-4 group bg-gradient-to-r from-primary to-secondary border-0">
<span class="group-hover:scale-110 transition-transform duration-300">开始创作</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline-block ml-2 group-hover:animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg>
</button>
</form>

<div id="loading_indicator" class="htmx-indicator text-center mt-4 space-y-2">
<span class="loading loading-dots loading-lg text-primary"></span>
<p class="text-primary animate-pulse">正在召唤灵感缪斯...</p>
</div>
</aside>

<main class="flex-grow bg-base-100/50 backdrop-blur-sm border border-base-300/50 p-6 rounded-xl shadow-xl flex flex-col justify-center items-center min-h-[60vh] lg:min-h-[70vh]">
<div x-data id="view_area_image_container" class="w-full h-full flex justify-center items-center" @click="my_modal_2.showModal()">
<p id="initial_placeholder_text" class="text-xl text-center">您的 AI 画作将在此惊艳登场!🚀</p>
<!-- <img id="generated_image_display" src="./btygir.png" alt="生成的图像" class="cursor-pointer"/> -->
</div>
</main>
<dialog id="my_modal_2" class="modal">
<div class="modal-box w-11/12 max-w-5xl p-0 relative bg-transparent shadow-none">
<img id="modal_image_content" src="" alt="图像预览" class="modal-image-preview mx-auto"/>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</div>

<footer class="footer footer-center p-6 bg-base-300/80 backdrop-blur-md text-base-content mt-auto border-t border-base-300/50">
<aside>
<p>版权所有 © <span id="current_year"></span> AI 绘画工坊 - 由先进的 AI 技术驱动</p>
<p class="text-xs opacity-70">简约设计,无限创意</p>
</aside>
</footer>
</body>
</html>

注意,为了下面的方便,我们直接将需要的库全部引入在<head>标签内:

<link href="https://unpkg.com/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>

3. 用 Robyn 创建一个跟路由来托管该页面

from robyn import Robyn, serve_html
import pathlib
import os

app = Robyn(__file__)
##我们执行的文件路径
current_file_path = pathlib.Path(__file__).parent.resolve()
html_path = os.path.join(current_file_path, "templates")

##只需要使用serve_html即可以将整个页面返回给用户,这里我们不用模板,因为用不到
@app.get("/")
async def index():
return serve_html(os.path.join(html_path, "index.html"))

if __name__ == "__main__":
app.start(host="0.0.0.0", port=8080)

4. 使用Alpine.js来完成前端的交互

我们现在打开http://localhost:8080, 可以看到我们创建的页面。但是我们发现前端是没有交互的,例如切换主题的按钮无效,滑块滑动后,数值也不会变化。下面我们来通过添加一些标签就可以实现需要大量 JS 代码来实现的操作。

主题切换优化

DaisyUI主题切换需要在跟组件添加一个属性data-theme,我们在<body>标签添加它,并让切换按钮可以控制他,这就是数据绑定。

Body标签修改如下:

<body x-data="{ theme: false }" :data-theme="theme ? 'dark' : 'light'">

然后,我们修改切换按钮的代码,添加一个x-model="theme"属性,这样就可以实现主题的切换。

<input x-model="theme" type="checkbox" class="toggle theme-controller theme-toggle toggle-sm" id="theme-toggle"/> 

这里的逻辑是:input标签的值会绑定到x-model=theme的变量上,所以input的变化会改变X-data中的数据,然后通过:data-theme=theme ? 'dark' : 'light'属性来控制主题。

滑块交互优化

对于滑动组件,我们首先添加一个x-data="{ steps: 4 }标签。

<div x-data="{ steps: 4 }">
<label for="num_steps" class="label justify-between">
<span class="label-text text-base font-medium">绘画步数 (Steps) 🖼️</span>
<span class="label-text-alt text-lg font-semibold text-primary" id="steps_value_display"></span>
</label>
...
</div>

然后是input标签,添加一个x-model="steps"属性,这样就可以实现滑块的交互。

<input x-model="steps" type="range" name="num_steps" min="1" max="8" value="4" step="1" class="custom-range w-full" id="steps_input" />

最后在<span>使用x-text="steps"来绑定滑块的值。

<div x-data="{ steps: 4 }">
<label for="num_steps" class="label justify-between">
<span class="label-text text-base font-medium">绘画步数 (Steps) 🖼️</span>
<span class="label-text-alt text-lg font-semibold text-primary" id="steps_value_display" x-text="steps"></span>
</label>
...
</div>

这里的原理就是:x-data标签会创建一个数据对象,x-model=steps会绑定到这个数据对象的steps属性,x-text=steps会绑定到这个数据对象的steps属性。

5. 使用HTMX来完成后端的交互

这个就很简单了,我们假设需要向后端/generate路由发送Post请求,并获取返回的图片,那么我们只需要在<form>标签添加一个hx-post属性,并添加一个hx-target属性,这样就可以实现后端的交互。

<form id="generate_form" 
hx-post="/generate"
hx-target="#initial_placeholder_text"
hx-indicator="#loading_indicator"
hx-swap="outerHTML"
class="space-y-6">
<div>
<label for="prompt" class="label">
<span class="label-text text-base font-medium">魔法咒语 (Prompt) ✨</span>
</label>
<textarea name="prompt" id="prompt" class="textarea textarea-bordered textarea-lg w-full h-36 prompt-textarea bg-basebackdrop-blur-sm" placeholder="例如:一只戴着宇航员头盔的猫漂浮在宇宙中,背景是绚丽的星云,数字艺术" required></textarea>
<p id="prompt_error_msg" class="text-error text-sm mt-1 h-4"></p>
</div>
...
</form>

我们依次解释一下含义:

  • hx-post:指定要发送请求的URL
  • hx-target:指定要更新的目标元素
  • hx-indicator:指定要显示的加载指示器,在请求过程中会显示loading状态,请求完成后会隐藏
  • hx-swap:指定要更新的方式

这里我们使用hx-swap="outerHTML",这意味着当后端返回数据时,会替换掉<div>标签的内容。

6. 使用Robyn来创建后端路由


import httpx


def get_flux_image_data(prompt, num_steps):
endpoint = "https://api.cloudflare.com/client/v4/accounts/{accountid}/ai/run/@cf/black-forest-labs/flux-1-schnell"
headers = {
"Authorization": "Bearer {key}",
"Content-Type": "application/json",
}
if isinstance(num_steps, str):
num_steps = int(num_steps)

payload = {
"prompt": prompt,
"num_steps": num_steps
}
response = httpx.post(
endpoint, headers=headers, json=payload, timeout=None)

if response.status_code == 400:
# raise error
return None
else:
image_data = response.json()['result']['image']
return image_data

首先,我们创建一个函数来代理CF的API,这样好处是我们的密钥是保存在后端服务器的,不会暴露给前端。

然后,我们创建一个路由来处理请求:

from robyn import html
from urllib.parse import parse_qs

@app.post("/generate")
async def generate(request):
body = parse_qs(request.body)
prompt = body["prompt"][0]
num_steps = body["num_steps"][0]
image_data = get_flux_image_data(prompt, num_steps)
html_str = f"""
<img id=\"generated_image_display\" src=\"data:image/png;base64,{image_data}\" alt=\"生成的图像\" class=\"cursor-pointer\"/>
<img hx-swap-oob="true" id="modal_image_content" src="data:image/png;base64,{image_data}" alt="图像预览" class="modal-image-preview mx-auto"/>
"""
return html(html_str)

info:我们看到,我们的接口返回的是html的字符串,而不是通常的JSON数据,这是因为html期望我们返回的是html字符串,而不是JSON数据,并且用返回的字符串去替换hx-target指定的元素。 另外,这里用到了hx-swap-oob属性,这个属性的作用可以帮助我们在替换掉需要替换的元素后,可以顺便将idmodal_image_content的组件也替换掉,因为我们前端有个点击生成的图片会放大预览的效果,我们使用的是模态框,需要同步替换里面的内容。

最终呈现:

我们运行

$ python3 -m robyn app.py ##假如我们的文件名是app.py

然后我们打开http://localhost:8080, 就可以看到我们创建的页面了。

image

总结

我们通过使用Robyn, HTMX 以及 Alpine.js 创建了一个AI绘画页面,整个过程非常简单,只需要几个步骤就可以完成。

评论