fix(server,ui): 根治 HTML/资源版本错配——/ 每请求现读 chat.html+现算 ?v;侧边栏布局双保险;Vite 插件实时转换(去 index.html)

This commit is contained in:
lhl
2026-09-12 22:53:23 +08:00
parent 26302866cf
commit 82c435a9b2
8 changed files with 83 additions and 36 deletions
-1
View File
@@ -37,6 +37,5 @@ conversations/
frontend/node_modules/
frontend/*.log
# ---- 前端 dev 产物(Vite dev 入口 / 构建输出均不入库)----
frontend/index.html
frontend/dist/
frontend/.vite-verify/
-1
View File
@@ -4,7 +4,6 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"predev": "node scripts/gen-dev-html.mjs",
"dev": "vite",
"build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch",
-21
View File
@@ -1,21 +0,0 @@
// 由规范页面 src/genesis/server/static/chat.html 生成本地开发用 frontend/index.html
// (仅用于 Vite dev;写入 index.html 供 Vite 作为入口,产物不入库)
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const frontend = resolve(here, '..');
const canonical = resolve(frontend, '../src/genesis/server/static/chat.html');
let html = readFileSync(canonical, 'utf8');
html = html
// 入口脚本 -> 指向 TS 源(Vite HMR
.replace(/<script type="module" src="\/static\/chat\.js[^"]*"><\/script>/, '<script type="module" src="/src/main.ts"></script>')
// 移除生产 CSS 链接(dev 由 main.ts 导入 chat.css,启用样式 HMR
.replace(/<link rel="stylesheet" href="\/static\/chat\.css[^"]*">\s*/, '')
// 去掉资源版本占位
.replace(/\?v=__ASSET_VER__/g, '');
writeFileSync(resolve(frontend, 'index.html'), html);
console.log('generated frontend/index.html from src/genesis/server/static/chat.html');
+2 -2
View File
@@ -66,7 +66,7 @@ body {
/* === 侧边栏 (spec §2.1) === */
#sidebar {
width: 264px; height: 100vh; background: var(--bg-surface); color: var(--text-primary);
display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden;
display: flex; flex-direction: column; flex-shrink: 0; overflow-y: auto; overflow-x: hidden;
border-right: 1px solid var(--border-subtle);
transition: width .25s var(--ease);
position: relative;
@@ -114,7 +114,7 @@ body {
#sidebar.collapsed #new-chat .nc-text { display: none; }
/* 侧边栏分区(项目 / 其他会话):品牌+新会话固定,两个列表共用一个滚动条 */
.side-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.side-section { display: flex; flex-direction: column; min-height: 0; }
.side-section { display: flex; flex-direction: column; flex: 0 0 auto; min-height: 0; }
#sidebar.collapsed .side-scroll { display: none; }
.side-head {
display: flex; align-items: center; justify-content: space-between;
+43 -1
View File
@@ -1,9 +1,51 @@
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
// 开发用 Vite dev server:提供 HMR,并把 API / 静态资源代理到本地 FastAPI(:8000)。
// 生产构建仍走 esbuildnpm run build)。
//
// genesis-dev-html 插件:请求 '/' 时实时读取规范页面
// src/genesis/server/static/chat.html 并转换为开发入口(脚本指向 /src/main.ts、
// 去掉生产 CSS link 与版本占位),并注入 Vite HMR 客户端。
// 这样开发期不存在会过期的中间 HTML,改 chat.html 也会即时整页刷新。
const frontendDir = dirname(fileURLToPath(import.meta.url));
const chatHtmlPath = resolve(frontendDir, '../src/genesis/server/static/chat.html');
const VITE_CLIENT = '<script type="module" src="/@vite/client"></script>';
function devHtmlPlugin(): Plugin {
const render = (): string => {
let html = readFileSync(chatHtmlPath, 'utf8');
html = html
.replace(
/<script type="module" src="\/static\/chat\.js[^"]*"><\/script>/,
'<script type="module" src="/src/main.ts"></script>',
)
.replace(/<link rel="stylesheet" href="\/static\/chat\.css[^"]*">\s*/, '')
.replace(/\?v=__ASSET_VER__/g, '');
return html.replace('</head>', VITE_CLIENT + '\n</head>');
};
return {
name: 'genesis-dev-html',
configureServer(server) {
server.watcher.add(chatHtmlPath);
server.watcher.on('change', (file) => {
if (resolve(file) === chatHtmlPath) server.ws.send({ type: 'full-reload' });
});
server.middlewares.use((req, res, next) => {
const url = (req.url || '').split('?')[0];
if (req.method !== 'GET' || (url !== '/' && url !== '/index.html')) return next();
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(render());
});
},
};
}
export default defineConfig({
root: '.',
plugins: [devHtmlPlugin()],
server: {
host: '127.0.0.1',
port: 5173,
+15 -9
View File
@@ -116,15 +116,21 @@ def create_app(
chat_agent = ChatAgent(service=service, fake=is_fake, engine=engine)
static_dir = Path(__file__).parent / "static"
# 静态资源版本号:按 mtime 计算,随任何前端文件变更而变化(重启后生效)
# 前端页面:每次请求即时读取 chat.html 并现算资源版本号,
# 保证 HTML 与 /static/chat.css|chat.js 的版本始终一致(改 HTML 免重启)。
_asset_names = ("chat.html", "chat.css", "chat.js", "favicon.svg")
try:
_asset_ver = str(max(int((static_dir / n).stat().st_mtime_ns)
for n in _asset_names if (static_dir / n).exists()))
except Exception: # noqa: BLE001
_asset_ver = "0"
chat_html = (static_dir / "chat.html").read_text(encoding="utf-8") if (static_dir / "chat.html").exists() else "<html><body>Genesis Chat</body></html>"
chat_html = chat_html.replace("__ASSET_VER__", _asset_ver)
def _asset_version() -> str:
try:
return str(max(int((static_dir / n).stat().st_mtime_ns)
for n in _asset_names if (static_dir / n).exists()))
except Exception: # noqa: BLE001
return "0"
def _render_index() -> str:
html_path = static_dir / "chat.html"
html = html_path.read_text(encoding="utf-8") if html_path.exists() else "<html><body>Genesis Chat</body></html>"
return html.replace("__ASSET_VER__", _asset_version())
app = FastAPI(title="Genesis API", version=VERSION)
@@ -136,7 +142,7 @@ def create_app(
@app.get("/", response_class=HTMLResponse)
def index():
return HTMLResponse(chat_html, headers={"Cache-Control": "no-store", "Pragma": "no-cache"})
return HTMLResponse(_render_index(), headers={"Cache-Control": "no-store", "Pragma": "no-cache"})
@app.get("/favicon.ico")
def favicon():
+3 -1
View File
@@ -90,7 +90,8 @@ body {
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
border-right: 1px solid var(--border-subtle);
transition: width .25s var(--ease);
position: relative;
@@ -217,6 +218,7 @@ body {
.side-section {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-height: 0;
}
#sidebar.collapsed .side-scroll {
+20
View File
@@ -388,3 +388,23 @@ def test_root_has_icon_link(client):
assert r.status_code == 200
assert 'rel="icon"' in r.text
assert "/static/favicon.svg" in r.text
def test_root_fresh_and_wrapped(client):
# / 每请求即时读 HTML:应含 .side-scroll 包裹,且不含版本占位残留
r = client.get("/")
assert r.status_code == 200
assert 'class="side-scroll"' in r.text
assert "__ASSET_VER__" not in r.text
def test_root_version_matches_disk(client):
# / 注入的 ?v= 应等于当前磁盘 mtime 版本(HTML 与静态资源同源)
import re
from pathlib import Path
r = client.get("/")
m = re.search(r"chat\.css\?v=(\d+)", r.text)
assert m, r.text[:300]
static_dir = Path(__file__).resolve().parents[1] / "src" / "genesis" / "server" / "static"
names = ("chat.html", "chat.css", "chat.js", "favicon.svg")
expect = str(max(int((static_dir / n).stat().st_mtime_ns) for n in names if (static_dir / n).exists()))
assert m.group(1) == expect