un2266 commited on
Commit
b234dbb
·
1 Parent(s): 6f0ed7a

排版重構 Phase 2:nav-col 真正的頁面切換模型

Browse files

千卉確認的核心行為:情境選擇→上傳→格式驗證→Metadata→運算中這段流程
進行中,對話欄常駐顯示;點導覽列任何一個結果類項目(語言指標分析/
統計比較分析/LLM輔助分析/技術執行紀錄)互斥顯示、收起對話欄、
Canvas 佔滿寬度;點「AI對話」(原「回到上傳/表單」改名)對話欄彈回。
這是全新邏輯,取代 8-C/8-D「有資料就全部堆疊、使用者自己往下捲」的
舊行為(8-C 程式碼註解本來就把這個標成「8-D 待辦」,8-D 當時只做了
捲動+子分頁切換,沒真的做互斥頁面,這次補完)。

- 技術執行紀錄從手風琴(exec_log_accordion)轉正成獨立頁面
(canvas_exec_col),跟其他三個結果頁同一種 visible=True/False 語意,
不再需要 open/label 收合狀態邏輯,_accordion_update/_accordion_label
兩個函式跟著移除;nav-col 新增「技術執行紀錄」項目,跟主要三項用
分隔線+降低視覺權重區隔
- 純 JS 實作(applyActivePage(),_APP_HEAD_SCRIPT):在 .main-layout-row
設 data-page 屬性+chat-collapsed class,canvas-scroll 底下 7 個直屬
區塊按頁面歸屬只清除或強制 inline display:none,絕不強制顯示——
Python 的 visible=False(資料是否存在)永遠優先,沿用 8-E 驗證過的
inline style 優先權技巧。延續這批 nav 按鈕「刻意不接 Python .click()」
的既有設計原則,不新增 Gradio queue 往返
- 「開始新分析」reset 流程新增把頁面狀態重置回 chat(.main-layout-row
這個 DOM 節點在 Gradio 重置時不會銷毀重建,data-page 不會自動清空)

兩個新踩坑:
1. Gradio 的 Svelte reactivity 在元件 visible prop 改變時會整個重寫
inline style(不是疊加,是覆蓋),把 JS 手動加的 !important 覆寫
一併洗掉——運算完成那一刻 canvas_idx_col 被 Python 設 visible=True
就會把我的 display:none 蓋掉,混進當前頁面。修法沿用 _watchZone/
_syncMobileHamburger 同一套輪詢自我修正手法,不奢求一次設定永久有效
2. .main-layout-row(gr.Row 本身)從來沒設過 flex-wrap:nowrap——三欄
各自「內部」的 nowrap 不影響 row 怎麼排這三欄。平常剛好不超版所以
從沒被觸發過,這次 canvas-col 拿掉 max-width 上限後,寬表格把
intrinsic 寬度撐到跟 nav-col 放不下同一行,觸發 Gradio 預設的
flex-wrap:wrap,canvas-col 整個換到 nav-col 下面,頁面被撐高、
nav-col 跟著被捲出視窗外。修法:main-layout-row 本身也要明確 nowrap

已用 Playwright 完整驗證:情境選定後預設在 AI對話頁;上傳→格式驗證→
Metadata略過→運算完成全程對話欄正確常駐、無結果內容洩漏;點語言指標
分析/統計比較分析(含4子分頁)/LLM輔助分析/技術執行紀錄皆正確切頁、
對話欄收起、Canvas佔滿、nav active高亮正確;點AI對話對話欄正確彈回;
reset後選新情境正確回到chat頁初始狀態;手機375px寬度漢堡抽屜點導覽
項目正確關閉抽屜+切頁;全程console 0 error。

Files changed (2) hide show
  1. app_agent.py +212 -68
  2. assets/icons/folder.svg +1 -0
app_agent.py CHANGED
@@ -2256,27 +2256,16 @@ div[role="log"][aria-label="chatbot conversation"],
2256
  text-underline-offset: 2px !important;
2257
  }
2258
 
2259
- /* 技術執行紀錄 Accordion:視覺上接在對話視窗卡片下方,變成同一張卡片的
2260
- 延伸「抽屜」而不是另一個獨立區塊——只留下方圓角,上緣邊框當作跟對話
2261
- 視窗之間唯一的分界線 */
2262
- #exec_log_accordion {
2263
- border: 1px solid var(--c-surface3-border) !important;
2264
- border-radius: 0 0 14px 14px !important;
2265
- background: var(--c-surface) !important;
2266
- box-shadow: none !important;
2267
- margin-top: 0 !important;
2268
- }
2269
- #exec_log_accordion .label-wrap {
2270
- color: var(--c-text-muted) !important;
2271
- font-size: var(--font-size-base) !important;
2272
- }
2273
 
2274
  /* 執行視窗(簡易訊息顯示欄,非對話氣泡) */
2275
- /* 已經包在 Accordion 裡,Accordion 本身就有外框,這裡不再加第二層框線/底色,
2276
- 避免卡中卡的雙重外框。原本這裡還設了 max-height + overflow-y:auto 讓內部
2277
- 自己捲動,但整個頁面本身也會捲動,兩個捲動容器疊在一起、捲軸位置又離
2278
- 得很近,看起來像「兩條捲軸」造成混亂——拿掉內部捲動限制,讓內容自然
2279
- 撐開高度,只用頁面本身的捲軸,維持單一捲動體驗 */
2280
  .exec-log {
2281
  background: transparent !important;
2282
  border: none !important;
@@ -2377,6 +2366,34 @@ div[role="log"][aria-label="chatbot conversation"],
2377
  overflow-x: hidden !important;
2378
  overflow-y: auto !important;
2379
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2380
  /* Phase 8-C 踩坑:結果內容搬進 canvas-scroll 後,內容總高度遠超過
2381
  canvas-col 給的固定 height,理論上該讓 canvas-scroll 自己捲動就好;但
2382
  exec_log_accordion(技術執行紀錄)被壓縮成只剩標題列高度(22px),內容
@@ -2533,6 +2550,7 @@ img.button-icon { opacity: .82; }
2533
  .nav-col.collapsed .nav-collapse-row { justify-content: center; }
2534
  .nav-col.collapsed .scenario-buttons,
2535
  .nav-col.collapsed .result-nav-accordion,
 
2536
  .nav-col.collapsed .reset-confirm { display: none !important; }
2537
  .nav-col.collapsed .reset-btn,
2538
  .nav-col.collapsed .result-nav-btn {
@@ -2756,6 +2774,31 @@ img.button-icon { opacity: .82; }
2756
  font-weight: 600 !important;
2757
  color: var(--c-text-soft) !important;
2758
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2759
  /* 語料 Metadata 說明表單卡片 */
2760
  .metadata-zone {
2761
  border: 1px solid rgba(var(--c-accent-rgb),.40) !important;
@@ -3193,10 +3236,64 @@ _APP_HEAD_SCRIPT = """
3193
  }
3194
  [300, 800, 1500, 3000].forEach(function(t) { setTimeout(bindScenarioCards, t); });
3195
 
3196
- /* Phase 8-D:左側結果導覽(result_nav_col)——純粹是「捲動到
3197
- Canvas 對應區塊+切換巢狀子分頁」的畫面操作,不牽動任何後端
3198
- 狀態,所以這幾顆按鈕在 Python 端完全沒有掛 .click(),改用這裡
3199
- 的事件代理直接處理,不用走 Gradio queue 來回。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3200
  注意:result_nav_col 建構時是 visible=False(跟 sc_btn_1..4
3201
  一度踩過的坑同一類),如果比照 bindScenarioCards() 用
3202
  setTimeout 抓固定��間點的按鈕,情境選定前按鈕還沒掛載,之後
@@ -3213,16 +3310,27 @@ _APP_HEAD_SCRIPT = """
3213
  document.addEventListener('click', function (e) {
3214
  if (e.target.closest('#nav_top_btn')) {
3215
  closeMobileDrawerIfOpen();
 
3216
  var scrollBox = document.querySelector('.canvas-scroll');
3217
  if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' });
3218
  return;
3219
  }
3220
- if (e.target.closest('#nav_idx_btn')) { closeMobileDrawerIfOpen(); scrollToId('canvas_idx'); return; }
3221
- if (e.target.closest('#nav_report_btn')) { closeMobileDrawerIfOpen(); scrollToId('canvas_report'); return; }
3222
- if (e.target.closest('#nav_stat_accordion .label-wrap')) { closeMobileDrawerIfOpen(); scrollToId('canvas_stat'); return; }
 
 
 
 
 
 
 
 
 
3223
  for (var i = 0; i < 4; i++) {
3224
  if (e.target.closest('#nav_stat_sub_' + i)) {
3225
  closeMobileDrawerIfOpen();
 
3226
  scrollToId('canvas_stat');
3227
  /* 注意:這版 Gradio 的巢狀分頁 DOM 結構是
3228
  .tabs > .tab-wrapper > .tab-container(兩個,
@@ -3318,6 +3426,16 @@ _APP_HEAD_SCRIPT = """
3318
  if (navCol2 && navCol2.classList.contains('collapsed')) setNavCollapsed(false);
3319
  closeMobileDrawerIfOpen();
3320
  }
 
 
 
 
 
 
 
 
 
 
3321
  });
3322
  document.addEventListener('keydown', function (e) {
3323
  if (e.key === 'Escape') closeMobileDrawerIfOpen();
@@ -3389,11 +3507,32 @@ _APP_HEAD_SCRIPT = """
3389
  try { saved = localStorage.getItem('spectra_nav_collapsed'); } catch (err) { saved = null; }
3390
  if (saved === '1') setNavCollapsed(true);
3391
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3392
  setInterval(function () {
3393
  _watchZone('upload_zone');
3394
  _watchZone('metadata_zone');
3395
  _syncMobileHamburger();
3396
  _initNavCollapseIfNeeded();
 
 
3397
  }, 400);
3398
  }
3399
  if (document.readyState === 'loading') {
@@ -3540,7 +3679,7 @@ def create_app():
3540
  gr.Markdown("確定要開始新分析嗎?目前進度將會遺失。")
3541
  with gr.Row(elem_classes=["upload-actions"]):
3542
  reset_cancel_btn = gr.Button("取消", scale=1, variant="secondary")
3543
- reset_confirm_btn = gr.Button("確定重新開始", scale=2, variant="primary")
3544
 
3545
  # Phase 8-D:結果導覽——情境選定後顯示,讓使用者不用手動
3546
  # 往下捲一大段就能跳到 Canvas 對應區塊。這幾顆按鈕刻意不接
@@ -3551,8 +3690,14 @@ def create_app():
3551
  # 下一輪視覺打磨(Heroicons 圖示)一起補,這次先求導覽功能
3552
  # 正確可用。
3553
  with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col:
 
 
 
 
 
 
3554
  nav_top_btn = gr.Button(
3555
- "回到上傳/表單", icon=str(BASE_DIR / "assets" / "icons" / "home.svg"),
3556
  elem_id="nav_top_btn", elem_classes=["result-nav-btn"], variant="secondary",
3557
  )
3558
  nav_idx_btn = gr.Button(
@@ -3573,6 +3718,16 @@ def create_app():
3573
  "LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
3574
  elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
3575
  )
 
 
 
 
 
 
 
 
 
 
3576
 
3577
  with gr.Column(scale=3, min_width=280, elem_classes=["chat-col"]):
3578
  # 高度不再寫死 px,改用 CSS flex-grow 讓對話視窗跟輸入框依
@@ -3769,10 +3924,13 @@ def create_app():
3769
  file_count="multiple", interactive=False, visible=False,
3770
  )
3771
 
3772
- # 技術執行紀錄:Phase 8-B 從 chat-col 搬到 Canvas 底
3773
- # 部,跟中段內容同一塊可捲動捲軸,非 sticky,點擊
3774
- # 往下展開。
3775
- with gr.Accordion("技術執行紀錄", open=False, elem_id="exec_log_accordion") as exec_log_accordion:
 
 
 
3776
  exec_log = gr.Markdown(
3777
  value="",
3778
  elem_id="exec_log",
@@ -3792,6 +3950,7 @@ def create_app():
3792
  canvas_idx_col, canvas_stat_col, canvas_report_col, # 20-22 (visibility)
3793
  tab_imp_file, tab_cluster_file, # 23-24 新增下載按鈕(僅能往尾端加,
3794
  # 不可插入既有 index 中間,見程式工作守則)
 
3795
  ]
3796
  _N_TAB = len(_TAB_OUTPUTS)
3797
 
@@ -4004,28 +4163,6 @@ def create_app():
4004
  )
4005
  )
4006
 
4007
- # ── 技術執行紀錄摺疊面板:長時間運算(指標計算/統計分析/報告撰寫)
4008
- # 時自動展開,其餘時間收合,避免長時間等待時看起來像沒有反應,
4009
- # 但平常又不會有一堆技術字樣佔用版面。
4010
- # 收合時的標題列也會帶上最新一行技術紀錄的摘要,不用點開展開
4011
- # 就能掃一眼知道系統目前在做什麼 ──────────────────────────────
4012
- def _accordion_label(log_history):
4013
- if not log_history:
4014
- return "技術執行紀錄"
4015
- last_line = log_history.strip().split("\n\n")[-1]
4016
- last_line = last_line.replace("`›`", "").strip()
4017
- if len(last_line) > 34:
4018
- last_line = last_line[:34] + "…"
4019
- return f"技術執行紀錄 {last_line}" if last_line else "技術執行紀錄"
4020
-
4021
- def _accordion_update(state, log_history):
4022
- if not state:
4023
- return gr.update()
4024
- return gr.update(
4025
- open=state.get("current_step") in (5, 6, 7),
4026
- label=_accordion_label(log_history),
4027
- )
4028
-
4029
  # ── Phase 8-D:Canvas 運算中提示(粗粒度階段文字+轉圈動畫)─────
4030
  # 沿用跟 _accordion_update 完全一樣的判斷(current_step 在 5/6/7 之間
4031
  # 才顯示),不新增運算邏輯;用 gr.HTML() 單一元件承載,空字串=不
@@ -4058,7 +4195,7 @@ def create_app():
4058
  def on_scenario_pick(sc_num, chat_history, log_history, state):
4059
  if state is None:
4060
  yield (chat_history, log_history, state, gr.update(), gr.update(), gr.update(), gr.update(),
4061
- gr.update(), gr.update(), gr.update())
4062
  return
4063
 
4064
  sc_info = SCENARIOS[sc_num]
@@ -4067,23 +4204,27 @@ def create_app():
4067
  # 首頁一旦選定情境即隱藏,換成對話+操作區(workspace_row);
4068
  # 結果導覽(result_nav_col)也在這時候一併顯示(雖然還沒有結果
4069
  # 可跳轉,但導覽列本身跟開始新分析按鈕一樣是常駐的操作區塊)。
 
 
 
4070
  # 這幾個 visibility 只需要在第一個 yield 就切換一次,後續 yield
4071
  # 沿用 gr.update() 不動即可,不用每次都重複賦值。
4072
  yield (chat_history, log_history, state, gr.update(), gr.update(), gr.update(), gr.update(),
4073
- gr.update(visible=False), gr.update(visible=True), gr.update(visible=True))
 
4074
 
4075
  reply, state = handle_scenario_select(sc_num, state)
4076
  log_history = _log_append(log_history, "✅ STEP 1 情境選擇完成")
4077
  chat_history = chat_history + [_assistant_msg(reply)]
4078
  yield (chat_history, log_history, state, gr.update(), _todo_update(state), gr.update(), gr.update(),
4079
- gr.update(), gr.update(), gr.update())
4080
 
4081
  # STEP 2:確認訊息已經導向上傳卡片的「格式要求」區塊,不再另外用
4082
  # LLM 生成一則重複格式細節的引導訊息,避免對話視窗出現冗餘內容
4083
  state["current_step"] = 2
4084
  log_history = _log_append(log_history, "⏳ STEP 2 上傳引導中……")
4085
  yield (chat_history, log_history, state, gr.update(), _todo_update(state), gr.update(), gr.update(),
4086
- gr.update(), gr.update(), gr.update())
4087
 
4088
  state["done_steps"] = [1, 2]
4089
  state["current_step"] = 3
@@ -4097,14 +4238,14 @@ def create_app():
4097
  )
4098
  yield (chat_history, log_history, state, gr.update(visible=True),
4099
  _todo_update(state), gr.update(visible=False), gr.update(value=hint),
4100
- gr.update(), gr.update(), gr.update())
4101
 
4102
  for _sc_num, _sc_btn in (("1", sc_btn_1), ("2", sc_btn_2), ("3", sc_btn_3), ("4", sc_btn_4)):
4103
  _sc_btn.click(
4104
  fn=functools.partial(on_scenario_pick, _sc_num),
4105
  inputs=[chatbot, exec_log, session_state],
4106
  outputs=[chatbot, exec_log, session_state, upload_row, app_header_html, scenario_btn_col,
4107
- upload_hint_md, homepage_col, workspace_row, result_nav_col],
4108
  ).then(
4109
  fn=_populate_tabs,
4110
  inputs=[session_state],
@@ -4136,7 +4277,7 @@ def create_app():
4136
  gr.update(visible=False), # canvas_stat_col 統計比較分析
4137
  gr.update(visible=False), # canvas_report_col LLM輔助分析
4138
  gr.update(visible=False), # reset_confirm_row(關掉確認彈窗自己)
4139
- _accordion_update(state, new_log), # exec_log_accordion(收合並更新摘要文字)
4140
  gr.update(visible=True), # homepage_col(Phase 8:重新開始=回到首頁選情境)
4141
  gr.update(visible=False), # workspace_row
4142
  gr.update(value=None), # file_input(Phase 8:upload_row 變正式入口後一併清空)
@@ -4151,7 +4292,7 @@ def create_app():
4151
  outputs=[chatbot, exec_log, session_state, app_header_html,
4152
  scenario_btn_col, upload_row, chat_upload, metadata_row,
4153
  canvas_idx_col, canvas_stat_col, canvas_report_col,
4154
- reset_confirm_row, exec_log_accordion, homepage_col, workspace_row, file_input,
4155
  computing_html, result_nav_col],
4156
  )
4157
 
@@ -4244,9 +4385,12 @@ def create_app():
4244
  return "\n".join(L)
4245
 
4246
  def _run_computation(chat_history, log_history, state):
 
 
 
4247
  log_history = _log_append(log_history, "⏳ STEP 5 計算語言指標中……")
4248
  yield (chat_history, log_history, state, _todo_update(state),
4249
- _accordion_update(state, log_history), _computing_update(state))
4250
  for prog_msg, is_final, state in run_computation_pipeline_streaming(state):
4251
  if "指標計算完成" in prog_msg:
4252
  state["done_steps"] = list(set(state.get("done_steps", []) + [5])); state["current_step"] = 6
@@ -4262,7 +4406,7 @@ def create_app():
4262
  if is_final:
4263
  chat_history = chat_history + [_assistant_msg(prog_msg)]
4264
  yield (chat_history, log_history, state, _todo_update(state),
4265
- _accordion_update(state, log_history), _computing_update(state))
4266
 
4267
  def _show_metadata_form(state):
4268
  _NO = gr.update()
@@ -4347,7 +4491,7 @@ def create_app():
4347
  # 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應
4348
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
4349
  yield (chat_history, log_history, state, gr.update(visible=False),
4350
- _todo_update(state), _accordion_update(state, log_history), _computing_update(state))
4351
  for ch, lh, st, todo, acc, comp in _run_computation(chat_history, log_history, state):
4352
  chat_history, log_history, state = ch, lh, st
4353
  yield chat_history, log_history, state, gr.update(visible=False), todo, acc, comp
@@ -4366,7 +4510,7 @@ def create_app():
4366
  meta_ngroups, meta_method, meta_group_df, meta_indiv_df,
4367
  chatbot, exec_log, session_state],
4368
  outputs=[chatbot, exec_log, session_state, metadata_row, app_header_html,
4369
- exec_log_accordion, computing_html],
4370
  ).then(
4371
  fn=_unlock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn],
4372
  ).then(fn=_populate_tabs, inputs=[session_state], outputs=_TAB_OUTPUTS)
@@ -4379,7 +4523,7 @@ def create_app():
4379
  log_history = _log_append(log_history, "⏭️ 已略過 Metadata,直接開始分析")
4380
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
4381
  yield (chat_history, log_history, state, gr.update(visible=False),
4382
- _todo_update(state), _accordion_update(state, log_history), _computing_update(state))
4383
  for ch, lh, st, todo, acc, comp in _run_computation(chat_history, log_history, state):
4384
  chat_history, log_history, state = ch, lh, st
4385
  yield chat_history, log_history, state, gr.update(visible=False), todo, acc, comp
@@ -4390,7 +4534,7 @@ def create_app():
4390
  fn=on_metadata_skip,
4391
  inputs=[chatbot, exec_log, session_state],
4392
  outputs=[chatbot, exec_log, session_state, metadata_row, app_header_html,
4393
- exec_log_accordion, computing_html],
4394
  ).then(
4395
  fn=_unlock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn],
4396
  ).then(fn=_populate_tabs, inputs=[session_state], outputs=_TAB_OUTPUTS)
 
2256
  text-underline-offset: 2px !important;
2257
  }
2258
 
2259
+ /* 2026-07-23:技術執行紀錄從 Accordion 轉正成獨立頁面(canvas_exec_col),
2260
+ 不再是接在對話視窗卡片下方的「抽屜」,改用跟 canvas_idx_col 等其他三個
2261
+ 結果頁共用的 .canvas-result class(已在 gr.Column 宣告時掛上),這裡不用
2262
+ 再另外寫專屬的卡片外框樣式,避免跟共用樣式重複定義出兩套外觀。 */
 
 
 
 
 
 
 
 
 
 
2263
 
2264
  /* 執行視窗(簡易訊息顯示欄,非對話氣泡) */
2265
+ /* 原本這裡還設了 max-height + overflow-y:auto 讓內部自己捲動,但整個頁面
2266
+ 本身也會捲動,兩個捲動容器疊在一起、捲軸位置又離得很近,看起來像「兩條
2267
+ 捲軸」造成混亂——拿掉內部捲動限制,讓內容自然撐開高度,只用頁面本身的
2268
+ 捲軸,維持單一捲動體驗 */
 
2269
  .exec-log {
2270
  background: transparent !important;
2271
  border: none !important;
 
2366
  overflow-x: hidden !important;
2367
  overflow-y: auto !important;
2368
  }
2369
+ /* 2026-07-23:頁面切換模型。點結果類 nav 項目時 JS 會在 .main-layout-row
2370
+ 上加這個 class(見 _APP_HEAD_SCRIPT 的 applyActivePage()),對話欄收起、
2371
+ Canvas 佔滿剩下的寬度。canvas-col 的 flex 要蓋過 Gradio 自己依 scale=7
2372
+ 算出的 inline flex-grow,才會真的補滿收起對話欄後空出來的寬度(同一類
2373
+ 問題 8-E 的 min-width 已經踩過一次,這裡沿用同一個「stylesheet
2374
+ !important 蓋得過沒加 !important 的 inline style」原則)。
2375
+ 踩坑(新案例,跟 8-B 的 .column flex-wrap 是同一大類但這次是 .row 本身
2376
+ 從沒被設過):.main-layout-row(gr.Row)本身從來沒設過 flex-wrap:nowrap
2377
+ ——nav-col/chat-col/canvas-col 各自「內部」的 flex-wrap:nowrap 只管
2378
+ 它們自己的子元件怎麼排,不影響 main-layout-row 怎麼排這三欄本身。平常
2379
+ 三欄用 flex-grow 精準分配寬度、剛好不超版所以wrap從沒被觸發過;這次
2380
+ canvas-col 拿掉 max-width 上限、又改用 flex-basis:auto 之後,內容較寬
2381
+ 的表格會把 canvas-col 的 intrinsic 寬度撐到跟 nav-col 放不進同一行,
2382
+ Gradio 預設的 flex-wrap:wrap 就把 canvas-col 換到 nav-col 下面另起
2383
+ 一行——整個版面變成上下堆疊、頁面被撐到 1200px+ 高,捲動 canvas 內容
2384
+ 時連 nav-col 都跟著被推出視窗外。修法:main-layout-row 本身也要明確
2385
+ nowrap,不能只顧內部三欄各自的 nowrap。 */
2386
+ .main-layout-row {
2387
+ flex-wrap: nowrap !important;
2388
+ }
2389
+ .main-layout-row.chat-collapsed .chat-col {
2390
+ display: none !important;
2391
+ }
2392
+ .main-layout-row.chat-collapsed .canvas-col {
2393
+ flex: 1 1 auto !important;
2394
+ max-width: none !important;
2395
+ min-width: 0 !important;
2396
+ }
2397
  /* Phase 8-C 踩坑:結果內容搬進 canvas-scroll 後,內容總高度遠超過
2398
  canvas-col 給的固定 height,理論上該讓 canvas-scroll 自己捲動就好;但
2399
  exec_log_accordion(技術執行紀錄)被壓縮成只剩標題列高度(22px),內容
 
2550
  .nav-col.collapsed .nav-collapse-row { justify-content: center; }
2551
  .nav-col.collapsed .scenario-buttons,
2552
  .nav-col.collapsed .result-nav-accordion,
2553
+ .nav-col.collapsed .result-nav-divider,
2554
  .nav-col.collapsed .reset-confirm { display: none !important; }
2555
  .nav-col.collapsed .reset-btn,
2556
  .nav-col.collapsed .result-nav-btn {
 
2774
  font-weight: 600 !important;
2775
  color: var(--c-text-soft) !important;
2776
  }
2777
+ /* 2026-07-23:頁面切換模型——目前使用者所在的那一頁在導覽列高亮,
2778
+ applyActivePage() JS 幫對應按鈕加/移除這個 class。視覺先求「看得出
2779
+ 選中誰」��基本可用,細部樣式(跟 Bootstrap 借來的分組/層級規範)留給
2780
+ 下一輪 nav-col 視覺重做一起處理,這裡不重複做兩次。 */
2781
+ .result-nav-btn.active {
2782
+ background: rgba(var(--c-accent-rgb),.10) !important;
2783
+ color: var(--c-accent-strong) !important;
2784
+ font-weight: 700 !important;
2785
+ }
2786
+ .result-nav-accordion.active .label-wrap {
2787
+ color: var(--c-accent-strong) !important;
2788
+ font-weight: 700 !important;
2789
+ }
2790
+ /* 技術執行紀錄:低優先度診斷工具,跟上面三個主要分析結果用分隔線+
2791
+ 降低視覺權重區隔(字級/顏色都比照 result-nav-subbtn 而非 result-nav-btn)*/
2792
+ .result-nav-divider {
2793
+ height: 1px;
2794
+ background: var(--c-border);
2795
+ margin: .5rem .3rem;
2796
+ }
2797
+ .result-nav-btn--muted {
2798
+ font-size: .82rem !important;
2799
+ font-weight: 500 !important;
2800
+ color: var(--c-text-muted) !important;
2801
+ }
2802
  /* 語料 Metadata 說明表單卡片 */
2803
  .metadata-zone {
2804
  border: 1px solid rgba(var(--c-accent-rgb),.40) !important;
 
3236
  }
3237
  [300, 800, 1500, 3000].forEach(function(t) { setTimeout(bindScenarioCards, t); });
3238
 
3239
+ /* 2026-07-23:頁面切換模型。nav-col 的每個項目現在是真正的
3240
+ 「頁面」——結果類項目(語言指標分析/統計比較分析/LLM輔助分析/
3241
+ 技術執行紀錄)點進去時互斥顯示、收起對話欄、Canvas 佔滿寬度;
3242
+ 「AI對話」(原「回到上傳/表單」)點進去對話欄彈回、Canvas 顯示
3243
+ 上傳/表單/運算中內容。跟 8-C/8-D 的舊行為(有資料就全部堆疊、
3244
+ 使用者自己往下捲)完全不同:現在同一時間只顯示使用者當前選的
3245
+ 那一頁,不管其他頁有沒有資料。
3246
+ 技術做法:canvas-scroll 底下 7 個直屬區塊分成 5 組頁面歸屬
3247
+ (upload_zone/metadata_zone/computing_zone 屬於 chat 頁;
3248
+ canvas_idx/canvas_stat/canvas_report/canvas_exec 各自獨立頁),
3249
+ JS 只清除或強制 inline display:none,絕不強制顯示——Python 那層
3250
+ `visible=False`(資料是否存在的判斷,見 _populate_tabs)永遠
3251
+ 優先,這裡只負責「同一頁的東西藏起來,不動 Python 已經決定隱藏
3252
+ 的東西」(沿用 8-E 的 style.display='' / 'none' !important
3253
+ 技巧)。 */
3254
+ var _PAGE_OWNED_IDS = {
3255
+ chat: ['upload_zone', 'metadata_zone', 'computing_zone'],
3256
+ idx: ['canvas_idx'],
3257
+ stat: ['canvas_stat'],
3258
+ report: ['canvas_report'],
3259
+ exec: ['canvas_exec']
3260
+ };
3261
+ var _PAGE_NAV_IDS = {
3262
+ chat: 'nav_top_btn', idx: 'nav_idx_btn', stat: 'nav_stat_accordion',
3263
+ report: 'nav_report_btn', exec: 'nav_exec_btn'
3264
+ };
3265
+ function applyActivePage(page) {
3266
+ var mainRow = document.querySelector('.main-layout-row');
3267
+ if (!mainRow) return;
3268
+ mainRow.setAttribute('data-page', page);
3269
+ mainRow.classList.toggle('chat-collapsed', page !== 'chat');
3270
+ Object.keys(_PAGE_OWNED_IDS).forEach(function (key) {
3271
+ _PAGE_OWNED_IDS[key].forEach(function (id) {
3272
+ var el = document.getElementById(id);
3273
+ if (!el) return;
3274
+ if (key === page) {
3275
+ el.style.removeProperty('display');
3276
+ } else {
3277
+ el.style.setProperty('display', 'none', 'important');
3278
+ }
3279
+ });
3280
+ });
3281
+ Object.keys(_PAGE_NAV_IDS).forEach(function (key) {
3282
+ var btn = document.getElementById(_PAGE_NAV_IDS[key]);
3283
+ if (btn) btn.classList.toggle('active', key === page);
3284
+ });
3285
+ }
3286
+ function _initActivePageIfNeeded() {
3287
+ var mainRow = document.querySelector('.main-layout-row');
3288
+ if (!mainRow || mainRow.dataset.pageInit) return;
3289
+ mainRow.dataset.pageInit = '1';
3290
+ applyActivePage('chat');
3291
+ }
3292
+
3293
+ /* 左側結果導覽(result_nav_col)——純粹是「切換頁面+捲動+切換
3294
+ 巢狀子分頁」的畫面操作,不牽動任何後端狀態,所以這幾顆按鈕在
3295
+ Python 端完全沒有掛 .click(),改用這裡的事件代理直接處理,不用
3296
+ 走 Gradio queue 來回。
3297
  注意:result_nav_col 建構時是 visible=False(跟 sc_btn_1..4
3298
  一度踩過的坑同一類),如果比照 bindScenarioCards() 用
3299
  setTimeout 抓固定��間點的按鈕,情境選定前按鈕還沒掛載,之後
 
3310
  document.addEventListener('click', function (e) {
3311
  if (e.target.closest('#nav_top_btn')) {
3312
  closeMobileDrawerIfOpen();
3313
+ applyActivePage('chat');
3314
  var scrollBox = document.querySelector('.canvas-scroll');
3315
  if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' });
3316
  return;
3317
  }
3318
+ if (e.target.closest('#nav_idx_btn')) {
3319
+ closeMobileDrawerIfOpen(); applyActivePage('idx'); scrollToId('canvas_idx'); return;
3320
+ }
3321
+ if (e.target.closest('#nav_report_btn')) {
3322
+ closeMobileDrawerIfOpen(); applyActivePage('report'); scrollToId('canvas_report'); return;
3323
+ }
3324
+ if (e.target.closest('#nav_exec_btn')) {
3325
+ closeMobileDrawerIfOpen(); applyActivePage('exec'); scrollToId('canvas_exec'); return;
3326
+ }
3327
+ if (e.target.closest('#nav_stat_accordion .label-wrap')) {
3328
+ closeMobileDrawerIfOpen(); applyActivePage('stat'); scrollToId('canvas_stat'); return;
3329
+ }
3330
  for (var i = 0; i < 4; i++) {
3331
  if (e.target.closest('#nav_stat_sub_' + i)) {
3332
  closeMobileDrawerIfOpen();
3333
+ applyActivePage('stat');
3334
  scrollToId('canvas_stat');
3335
  /* 注意:這版 Gradio 的巢狀分頁 DOM 結構是
3336
  .tabs > .tab-wrapper > .tab-container(兩個,
 
3426
  if (navCol2 && navCol2.classList.contains('collapsed')) setNavCollapsed(false);
3427
  closeMobileDrawerIfOpen();
3428
  }
3429
+ if (e.target.closest('#reset_confirm_btn')) {
3430
+ /* 2026-07-23:真的按下「確定重新開始」才重置頁面切換
3431
+ 狀態回 chat(不能在 #reset_btn 那支就做,那個只是
3432
+ 打開確認彈窗,使用者還可能按取消)。.main-layout-row
3433
+ 這個 DOM 節點在 Gradio 重置流程中不會被銷毀重建,
3434
+ data-page/chat-collapsed 這些 attribute 不會自動
3435
+ 清掉,若不主動重置,下一次選新情境時畫面會直接卡在
3436
+ 上一次分析結束前所在的那一頁。 */
3437
+ applyActivePage('chat');
3438
+ }
3439
  });
3440
  document.addEventListener('keydown', function (e) {
3441
  if (e.key === 'Escape') closeMobileDrawerIfOpen();
 
3507
  try { saved = localStorage.getItem('spectra_nav_collapsed'); } catch (err) { saved = null; }
3508
  if (saved === '1') setNavCollapsed(true);
3509
  }
3510
+ function _reapplyActivePage() {
3511
+ /* 踩坑(2026-07-23,全新踩坑類別):applyActivePage() 用
3512
+ el.style.setProperty('display','none','important') 隱藏
3513
+ 非當前頁區塊,理論上一次設定就該一直有效。但實測發現運算
3514
+ 完成後(_populate_tabs 把 canvas_idx_col 等設 visible=True)
3515
+ canvas_idx/canvas_stat 又跑出來混進 chat 頁——代表 Gradio
3516
+ 的 Svelte reactivity 在元件的 visible prop 改變時,會自己
3517
+ 整個重寫該元素的 style 屬性(不是「疊加」而是「覆蓋」),
3518
+ 把我手動加的 inline !important 一併洗掉。這跟先前踩過的
3519
+ 「Python 的 gr.update 值改變時整個重新渲染」是同一個大類,
3520
+ 只是這次連 inline style 都被連帶清空,不只是 min-width 那種
3521
+ 單一屬性優先權的問題。修法沿用這個檔案一貫的解法:不奢求
3522
+ 一次設定永久有效,改成跟 _watchZone/_syncMobileHamburger
3523
+ 一樣搭這裡的輪詢,每 400ms 重新套用一次目前頁面對應的
3524
+ 隱藏規則,Gradio 重新渲染蓋掉的話下一個 tick 就修回來。 */
3525
+ var mainRow = document.querySelector('.main-layout-row');
3526
+ var page = mainRow && mainRow.getAttribute('data-page');
3527
+ if (page) applyActivePage(page);
3528
+ }
3529
  setInterval(function () {
3530
  _watchZone('upload_zone');
3531
  _watchZone('metadata_zone');
3532
  _syncMobileHamburger();
3533
  _initNavCollapseIfNeeded();
3534
+ _initActivePageIfNeeded();
3535
+ _reapplyActivePage();
3536
  }, 400);
3537
  }
3538
  if (document.readyState === 'loading') {
 
3679
  gr.Markdown("確定要開始新分析嗎?目前進度將會遺失。")
3680
  with gr.Row(elem_classes=["upload-actions"]):
3681
  reset_cancel_btn = gr.Button("取消", scale=1, variant="secondary")
3682
+ reset_confirm_btn = gr.Button("確定重新開始", elem_id="reset_confirm_btn", scale=2, variant="primary")
3683
 
3684
  # Phase 8-D:結果導覽——情境選定後顯示,讓使用者不用手動
3685
  # 往下捲一大段就能跳到 Canvas 對應區塊。這幾顆按鈕刻意不接
 
3690
  # 下一輪視覺打磨(Heroicons 圖示)一起補,這次先求導覽功能
3691
  # 正確可用。
3692
  with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col:
3693
+ # 2026-07-23 改名「AI對話」+改變功能定位:原本純粹是
3694
+ # 「捲回 Canvas 最上面」的畫面操作,現在身兼「切回流程頁」
3695
+ # 的角色(見 applyActivePage() JS)——流程進行中(上傳/
3696
+ # 表單/運算中)對話欄常駐顯示,點結果類項目才收起;點
3697
+ # 這顆按鈕會讓對話欄彈回來。圖示維持 home(語意上仍是
3698
+ #「回到主要工作流程」,不用重畫新圖示)。
3699
  nav_top_btn = gr.Button(
3700
+ "AI對話", icon=str(BASE_DIR / "assets" / "icons" / "home.svg"),
3701
  elem_id="nav_top_btn", elem_classes=["result-nav-btn"], variant="secondary",
3702
  )
3703
  nav_idx_btn = gr.Button(
 
3718
  "LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
3719
  elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
3720
  )
3721
+ # 2026-07-23:技術執行紀錄從手風琴轉正成獨立頁面(原本
3722
+ # exec_log_accordion 在 canvas-col 底部,現在跟其他三個
3723
+ # 結果頁同一種待遇)。用分隔線隔開,視覺上表達「低優先度
3724
+ # 診斷工具,不是主要分析結果」,避免跟上面三項混為一談。
3725
+ gr.HTML('<div class="result-nav-divider"></div>')
3726
+ nav_exec_btn = gr.Button(
3727
+ "技術執行紀錄", icon=str(BASE_DIR / "assets" / "icons" / "folder.svg"),
3728
+ elem_id="nav_exec_btn", elem_classes=["result-nav-btn", "result-nav-btn--muted"],
3729
+ variant="secondary",
3730
+ )
3731
 
3732
  with gr.Column(scale=3, min_width=280, elem_classes=["chat-col"]):
3733
  # 高度不再寫死 px,改用 CSS flex-grow 讓對話視窗跟輸入框依
 
3924
  file_count="multiple", interactive=False, visible=False,
3925
  )
3926
 
3927
+ # 2026-07-23:技術執行紀錄從手風琴(exec_log_accordion)
3928
+ # 轉正成獨立頁面,跟 canvas_idx_col/canvas_stat_col/
3929
+ # canvas_report_col 同一種待遇——不再需要 open/label
3930
+ # 收合狀態邏輯(原本靠 _accordion_update 控制),改用
3931
+ # 跟其他三個結果頁一樣的 visible=True/False 語意,交給
3932
+ # applyActivePage() JS 決定要不要顯示。
3933
+ with gr.Column(visible=False, elem_id="canvas_exec", elem_classes=["canvas-result"]) as canvas_exec_col:
3934
  exec_log = gr.Markdown(
3935
  value="",
3936
  elem_id="exec_log",
 
3950
  canvas_idx_col, canvas_stat_col, canvas_report_col, # 20-22 (visibility)
3951
  tab_imp_file, tab_cluster_file, # 23-24 新增下載按鈕(僅能往尾端加,
3952
  # 不可插入既有 index 中間,見程式工作守則)
3953
+ canvas_exec_col, # 25 技術執行紀錄轉正成獨立頁面(2026-07-23)
3954
  ]
3955
  _N_TAB = len(_TAB_OUTPUTS)
3956
 
 
4163
  )
4164
  )
4165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4166
  # ── Phase 8-D:Canvas 運算中提示(粗粒度階段文字+轉圈動畫)─────
4167
  # 沿用跟 _accordion_update 完全一樣的判斷(current_step 在 5/6/7 之間
4168
  # 才顯示),不新增運算邏輯;用 gr.HTML() 單一元件承載,空字串=不
 
4195
  def on_scenario_pick(sc_num, chat_history, log_history, state):
4196
  if state is None:
4197
  yield (chat_history, log_history, state, gr.update(), gr.update(), gr.update(), gr.update(),
4198
+ gr.update(), gr.update(), gr.update(), gr.update())
4199
  return
4200
 
4201
  sc_info = SCENARIOS[sc_num]
 
4204
  # 首頁一旦選定情境即隱藏,換成對話+操作區(workspace_row);
4205
  # 結果導覽(result_nav_col)也在這時候一併顯示(雖然還沒有結果
4206
  # 可跳轉,但導覽列本身跟開始新分析按鈕一樣是常駐的操作區塊)。
4207
+ # 技術執行紀錄(canvas_exec_col)跟著同一時間顯示——它從一開始
4208
+ # 就有內容(這段 log 本身),不像其他三個結果頁要等運算完成才有
4209
+ # 東西可看,不用透過 _populate_tabs 的檔案存在判斷來決定可見性。
4210
  # 這幾個 visibility 只需要在第一個 yield 就切換一次,後續 yield
4211
  # 沿用 gr.update() 不動即可,不用每次都重複賦值。
4212
  yield (chat_history, log_history, state, gr.update(), gr.update(), gr.update(), gr.update(),
4213
+ gr.update(visible=False), gr.update(visible=True), gr.update(visible=True),
4214
+ gr.update(visible=True))
4215
 
4216
  reply, state = handle_scenario_select(sc_num, state)
4217
  log_history = _log_append(log_history, "✅ STEP 1 情境選擇完成")
4218
  chat_history = chat_history + [_assistant_msg(reply)]
4219
  yield (chat_history, log_history, state, gr.update(), _todo_update(state), gr.update(), gr.update(),
4220
+ gr.update(), gr.update(), gr.update(), gr.update())
4221
 
4222
  # STEP 2:確認訊息已經導向上傳卡片的「格式要求」區塊,不再另外用
4223
  # LLM 生成一則重複格式細節的引導訊息,避免對話視窗出現冗餘內容
4224
  state["current_step"] = 2
4225
  log_history = _log_append(log_history, "⏳ STEP 2 上傳引導中……")
4226
  yield (chat_history, log_history, state, gr.update(), _todo_update(state), gr.update(), gr.update(),
4227
+ gr.update(), gr.update(), gr.update(), gr.update())
4228
 
4229
  state["done_steps"] = [1, 2]
4230
  state["current_step"] = 3
 
4238
  )
4239
  yield (chat_history, log_history, state, gr.update(visible=True),
4240
  _todo_update(state), gr.update(visible=False), gr.update(value=hint),
4241
+ gr.update(), gr.update(), gr.update(), gr.update())
4242
 
4243
  for _sc_num, _sc_btn in (("1", sc_btn_1), ("2", sc_btn_2), ("3", sc_btn_3), ("4", sc_btn_4)):
4244
  _sc_btn.click(
4245
  fn=functools.partial(on_scenario_pick, _sc_num),
4246
  inputs=[chatbot, exec_log, session_state],
4247
  outputs=[chatbot, exec_log, session_state, upload_row, app_header_html, scenario_btn_col,
4248
+ upload_hint_md, homepage_col, workspace_row, result_nav_col, canvas_exec_col],
4249
  ).then(
4250
  fn=_populate_tabs,
4251
  inputs=[session_state],
 
4277
  gr.update(visible=False), # canvas_stat_col 統計比較分析
4278
  gr.update(visible=False), # canvas_report_col LLM輔助分析
4279
  gr.update(visible=False), # reset_confirm_row(關掉確認彈窗自己)
4280
+ gr.update(visible=False), # canvas_exec_col 技術執行紀錄(2026-07-23 轉正後,跟其他三個結果頁一樣重置為隱藏)
4281
  gr.update(visible=True), # homepage_col(Phase 8:重新開始=回到首頁選情境)
4282
  gr.update(visible=False), # workspace_row
4283
  gr.update(value=None), # file_input(Phase 8:upload_row 變正式入口後一併清空)
 
4292
  outputs=[chatbot, exec_log, session_state, app_header_html,
4293
  scenario_btn_col, upload_row, chat_upload, metadata_row,
4294
  canvas_idx_col, canvas_stat_col, canvas_report_col,
4295
+ reset_confirm_row, canvas_exec_col, homepage_col, workspace_row, file_input,
4296
  computing_html, result_nav_col],
4297
  )
4298
 
 
4385
  return "\n".join(L)
4386
 
4387
  def _run_computation(chat_history, log_history, state):
4388
+ # 2026-07-23:技術執行紀錄轉正成獨立頁面後,可見性只在情境選定
4389
+ # (on_scenario_pick)與重置(on_reset_confirm)時切換一次,
4390
+ # 這裡不用再逐步更新開合狀態/摘要標籤,改回傳 gr.update() 不動。
4391
  log_history = _log_append(log_history, "⏳ STEP 5 計算語言指標中……")
4392
  yield (chat_history, log_history, state, _todo_update(state),
4393
+ gr.update(), _computing_update(state))
4394
  for prog_msg, is_final, state in run_computation_pipeline_streaming(state):
4395
  if "指標計算完成" in prog_msg:
4396
  state["done_steps"] = list(set(state.get("done_steps", []) + [5])); state["current_step"] = 6
 
4406
  if is_final:
4407
  chat_history = chat_history + [_assistant_msg(prog_msg)]
4408
  yield (chat_history, log_history, state, _todo_update(state),
4409
+ gr.update(), _computing_update(state))
4410
 
4411
  def _show_metadata_form(state):
4412
  _NO = gr.update()
 
4491
  # 在主對話視窗立即顯示「開始分析」訊息,避免長時間運算期間畫面看起來像沒有反應
4492
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
4493
  yield (chat_history, log_history, state, gr.update(visible=False),
4494
+ _todo_update(state), gr.update(), _computing_update(state))
4495
  for ch, lh, st, todo, acc, comp in _run_computation(chat_history, log_history, state):
4496
  chat_history, log_history, state = ch, lh, st
4497
  yield chat_history, log_history, state, gr.update(visible=False), todo, acc, comp
 
4510
  meta_ngroups, meta_method, meta_group_df, meta_indiv_df,
4511
  chatbot, exec_log, session_state],
4512
  outputs=[chatbot, exec_log, session_state, metadata_row, app_header_html,
4513
+ canvas_exec_col, computing_html],
4514
  ).then(
4515
  fn=_unlock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn],
4516
  ).then(fn=_populate_tabs, inputs=[session_state], outputs=_TAB_OUTPUTS)
 
4523
  log_history = _log_append(log_history, "⏭️ 已略過 Metadata,直接開始分析")
4524
  chat_history = chat_history + [_assistant_msg(_ANALYSIS_START_MSG)]
4525
  yield (chat_history, log_history, state, gr.update(visible=False),
4526
+ _todo_update(state), gr.update(), _computing_update(state))
4527
  for ch, lh, st, todo, acc, comp in _run_computation(chat_history, log_history, state):
4528
  chat_history, log_history, state = ch, lh, st
4529
  yield chat_history, log_history, state, gr.update(visible=False), todo, acc, comp
 
4534
  fn=on_metadata_skip,
4535
  inputs=[chatbot, exec_log, session_state],
4536
  outputs=[chatbot, exec_log, session_state, metadata_row, app_header_html,
4537
+ canvas_exec_col, computing_html],
4538
  ).then(
4539
  fn=_unlock_meta, inputs=None, outputs=[meta_confirm_btn, meta_cancel_btn],
4540
  ).then(fn=_populate_tabs, inputs=[session_state], outputs=_TAB_OUTPUTS)
assets/icons/folder.svg ADDED