fix(ui): 修复资源缓存错配白屏(no-store+资源版本号+setText空值防御) + 豆包式输入区(去分割线/输入框铺满/图标发送/textarea自增高)
This commit is contained in:
@@ -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%)→ 手动强刷核对。
|
||||
@@ -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> 访问,避免逐个文件注册
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -43,6 +43,12 @@ function addMsg(role, html) {
|
||||
return m;
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 / P1‑F:上传成功后同步 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(); // P0‑A:上传 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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user