fix(ui): 修复资源缓存错配白屏(no-store+资源版本号+setText空值防御) + 豆包式输入区(去分割线/输入框铺满/图标发送/textarea自增高)

This commit is contained in:
lhl
2026-09-12 15:48:28 +08:00
parent 1e8b6ba6cb
commit 9946823f0a
5 changed files with 73 additions and 31 deletions
@@ -0,0 +1,22 @@
# 豆包式布局修复计划(崩溃 + 布局)
日期:2026-09-12
## Fix A — 修复 `Cannot set properties of null`(新旧资源缓存错配)
根因:`/` 返回启动时缓存、无缓存头的 HTML;静态资源无版本号 → 浏览器缓存旧 chat.js/chat.html 之一后新旧混用,`getElementById(...)` 为 null 时 `.textContent` 抛错。
修复:
1. `app.py`:启动时按 static 文件 `st_mtime_ns` 计算 `_ASSET_VER`,注入 `chat.html``__ASSET_VER__``index()` 返回 `HTMLResponse(..., headers={"Cache-Control":"no-store","Pragma":"no-cache"})`
2. `chat.html`4 个资源 URL 加 `?v=__ASSET_VER__`
3. `chat.js`:新增 `setText(el, v)`,关键 `.textContent` 赋值 null 安全(防白屏)。
4. 部署后强刷一次清掉旧 HTML 缓存。
## Fix B — 布局(去线 / 铺满 / 图标发送 / 省空间)
- `#composer`:去 `border-top`、背景透明、内边距收紧(与聊天区无缝)。
- `#composer-inner` / `#composer-toolbar` / `#upload-panel`:去 880px 限宽,输入框铺满右侧。
- `#send`:SVG 上箭头图标 + 40px 圆形按钮。
- `header`:高度 56→48、padding 收紧。
- 输入框改 `<textarea rows="1">` 自增高(≤160px);Enter 发送、Shift+Enter 换行。
- 消息区 `#chat` 保持 `max-width:880px` 居中(按用户选择)。
## 验证
`node --check``node --test``python -m pytest`(≥99%)→ 手动强刷核对。
+9 -1
View File
@@ -116,7 +116,15 @@ def create_app(
chat_agent = ChatAgent(service=service, fake=is_fake, engine=engine)
static_dir = Path(__file__).parent / "static"
# 静态资源版本号:按 mtime 计算,随任何前端文件变更而变化(重启后生效)
_asset_names = ("chat.html", "chat.css", "chat.js", "chat_state.js", "chat_ws.js")
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)
app = FastAPI(title="Genesis API", version=VERSION)
@@ -128,7 +136,7 @@ def create_app(
@app.get("/", response_class=HTMLResponse)
def index():
return chat_html
return HTMLResponse(chat_html, headers={"Cache-Control": "no-store", "Pragma": "no-cache"})
# 静态资源(chat.css / chat_state.js / chat_ws.js / 未来的 chat.js
# 统一通过 /static/<file> 访问,避免逐个文件注册
+11 -10
View File
@@ -153,8 +153,8 @@ body {
/* === 主区 + 顶栏 (spec §2.2) === */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-base); }
header {
height: 56px; position: sticky; top: 0; z-index: 10;
padding: 0 var(--sp-6); display: flex; align-items: center; gap: 14px;
height: 48px; position: sticky; top: 0; z-index: 10;
padding: 0 var(--sp-4); display: flex; align-items: center; gap: 14px;
background: rgba(20, 26, 36, 0.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
@@ -302,23 +302,24 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
/* === 输入区 (spec §2.5) === */
#composer { border-top: 1px solid var(--border-subtle); background: var(--bg-surface); padding: var(--sp-4) var(--sp-6) var(--sp-5); }
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
#composer { border-top: none; background: transparent; padding: var(--sp-3) var(--sp-4) var(--sp-4); }
#composer-inner { max-width: none; margin: 0; display: flex; gap: 10px; align-items: flex-end; }
#input {
flex: 1; border: 1px solid var(--border-strong); border-radius: var(--r-md);
padding: 11px 14px; font-size: 14px; outline: none; background: var(--bg-input);
transition: all var(--dur) var(--ease); color: var(--text-primary);
font-family: var(--font-ui);
resize: none; min-height: 42px; max-height: 160px; line-height: 1.5;
resize: none; min-height: 42px; max-height: 160px; line-height: 1.5; overflow-y: auto;
}
#input:focus { border-color: var(--accent); background: var(--bg-elevated); box-shadow: 0 0 0 2px var(--accent-soft); }
#input::placeholder { color: var(--text-muted); }
#send {
background: var(--accent); color: var(--bg-base); border: none; border-radius: var(--r-md);
padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700;
background: var(--accent); color: var(--bg-base); border: none; border-radius: 50%;
width: 40px; height: 40px; padding: 0; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all var(--dur) var(--ease);
min-height: 42px;
}
#send svg { display: block; }
#send:hover:not(:disabled) { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
#send:active:not(:disabled) { transform: translateY(0); }
#send:disabled { opacity: .3; cursor: not-allowed; }
@@ -435,7 +436,7 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
.suggest-card::before { content: "\2192 "; color: var(--accent); font-weight: 700; }
/* === 底部工具栏 === */
#composer-toolbar { max-width: 880px; margin: 10px auto 0; display: flex; align-items: center;
#composer-toolbar { max-width: none; margin: 10px 0 0; display: flex; align-items: center;
gap: 8px; flex-wrap: wrap; }
.tb-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px;
background: transparent; border: 1px solid var(--border-strong); border-radius: var(--r-md);
@@ -445,7 +446,7 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
.tb-btn .tb-ic { font-size: 14px; line-height: 1; }
.tb-btn .tb-caret { font-size: 10px; color: var(--text-muted); }
.proj-switcher { position: relative; }
#upload-panel { max-width: 880px; margin: 10px auto 0; }
#upload-panel { max-width: none; margin: 10px 0 0; }
/* === 会话项「⋯」与重命名菜单 === */
.sess .kebab { flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent;
+6 -6
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Genesis — 概要设计书自动生成(对话)</title>
<link rel="stylesheet" href="/static/chat.css">
<link rel="stylesheet" href="/static/chat.css?v=__ASSET_VER__">
</head>
<body>
<div id="sidebar">
@@ -27,8 +27,8 @@
<div id="composer">
<div id="composer-inner">
<input id="input" placeholder="输入指令,如:生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000">
<button id="send">发送</button>
<textarea id="input" rows="1" placeholder="输入指令,如:生成概要设计书 / 现在什么状态?Enter 发送,Shift+Enter 换行)" autocomplete="off" maxlength="2000"></textarea>
<button id="send" aria-label="发送" title="发送"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg></button>
</div>
<div id="composer-toolbar">
@@ -121,8 +121,8 @@
<button type="button" class="sm-item" id="sm-rename"><span class="sm-ic"></span>重命名</button>
</div>
<script src="/static/chat_state.js"></script>
<script src="/static/chat_ws.js"></script>
<script src="/static/chat.js" defer></script>
<script src="/static/chat_state.js?v=__ASSET_VER__"></script>
<script src="/static/chat_ws.js?v=__ASSET_VER__"></script>
<script src="/static/chat.js?v=__ASSET_VER__" defer></script>
</body>
</html>
+25 -14
View File
@@ -43,6 +43,12 @@ function addMsg(role, html) {
return m;
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function setText(el, v) { if (el) el.textContent = v; }
function autoGrowInput() {
if (!inputEl) return;
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
}
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
async function api(method, url, body) {
@@ -121,7 +127,7 @@ async function loadProjects() {
}
function applyProjectContext() {
psName.textContent = draftProject || '未选择项目';
setText(psName, draftProject || '未选择项目');
}
function renderProjectSwitcher() {
@@ -370,6 +376,7 @@ function insertPrompt(text) {
inputEl.value = text;
inputEl.focus();
try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ }
autoGrowInput();
}
function renderEmptyOrWelcome() {
@@ -422,7 +429,7 @@ function startRenameSession(el, s) {
if (save && newName && newName !== (s.name || '')) {
try {
const r = await api('PATCH', '/api/sessions/' + encodeURIComponent(s.session_id), { name: newName });
if (s.session_id === sid) sessionTitle.textContent = r.name || newName;
if (s.session_id === sid) setText(sessionTitle, r.name || newName);
} catch (e) {
addMsg('error', '重命名失败:' + esc(String(e)));
}
@@ -444,7 +451,7 @@ async function newSession() {
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定(§4.2)
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
chatEl.innerHTML = '';
sessionTitle.textContent = '未创建会话';
setText(sessionTitle, '未创建会话');
updateUploadBar();
await refreshRagStats();
await refreshSessions();
@@ -460,7 +467,7 @@ async function loadSession(id) {
// 分层;若不一致,由提示条让用户主动选择。
// draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好
applyProjectContext();
sessionTitle.textContent = (rec.name || '新会话');
setText(sessionTitle, (rec.name || '新会话'));
if (progressWs) { try { progressWs.close(); } catch (e) {} }
progressWs = GenesisWS.connectProgressWs(id, {
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
@@ -540,7 +547,7 @@ async function send() {
const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null });
sid = d.session_id;
activeProject = draftProject || null;
sessionTitle.textContent = (d.name || '新会话');
setText(sessionTitle, (d.name || '新会话'));
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
await refreshSessions();
} catch (e) {
@@ -554,6 +561,7 @@ async function send() {
onClose: () => {},
});
inputEl.value = '';
inputEl.style.height = '';
addMsg('user', esc(text));
const typing = showTyping();
sendBtn.disabled = true;
@@ -585,7 +593,10 @@ async function send() {
}
document.getElementById('send').addEventListener('click', send);
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
inputEl.addEventListener('input', autoGrowInput);
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
document.getElementById('sidebar-toggle').addEventListener('click', () => toggleSidebar());
document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); });
@@ -636,9 +647,9 @@ async function refreshRagStats() {
stats = { chunks: 0, rag_enabled: false };
}
const n = Number(stats.chunks) || 0;
ragStatsEl.textContent = n > 0
setText(ragStatsEl, n > 0
? 'RAG 已索引 ' + n + ' 片段'
: 'RAG 0 片段(请在项目配置中设置代码库目录)';
: 'RAG 0 片段(请在项目配置中设置代码库目录)');
ragStatsEl.classList.toggle('ready', n > 0);
ragStatsEl.classList.toggle('zero', n === 0);
// 开关可用性:必须先有 RAG 索引才能用
@@ -661,9 +672,9 @@ function updateImpactButton() {
if (!sid) { impactBtnEl.disabled = true; return; }
const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled;
impactBtnEl.disabled = !hasRag;
impactBtnEl.textContent = ragEnabledEl.checked
setText(impactBtnEl, ragEnabledEl.checked
? '开始影响调查(启用 RAG'
: '开始影响调查';
: '开始影响调查');
}
impactBtnEl.addEventListener('click', async () => {
@@ -753,16 +764,16 @@ document.getElementById('upload-btn').addEventListener('click', async () => {
fd.append('file_type', ft);
fd.append('file', file);
const statusEl = document.getElementById('upload-status');
statusEl.textContent = '上传中…';
setText(statusEl, '上传中…');
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(sid) + '/files', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail?.message || r.status);
statusEl.textContent = '';
setText(statusEl, '');
// B5 / P1F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
activeProject = rec.project || null;
sessionTitle.textContent = (rec.name || '新会话');
setText(sessionTitle, (rec.name || '新会话'));
await refreshSessions();
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
await refreshRagStats(); // P0A:上传 existing_system 后 RAG 索引已建
@@ -770,7 +781,7 @@ document.getElementById('upload-btn').addEventListener('click', async () => {
addMsg('user', '[上传] ' + ft + '' + d.file_name);
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
} catch (e) {
statusEl.textContent = '';
setText(statusEl, '');
// F1:失败保留 file-input 值,展示后端明细
addMsg('error', '上传失败:' + esc(String(e)));
}