fix(server,ui): 根治 HTML/资源版本错配——/ 每请求现读 chat.html+现算 ?v;侧边栏布局双保险;Vite 插件实时转换(去 index.html)
This commit is contained in:
@@ -37,6 +37,5 @@ conversations/
|
||||
frontend/node_modules/
|
||||
frontend/*.log
|
||||
# ---- 前端 dev 产物(Vite dev 入口 / 构建输出均不入库)----
|
||||
frontend/index.html
|
||||
frontend/dist/
|
||||
frontend/.vite-verify/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
@@ -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
@@ -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)。
|
||||
// 生产构建仍走 esbuild(npm 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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user