Spaces:
Running
新增整包下載:導覽列四顆下載捷徑 + beforeunload 防手滑 + 修復重置後情境卡片失效
Browse files整包下載(需求文件缺口 1,原訂只做假按鈕,改為直接實作完整後端):
- 新增 _ZIP_GROUPS/_make_result_zips(),把產出分成指標數據/統計圖表/
研究報告/完整分析結果四包 zip
- 呼叫點放在 _populate_tabs 而非運算流程:結果頁提供下載的是改過名的
複本(TransPrism_LLM Report_*.docx)且刻意排除原始 .txt,重新命名就
發生在 _populate_tabs 裡,在那裡打包才能保證 zip 內容與使用者在頁面上
看到的檔案一致,也不用把「哪些檔案是給使用者的」寫兩遍
- 內容為空的那包不產生,對應按鈕就不出現(LLM 報告失敗時「下載研究報告」
不會亮出來);只有一組有內容時不另外產生整包,避免兩顆按鈕內容一樣
- nav-col 底部新增四顆 gr.DownloadButton,接在 _TAB_OUTPUTS 尾端 26-29
- _populate_tabs 開頭明確把這四顆設回隱藏:其他結果元件都住在 canvas_*_col
裡,重置時整個 Column 被藏起來看不到殘值;這四顆住在 result_nav_col,
而它會在下次選定情境時重新顯示,不歸零的話上一輪的 zip 會跟著冒出來
- state 的 zip_path(從專案第一天就只被初始化成 None)改成 zip_paths 字典
- 新增 download.svg(照既有規格:24x24、stroke #475569、stroke-width 1.75)
防手滑:
- 工作區顯示期間掛上 beforeunload,回到首頁自動解除
- 兩個瀏覽器層面的限制寫進註解:需先與頁面互動確認框才會出現、提示文字
無法自訂
順帶修復測試中發現的既有 bug(已用 git stash 在原版程式碼上重現確認,非本次
改動造成):按「開始新分析」回到首頁後,情境卡片點了完全沒反應,必須重新
整理頁面才能再選,等於 Bug1「選錯情境無法變更」的修復只有一半。根因是
homepage_col 設成 visible=False 時 Svelte 會銷毀整個子樹而非加 display:none,
重新顯示時是全新節點,原本掛在 #homepage_scenario_grid 上的 listener 隨舊
節點消失,而綁定只在開頁後 3 秒內跑過四次、之後不再重試。改用 document
事件代理,跟 bindResultNav() 同一套解法。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app_agent.py +146 -14
- assets/icons/download.svg +1 -0
|
@@ -1368,7 +1368,9 @@ def make_initial_state(session_id: str) -> dict:
|
|
| 1368 |
"invalid_info": {},
|
| 1369 |
"cleaned_files": [],
|
| 1370 |
"output_files": [],
|
| 1371 |
-
|
|
|
|
|
|
|
| 1372 |
"user_metadata": "",
|
| 1373 |
"user_focus": "",
|
| 1374 |
"analysis_result": None,
|
|
@@ -1594,6 +1596,56 @@ def run_format_check(state: dict):
|
|
| 1594 |
return msg, state, True
|
| 1595 |
|
| 1596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1597 |
def run_computation_pipeline_streaming(state: dict):
|
| 1598 |
"""
|
| 1599 |
執行完整計算流程(指標計算 → 統計分析 → LLM 報告),
|
|
@@ -3436,19 +3488,23 @@ _APP_HEAD_SCRIPT = """
|
|
| 3436 |
gr.HTML() 刻的純展示內容,點擊時模擬點擊對應的隱藏版原生按鈕
|
| 3437 |
(sc_btn_1..4,見 create_app() 的 .sc-bridge-btn),Python 端
|
| 3438 |
on_scenario_pick 的 .click() 綁定完全不用改 */
|
| 3439 |
-
|
| 3440 |
-
|
| 3441 |
-
|
| 3442 |
-
|
| 3443 |
-
|
| 3444 |
-
|
| 3445 |
-
|
| 3446 |
-
|
| 3447 |
-
|
| 3448 |
-
|
| 3449 |
-
|
| 3450 |
-
|
| 3451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3452 |
|
| 3453 |
/* 2026-07-23:頁面切換模型。nav-col 的每個項目現在是真正的
|
| 3454 |
「頁面」——結果類項目(語言指標分析/統計比較分析/LLM輔助分析/
|
|
@@ -3747,6 +3803,29 @@ _APP_HEAD_SCRIPT = """
|
|
| 3747 |
var page = mainRow && mainRow.getAttribute('data-page');
|
| 3748 |
if (page) applyActivePage(page);
|
| 3749 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3750 |
setInterval(function () {
|
| 3751 |
_watchZone('upload_zone');
|
| 3752 |
_watchZone('metadata_zone');
|
|
@@ -3754,6 +3833,7 @@ _APP_HEAD_SCRIPT = """
|
|
| 3754 |
_initNavCollapseIfNeeded();
|
| 3755 |
_initActivePageIfNeeded();
|
| 3756 |
_reapplyActivePage();
|
|
|
|
| 3757 |
}, 400);
|
| 3758 |
}
|
| 3759 |
if (document.readyState === 'loading') {
|
|
@@ -3976,6 +4056,35 @@ def create_app():
|
|
| 3976 |
"LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
|
| 3977 |
elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
|
| 3978 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3979 |
# 2026-07-23:技術執行紀錄從手風琴轉正成獨立頁面(原本
|
| 3980 |
# exec_log_accordion 在 canvas-col 底部,現在跟其他三個
|
| 3981 |
# 結果頁同一種待遇)。用分隔線隔開,視覺上表達「低優先度
|
|
@@ -4209,6 +4318,8 @@ def create_app():
|
|
| 4209 |
tab_imp_file, tab_cluster_file, # 23-24 新增下載按鈕(僅能往尾端加,
|
| 4210 |
# 不可插入既有 index 中間,見程式工作守則)
|
| 4211 |
canvas_exec_col, # 25 技術執行紀錄轉正成獨立頁面(2026-07-23)
|
|
|
|
|
|
|
| 4212 |
]
|
| 4213 |
_N_TAB = len(_TAB_OUTPUTS)
|
| 4214 |
|
|
@@ -4226,6 +4337,13 @@ def create_app():
|
|
| 4226 |
if state is None:
|
| 4227 |
return R
|
| 4228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4229 |
output_dir = Path(state["session_dir"]) / "output"
|
| 4230 |
uploaded = state.get("uploaded_files", [])
|
| 4231 |
|
|
@@ -4354,6 +4472,20 @@ def create_app():
|
|
| 4354 |
if dl_rpt:
|
| 4355 |
R[19] = gr.update(value=dl_rpt, visible=True)
|
| 4356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4357 |
# ── 顯示各分頁 Tab(index 20-22)──────────────────────────────
|
| 4358 |
any_tab_ready = False
|
| 4359 |
if csv_path.exists():
|
|
|
|
| 1368 |
"invalid_info": {},
|
| 1369 |
"cleaned_files": [],
|
| 1370 |
"output_files": [],
|
| 1371 |
+
# 2026-07-24:原本是 zip_path(單數),從專案第一天就只被初始化成 None、
|
| 1372 |
+
# 從沒被賦值過。改成 dict 存四包 zip 的路徑,key 見 _ZIP_GROUPS。
|
| 1373 |
+
"zip_paths": {},
|
| 1374 |
"user_metadata": "",
|
| 1375 |
"user_focus": "",
|
| 1376 |
"analysis_result": None,
|
|
|
|
| 1596 |
return msg, state, True
|
| 1597 |
|
| 1598 |
|
| 1599 |
+
# ── 產出打包(zip)────────────────────────────────────────────────────────────
|
| 1600 |
+
# 使用者要把成果全部帶走,原本得點進三個結果頁各按各的下載鈕(共 15 個檔案)。
|
| 1601 |
+
# 這裡把產出分成四包,接到左側導覽列底部的下載捷徑。
|
| 1602 |
+
# key 同時是 zip 檔名(使用者下載後看到的名字),順序即導覽列上的顯示順序。
|
| 1603 |
+
#
|
| 1604 |
+
# 呼叫點刻意放在 _populate_tabs(結果整理)而非運算流程:結果頁提供下載的是
|
| 1605 |
+
# 改過名的複本(TransPrism_LLM Report_*.docx)且刻意排除原始 .txt,這些重新
|
| 1606 |
+
# 命名就發生在 _populate_tabs 裡。在那裡打包才能保證 zip 內容跟使用者在頁面上
|
| 1607 |
+
# 看到的檔案完全一致,也不用把「哪些檔案是給使用者的」這件事寫兩遍。
|
| 1608 |
+
_ZIP_GROUPS = [
|
| 1609 |
+
("idx", "指標數據"),
|
| 1610 |
+
("stat", "統計圖表"),
|
| 1611 |
+
("report", "研究報告"),
|
| 1612 |
+
("all", "完整分析結果"),
|
| 1613 |
+
]
|
| 1614 |
+
|
| 1615 |
+
|
| 1616 |
+
def _make_result_zips(output_dir: Path, grouped: dict) -> dict:
|
| 1617 |
+
"""把各組產出打包成 zip,回傳 {group_key: zip 路徑字串}。
|
| 1618 |
+
|
| 1619 |
+
grouped:{group_key: [檔案路徑, ...]},不含 "all"(由本函式自己合併)。
|
| 1620 |
+
只打包真實存在的檔案;某組完全沒有檔案就不產生該包(對應的下載捷徑
|
| 1621 |
+
也就不會出現),例如 LLM 報告失敗時「研究報告」那包不該憑空生出來。
|
| 1622 |
+
"""
|
| 1623 |
+
targets = {k: [f for f in v if Path(f).exists()] for k, v in grouped.items()}
|
| 1624 |
+
|
| 1625 |
+
# 整包 = 各組去重合併。只有一組有東西時,整包跟那一組內容完全一樣,
|
| 1626 |
+
# 多給一顆按鈕只會讓人猶豫該點哪個,這種情況不另外產生整包。
|
| 1627 |
+
if sum(1 for v in targets.values() if v) >= 2:
|
| 1628 |
+
seen = set()
|
| 1629 |
+
targets["all"] = [f for v in targets.values() for f in v
|
| 1630 |
+
if not (f in seen or seen.add(f))]
|
| 1631 |
+
|
| 1632 |
+
zips = {}
|
| 1633 |
+
for key, label in _ZIP_GROUPS:
|
| 1634 |
+
files = targets.get(key) or []
|
| 1635 |
+
if not files:
|
| 1636 |
+
continue
|
| 1637 |
+
zip_path = output_dir / f"{label}.zip"
|
| 1638 |
+
try:
|
| 1639 |
+
with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
| 1640 |
+
for f in files:
|
| 1641 |
+
zf.write(str(f), arcname=Path(f).name)
|
| 1642 |
+
zips[key] = str(zip_path)
|
| 1643 |
+
except Exception:
|
| 1644 |
+
# 打包失敗不該拖垮整個分析流程——各結果頁的單項下載仍然可用
|
| 1645 |
+
traceback.print_exc()
|
| 1646 |
+
return zips
|
| 1647 |
+
|
| 1648 |
+
|
| 1649 |
def run_computation_pipeline_streaming(state: dict):
|
| 1650 |
"""
|
| 1651 |
執行完整計算流程(指標計算 → 統計分析 → LLM 報告),
|
|
|
|
| 3488 |
gr.HTML() 刻的純展示內容,點擊時模擬點擊對應的隱藏版原生按鈕
|
| 3489 |
(sc_btn_1..4,見 create_app() 的 .sc-bridge-btn),Python 端
|
| 3490 |
on_scenario_pick 的 .click() 綁定完全不用改 */
|
| 3491 |
+
/* 2026-07-24 修正:原本是把 listener 掛在 #homepage_scenario_grid
|
| 3492 |
+
上、用 grid._bound 防重複,並在載入後 300/800/1500/3000ms 各綁
|
| 3493 |
+
一次。實測發現按「開始新分析」回到首頁後,情境卡片點了完全沒
|
| 3494 |
+
反應,必須重新整理頁面才能再選——因為 homepage_col 被設成
|
| 3495 |
+
visible=False 時,Svelte 是把整個子樹「銷毀」而不是加
|
| 3496 |
+
display:none(已用 devtools 確認:進工作區後 grid 節點直接從
|
| 3497 |
+
DOM 消失),重新顯示時是全新節點,_bound 留在已被丟棄的舊節點
|
| 3498 |
+
上,而綁定只在開頁後那 3 秒內跑過,之後不會再重試。這讓 Bug1
|
| 3499 |
+
「選錯情境無法變更」的修復等於只有一半。
|
| 3500 |
+
改用事件代理掛在 document 上,跟 bindResultNav() 同一套解法:
|
| 3501 |
+
不管卡片節點被銷毀重建幾次都不用重綁。 */
|
| 3502 |
+
document.addEventListener('click', function (e) {
|
| 3503 |
+
var card = e.target.closest && e.target.closest('.scenario-card');
|
| 3504 |
+
if (!card) return;
|
| 3505 |
+
var btn = document.getElementById('sc_btn_' + card.getAttribute('data-scenario'));
|
| 3506 |
+
if (btn) btn.click();
|
| 3507 |
+
});
|
| 3508 |
|
| 3509 |
/* 2026-07-23:頁面切換模型。nav-col 的每個項目現在是真正的
|
| 3510 |
「頁面」——結果類項目(語言指標分析/統計比較分析/LLM輔助分析/
|
|
|
|
| 3803 |
var page = mainRow && mainRow.getAttribute('data-page');
|
| 3804 |
if (page) applyActivePage(page);
|
| 3805 |
}
|
| 3806 |
+
var _unloadGuardOn = false;
|
| 3807 |
+
function _onBeforeUnload(e) {
|
| 3808 |
+
e.preventDefault();
|
| 3809 |
+
e.returnValue = ''; /* 舊版瀏覽器需要,值本身不會被顯示 */
|
| 3810 |
+
return '';
|
| 3811 |
+
}
|
| 3812 |
+
function _syncUnloadGuard() {
|
| 3813 |
+
/* 防手滑:目前完全沒有存檔機制(.spectra 存檔/讀檔是後續工作),
|
| 3814 |
+
分析跑到一半或跑完還沒下載時關掉分頁/按重新整理,整段工作直接
|
| 3815 |
+
歸零。這裡在工作區顯示期間掛上 beforeunload,讓瀏覽器跳原生
|
| 3816 |
+
確認框。判斷依據是工作區有沒有真的顯示在畫面上——還停在首頁
|
| 3817 |
+
沒選情境時不攔,避免只是進來看看的人也被問一次;按「開始新
|
| 3818 |
+
分析」重置回首頁後也會自動解除。
|
| 3819 |
+
兩個瀏覽器層面的限制(不是實作沒做好):①使用者必須先與頁面
|
| 3820 |
+
互動過,這個確認框才會出現,這是各家瀏覽器防濫用的共同規範
|
| 3821 |
+
②提示文字一律由瀏覽器決定,無法自訂成「您的分析尚未下載」。 */
|
| 3822 |
+
var row = document.querySelector('.main-layout-row');
|
| 3823 |
+
var active = !!(row && row.offsetParent !== null);
|
| 3824 |
+
if (active === _unloadGuardOn) return;
|
| 3825 |
+
_unloadGuardOn = active;
|
| 3826 |
+
if (active) window.addEventListener('beforeunload', _onBeforeUnload);
|
| 3827 |
+
else window.removeEventListener('beforeunload', _onBeforeUnload);
|
| 3828 |
+
}
|
| 3829 |
setInterval(function () {
|
| 3830 |
_watchZone('upload_zone');
|
| 3831 |
_watchZone('metadata_zone');
|
|
|
|
| 3833 |
_initNavCollapseIfNeeded();
|
| 3834 |
_initActivePageIfNeeded();
|
| 3835 |
_reapplyActivePage();
|
| 3836 |
+
_syncUnloadGuard();
|
| 3837 |
}, 400);
|
| 3838 |
}
|
| 3839 |
if (document.readyState === 'loading') {
|
|
|
|
| 4056 |
"LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
|
| 4057 |
elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
|
| 4058 |
)
|
| 4059 |
+
# ── 下載捷徑(2026-07-24)────────────────────────────
|
| 4060 |
+
# 上面四項是「去看結果」,這四顆是「把結果帶走」,性質
|
| 4061 |
+
# 不同所以用分隔線隔開。原本使用者要拿到全部產出得點進
|
| 4062 |
+
# 三個結果頁各按各的下載鈕(共 15 個檔案),這裡各打成
|
| 4063 |
+
# 一包 zip。內容為空的那幾包不會產生,對應按鈕就不出現
|
| 4064 |
+
# (例如 LLM 報告失敗時「下載研究報告」不會亮出來),
|
| 4065 |
+
# 可見性由 _populate_tabs 依 zip 是否存在決定。
|
| 4066 |
+
gr.HTML('<div class="result-nav-divider"></div>')
|
| 4067 |
+
nav_dl_idx_btn = gr.DownloadButton(
|
| 4068 |
+
"下載指標數據", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
|
| 4069 |
+
visible=False, elem_id="nav_dl_idx_btn",
|
| 4070 |
+
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 4071 |
+
)
|
| 4072 |
+
nav_dl_stat_btn = gr.DownloadButton(
|
| 4073 |
+
"下載統計圖表", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
|
| 4074 |
+
visible=False, elem_id="nav_dl_stat_btn",
|
| 4075 |
+
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 4076 |
+
)
|
| 4077 |
+
nav_dl_report_btn = gr.DownloadButton(
|
| 4078 |
+
"下載研究報告", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
|
| 4079 |
+
visible=False, elem_id="nav_dl_report_btn",
|
| 4080 |
+
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 4081 |
+
)
|
| 4082 |
+
nav_dl_all_btn = gr.DownloadButton(
|
| 4083 |
+
"下載完整報告", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
|
| 4084 |
+
visible=False, elem_id="nav_dl_all_btn",
|
| 4085 |
+
elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
|
| 4086 |
+
)
|
| 4087 |
+
|
| 4088 |
# 2026-07-23:技術執行紀錄從手風琴轉正成獨立頁面(原本
|
| 4089 |
# exec_log_accordion 在 canvas-col 底部,現在跟其他三個
|
| 4090 |
# 結果頁同一種待遇)。用分隔線隔開,視覺上表達「低優先度
|
|
|
|
| 4318 |
tab_imp_file, tab_cluster_file, # 23-24 新增下載按鈕(僅能往尾端加,
|
| 4319 |
# 不可插入既有 index 中間,見程式工作守則)
|
| 4320 |
canvas_exec_col, # 25 技術執行紀錄轉正成獨立頁面(2026-07-23)
|
| 4321 |
+
nav_dl_idx_btn, nav_dl_stat_btn, # 26-27 導覽列下載捷徑(2026-07-24)
|
| 4322 |
+
nav_dl_report_btn, nav_dl_all_btn, # 28-29
|
| 4323 |
]
|
| 4324 |
_N_TAB = len(_TAB_OUTPUTS)
|
| 4325 |
|
|
|
|
| 4337 |
if state is None:
|
| 4338 |
return R
|
| 4339 |
|
| 4340 |
+
# 下載捷徑(26-29)是少數必須「明確設回隱藏」的輸出:其他結果元件
|
| 4341 |
+
# 都住在 canvas_*_col 裡,重置時整個 Column 被藏起來,殘值看不到;
|
| 4342 |
+
# 這四顆住在 result_nav_col,而 result_nav_col 會在下次選定情境時
|
| 4343 |
+
# 重新顯示,不主動歸零的話上一輪的 zip 會跟著冒出來。
|
| 4344 |
+
for _i in (26, 27, 28, 29):
|
| 4345 |
+
R[_i] = gr.update(visible=False, value=None)
|
| 4346 |
+
|
| 4347 |
output_dir = Path(state["session_dir"]) / "output"
|
| 4348 |
uploaded = state.get("uploaded_files", [])
|
| 4349 |
|
|
|
|
| 4472 |
if dl_rpt:
|
| 4473 |
R[19] = gr.update(value=dl_rpt, visible=True)
|
| 4474 |
|
| 4475 |
+
# ── 導覽列下載捷徑:把上面各頁的下載清單打成四包(index 26-29)─
|
| 4476 |
+
# 刻意沿用上面已經整理好的清單變數,不另外去 output_dir 掃檔案:
|
| 4477 |
+
# 掃出來的會含 LLM_Analysis-*.txt 這種只供畫面顯示、不提供下載的
|
| 4478 |
+
# 中間產物,也含前一次打包留下的 .zip 自己。
|
| 4479 |
+
zips = _make_result_zips(output_dir, {
|
| 4480 |
+
"idx": [str(csv_path)] + [str(p) for p in xlsx_list[-1:]],
|
| 4481 |
+
"stat": [str(p) for p in [desc_path, diff_path, box_path]] + imp_dl + cluster_dl,
|
| 4482 |
+
"report": dl_rpt,
|
| 4483 |
+
})
|
| 4484 |
+
state["zip_paths"] = zips
|
| 4485 |
+
for _i, _key in ((26, "idx"), (27, "stat"), (28, "report"), (29, "all")):
|
| 4486 |
+
if zips.get(_key):
|
| 4487 |
+
R[_i] = gr.update(value=zips[_key], visible=True)
|
| 4488 |
+
|
| 4489 |
# ── 顯示各分頁 Tab(index 20-22)──────────────────────────────
|
| 4490 |
any_tab_ready = False
|
| 4491 |
if csv_path.exists():
|
|
|