fix(server,ui): 根治 HTML/资源版本错配——/ 每请求现读 chat.html+现算 ?v;侧边栏布局双保险;Vite 插件实时转换(去 index.html)
This commit is contained in:
@@ -37,6 +37,5 @@ conversations/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/*.log
|
frontend/*.log
|
||||||
# ---- 前端 dev 产物(Vite dev 入口 / 构建输出均不入库)----
|
# ---- 前端 dev 产物(Vite dev 入口 / 构建输出均不入库)----
|
||||||
frontend/index.html
|
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite-verify/
|
frontend/.vite-verify/
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/gen-dev-html.mjs",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "node esbuild.config.mjs",
|
"build": "node esbuild.config.mjs",
|
||||||
"watch": "node esbuild.config.mjs --watch",
|
"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) === */
|
/* === 侧边栏 (spec §2.1) === */
|
||||||
#sidebar {
|
#sidebar {
|
||||||
width: 264px; height: 100vh; background: var(--bg-surface); color: var(--text-primary);
|
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);
|
border-right: 1px solid var(--border-subtle);
|
||||||
transition: width .25s var(--ease);
|
transition: width .25s var(--ease);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -114,7 +114,7 @@ body {
|
|||||||
#sidebar.collapsed #new-chat .nc-text { display: none; }
|
#sidebar.collapsed #new-chat .nc-text { display: none; }
|
||||||
/* 侧边栏分区(项目 / 其他会话):品牌+新会话固定,两个列表共用一个滚动条 */
|
/* 侧边栏分区(项目 / 其他会话):品牌+新会话固定,两个列表共用一个滚动条 */
|
||||||
.side-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
.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; }
|
#sidebar.collapsed .side-scroll { display: none; }
|
||||||
.side-head {
|
.side-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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)。
|
// 开发用 Vite dev server:提供 HMR,并把 API / 静态资源代理到本地 FastAPI(:8000)。
|
||||||
// 生产构建仍走 esbuild(npm run build)。
|
// 生产构建仍走 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({
|
export default defineConfig({
|
||||||
root: '.',
|
root: '.',
|
||||||
|
plugins: [devHtmlPlugin()],
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
@@ -116,15 +116,21 @@ def create_app(
|
|||||||
chat_agent = ChatAgent(service=service, fake=is_fake, engine=engine)
|
chat_agent = ChatAgent(service=service, fake=is_fake, engine=engine)
|
||||||
|
|
||||||
static_dir = Path(__file__).parent / "static"
|
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")
|
_asset_names = ("chat.html", "chat.css", "chat.js", "favicon.svg")
|
||||||
try:
|
|
||||||
_asset_ver = str(max(int((static_dir / n).stat().st_mtime_ns)
|
def _asset_version() -> str:
|
||||||
for n in _asset_names if (static_dir / n).exists()))
|
try:
|
||||||
except Exception: # noqa: BLE001
|
return str(max(int((static_dir / n).stat().st_mtime_ns)
|
||||||
_asset_ver = "0"
|
for n in _asset_names if (static_dir / n).exists()))
|
||||||
chat_html = (static_dir / "chat.html").read_text(encoding="utf-8") if (static_dir / "chat.html").exists() else "<html><body>Genesis Chat</body></html>"
|
except Exception: # noqa: BLE001
|
||||||
chat_html = chat_html.replace("__ASSET_VER__", _asset_ver)
|
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)
|
app = FastAPI(title="Genesis API", version=VERSION)
|
||||||
|
|
||||||
@@ -136,7 +142,7 @@ def create_app(
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index():
|
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")
|
@app.get("/favicon.ico")
|
||||||
def favicon():
|
def favicon():
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
border-right: 1px solid var(--border-subtle);
|
border-right: 1px solid var(--border-subtle);
|
||||||
transition: width .25s var(--ease);
|
transition: width .25s var(--ease);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -217,6 +218,7 @@ body {
|
|||||||
.side-section {
|
.side-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 0 0 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
#sidebar.collapsed .side-scroll {
|
#sidebar.collapsed .side-scroll {
|
||||||
|
|||||||
@@ -388,3 +388,23 @@ def test_root_has_icon_link(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert 'rel="icon"' in r.text
|
assert 'rel="icon"' in r.text
|
||||||
assert "/static/favicon.svg" 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