feat(t210): 首頁改統計,不再逐檔解釋——Evan「9000/101/20 兜不起來」的病

地基(progress.go 的 SyncProgress/ClassifyFailure/BuildFailureBreakdown,
eeb35ba)已經算好「總量」與「失敗分類」,但沒人接到畫面上:首頁仍在用
本輪計數(extractedOK)+逐檔白話翻譯(humanizeFailure),導致 leo 08-08
轉述的病——三個數字互相對不起來,使用者無法判斷「還在跑」還是「壞了」。

接線(collector/direct.go):
- runDirectOnceRoot 現在也回傳這一根資料夾的 SyncProgress/StuckReasons
  (rootProgress),RunDirectOnce 跨帳號跨資料夾 Add() 累加成總量。
- G-6.2 的 SkippedDocCount(讀不了的檔,根本沒進 manifest)併進
  Unreadable/Total——這是 Progress() 算不到的部分,由呼叫端補齊,
  維持不變式 Total == Done+Pending+Stuck+Unreadable。
- 「送不上去」的分類統計=Stuck 的 LastError 原文+Unreadable 重用
  convert.go 既有的 ErrUnsupported,一起餵給 BuildFailureBreakdown。
  分類判斷全程只經過 progress.go 的 ClassifyFailure 一個接縫,
  direct.go/app.go/前端都不認得任何分類名稱字串(留給 t214 之後
  改資料驅動時只動一個檔)。
- SyncStatus 新增 Progress/FailureBreakdown 兩個欄位,兩者都是每輪從
  manifest/掃描結果原地重算的現況快照,不進 CarryForwardActivity——
  斷網或閒置一輪不會被清成 0。

畫面(cmd/arcrun-app/app.go+frontend/src/main.js):
- 移除 humanizeFailure/buildFailures/UIFailures 那套逐檔白話翻譯,
  改用 UIProgress(Total/Done/Pending/CantSync/Groups);前端 cardProgress
  只把後端給的 category/count 陣列原樣印出,不分支、不排序、不認分類名。
- 「送不上去」預設摺疊(<details>),展開只有分類與份數,不逐檔列名、
  不解釋、不給解法;細節導向「開啟使用說明」。
- 保留 buildSkipped 的「讀不了的檔」卡片(那是另一件事),但份數已併入
  Unreadable。
- 移除「總計」卡片裡用本輪計數 extractedOK 的「份已整理」——上方狀態
  時間軸的「上次 N 份」與下方矛盾(1 份 vs 0 份已整理)的病因直接消掉,
  改用 cardProgress 的累計「已送上去」。

驗證:
- go build ./... 與 go test ./...(collector/cmd/arcrun-app/
  cmd/arcrun-tray 三個 module)全綠。
- 新增 collector/progress_wiring_test.go:真跑 RunDirectOnce 湊出
  Done/Pending/Stuck/Unreadable 四種狀態同時存在,驗四數字相加等於
  總數(leo 驗法①);再跑一輪「什麼都沒發生」驗數字不歸零(驗法③)。
- check-render.sh 視覺機械閘綠(lockup 底板/深色模式)。

殘項(誠實標記,未完成):
- 真機驗收(leo 08-08 驗法④:拿 Evan 的情境走一遍)未做,需要 leo 或
  封測者在實機驗證。
- 「開啟使用說明」目前連到既有 docs 首頁,尚無 t210 分類對應的 FAQ 頁
  (tasks.md 已記為相依項)。
- t213 診斷檔尚未消費這組新欄位(tasks.md 記載該任務等本任務讓路)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 12:45:12 +08:00
parent 1efc8123f1
commit b3f63f40fb
6 changed files with 333 additions and 170 deletions
+55 -133
View File
@@ -23,13 +23,12 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
runtime2 "runtime"
"sort"
"strconv"
"strings"
"time"
collector "arcrun-rag/collector"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -77,20 +76,10 @@ type directConfig struct {
// syncStatus 對映 collector 寫的 status.json(只取 UI 要的欄位)。
type syncStatus struct {
LastSync string `json:"last_sync,omitempty"`
ExtractedOK int `json:"extracted_ok"`
ExtractFailed int `json:"extract_failed"`
// 🔴 2026-08-06collector 早就把「哪個檔失敗、為什麼」寫進 status.json 了
// sync_status.go 的 Failures),但 App 一直只讀計數 ⇒ 畫面只會說「⚠ 3 份失敗」,
// 使用者與遠端的我都不知道是哪一份、什麼原因。**今天第四次同款病**
// (前三次:exit status 2 蓋掉真話/非文件檔不點名/誤判沒連線)。
Failures []struct {
Path string `json:"path"`
Error string `json:"error"`
} `json:"failures"`
LastSync string `json:"last_sync,omitempty"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
// 「上次真的有做事」那一輪(2026-08-05)——ExtractedOK 是本輪計數會被下一輪歸零,
// 「上次真的有做事」那一輪(2026-08-05)——本輪計數會被下一輪歸零,
// 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。
LastActivityAt string `json:"last_activity_at,omitempty"`
LastActivityOK int `json:"last_activity_ok"`
@@ -101,6 +90,12 @@ type syncStatus struct {
SkippedDocCount int `json:"skipped_doc_count"`
SkippedOtherCount int `json:"skipped_other_count"`
SkippedOtherNames []string `json:"skipped_other_names"`
// t2102026-08-08):總量進度快照+無法同步的分類統計——**同一組數字**
// 供首頁與診斷檔(t213)共用,不再各算各的。兩者的形狀(欄位、JSON tag)
// 都定義在 collector/progress.go,這裡只是原樣接住 status.json 裡的那一份,
// 不重新定義結構,避免兩邊的欄位定義漂移。
Progress collector.SyncProgress `json:"progress"`
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"`
}
type skippedDoc struct {
@@ -226,21 +221,20 @@ type UIAccount struct {
Folders []UIFolder `json:"folders"`
}
type UIState struct {
Version string `json:"version"`
StatusBig string `json:"statusBig"`
StatusSub string `json:"statusSub"`
Syncing bool `json:"syncing"`
Accounts []UIAccount `json:"accounts"`
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」
Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6
Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫)
Version string `json:"version"`
StatusBig string `json:"statusBig"`
StatusSub string `json:"statusSub"`
Syncing bool `json:"syncing"`
Accounts []UIAccount `json:"accounts"`
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6
Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫
// EngineTrouble=同步引擎有問題(沒在跑/一直啟動失敗)⇒ 前端才長出「回報問題」卡。
// 沒事時不顯示,避免把「哪裡看 log」變成常駐噪音。
EngineTrouble bool `json:"engineTrouble"`
Failures *UIFailures `json:"failures"` // 沒有就是 null,前端不畫
LogFolder string `json:"logFolder"`
EngineTrouble bool `json:"engineTrouble"`
Progress UIProgress `json:"progress"` // 首頁「你的檔案」那行(t210
LogFolder string `json:"logFolder"`
}
// UISkipped=首頁那張「這些檔案現在還處理不了」的卡。
@@ -260,116 +254,45 @@ type UISkipped struct {
Other string `json:"other"` // 非文件檔的一行說明(沒有就空字串)
}
// UIFailures=首頁那張「有幾份沒送上去」的卡
// UIProgress=首頁「你的檔案」那張卡(t2102026-08-08,取代 08-06 的逐檔白話翻譯)
//
// 🔴 存在理由(leo 2026-08-06 實測):畫面寫「⚠ 3 份失敗」但**不說是哪一份、為什麼**
// ⇒ leo 只能回報「新的兩個檔案確實沒推到雲端」,我只能猜。
// 而原因**本來就在 status.json 裡**,只是沒人拿出來用。
type UIFailures struct {
Title string `json:"title"`
Note string `json:"note"`
Items []UIFailItem `json:"items"`
More int `json:"more"`
}
type UIFailItem struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
// maxFailShown=最多列幾份(多了會洗版;總數仍照實講)。
const maxFailShown = 8
// parseRetryMessage 從退避訊息裡取出「試了幾次、多久後再試、原因是什麼」。
// 格式來源=collector 的 retrySkipReason()。解析失敗就回 ok=false,由呼叫端原樣顯示。
func parseRetryMessage(raw string) (n int, wait, reason string, ok bool) {
m := retryMsgRe.FindStringSubmatch(raw)
if m == nil {
return 0, "", "", false
}
n, _ = strconv.Atoi(m[1])
wait = humanizeDuration(m[2])
if i := strings.Index(raw, "|原因:"); i >= 0 {
reason = raw[i+len("|原因:"):]
}
return n, wait, reason, true
}
var retryMsgRe = regexp.MustCompile(`上次失敗(第 (\d+) 次),([0-9hms.]+) 後重試`)
// humanizeDuration 把 `5h38m38s` 講成「5 小時」——使用者不需要精確到秒。
func humanizeDuration(d string) string {
dur, err := time.ParseDuration(d)
if err != nil {
return d
}
switch {
case dur >= time.Hour:
return fmt.Sprintf("%d 小時", int(dur.Hours()))
case dur >= time.Minute:
return fmt.Sprintf("%d 分鐘", int(dur.Minutes()))
default:
return "不到 1 分鐘"
}
}
// humanizeFailure 把 collector 的技術錯誤翻成使用者看得懂、而且**知道要不要行動**的一句話。
// 🔴 存在理由(leo 轉述封測者 Evan 08-08 原話):「我有 9000 個檔,雲端只有 101 張卡,
// 畫面卻說『20 份沒送進知識庫』——這幾個數字到底是怎麼回事?是壞了嗎?還是繼續在跑?」
// ⇒ 首頁要能一次講完「你有幾份、我做完幾份、剩下幾份會自動接著做」,
//
// 🔴 leo 2026-08-06 實測,兩個真實案例(從他 Windows 的 collector.log 挖出來的):
// 單位一律是「份檔案」,Total/Done/Pending/CantSync 四個數字相加要等於 Total
// leo 08-08 驗法①,源頭不變式見 collector/progress.go)。
//
// ① `4006: you have used up your daily free allocation of 10,000 neurons`
// Cloudflare Workers AI **當日免費額度用完**。不是檔案的問題、也不是壞掉
// 明天會自動恢復。使用者若不知道,只會以為程式壞了、反覆重丟
// ② `轉檔失敗(…pdf):檔案裡沒有可抽取的文字`
// = 那份 PDF 是**掃描的圖片**,沒有文字層。要嘛做 OCR,要嘛換一份。
//
// 兩件事的處置完全相反(一個等就好、一個要動手),所以**不能都叫「失敗」了事**。
func humanizeFailure(raw string) string {
switch {
case strings.Contains(raw, "neurons"), strings.Contains(raw, "4006"):
return "今天的免費 AI 額度用完了 ⇒ 明天會自動恢復,這些檔案會自己補上,你不用做什麼。" +
"(想馬上處理可以到 Cloudflare 升級 Workers Paid 方案)"
case strings.Contains(raw, "沒有可抽取的文字"):
return "這份 PDF 看起來是掃描的圖片,沒有文字可以讀 ⇒ 需要先做文字辨識(OCR),或換一份有文字的版本。"
case strings.Contains(raw, "尚未支援的檔案格式"):
return "這種檔案格式還讀不了 ⇒ 先另存成 PDF 或 Word(.docx)再放回同一個資料夾。"
}
// 退避訊息:collector 給的是「上次失敗(第 5 次),5h38m38s 後重試|原因:…」。
//
// 🔴 leo 2026-08-06:「**這個錯誤訊息我真的看不懂,明白說比較好**」
// ——我原本只是把原文轉貼、再加一句「上面是自動重試的排程」,那是寫給我自己看的。
// 使用者要的是三件事:**現在怎樣、為什麼、我要不要做什麼**。
if n, wait, reason, ok := parseRetryMessage(raw); ok {
if reason != "" {
// 有原因就以原因為主,排程放後面當補充(他真正需要判斷的是原因)
return humanizeFailure(reason) +
fmt.Sprintf("(已經自動試過 %d 次,約 %s後會再試一次)", n, wait)
}
return fmt.Sprintf("已經自動試過 %d 次都沒成功,約 %s後會再試一次。"+
"這個檔是在舊版失敗的,當時沒有記下原因;如果一直沒好,"+
"到「版本與更新」旁邊把紀錄檔傳給我們。", n, wait)
}
return raw
// 🔴 leo 08-08 追加約束(t214 預留):**分類判斷只住在 collector/progress.go 的
// ClassifyFailure 一個接縫**——這裡與前端 main.js 都不准認得任何一個分類名稱字串
// Groups 原樣照後端給的 category/count 陣列畫,順序也照後端給的(FailCategories
// 之後把分類改成資料驅動時,才只需要動那一個檔。
type UIProgress struct {
Total int `json:"total"` // 你的檔案,共幾份
Done int `json:"done"` // 已送上去
Pending int `json:"pending"` // 排隊中(會自動接著做)
CantSync int `json:"cantSync"` // 送不上去(=卡住+讀不了,預設摺疊,展開看 Groups)
Groups []UIFailGroup `json:"groups"` // 「送不上去」展開後的分類統計;沒有就是空陣列
}
func buildFailures(s syncStatus) *UIFailures {
if len(s.Failures) == 0 {
return nil
// UIFailGroup=一個分類與它的份數,逐字接住 collector.FailureGroup(不重新判斷)。
type UIFailGroup struct {
Category string `json:"category"`
Count int `json:"count"`
}
// buildProgress 把 collector 已經算好的 SyncProgressFailureBreakdown
// 轉成前端要的形狀——只搬資料,不重新判斷任何分類。
func buildProgress(s syncStatus) UIProgress {
p := s.Progress
u := UIProgress{
Total: p.Total,
Done: p.Done,
Pending: p.Pending,
CantSync: p.Stuck + p.Unreadable,
}
u := &UIFailures{
Title: fmt.Sprintf("有 %d 份沒有送進知識庫", len(s.Failures)),
Note: "下面是失敗的檔案與原因。這些檔案會自動重試,你不用重丟;如果一直失敗,把這段回報給我們。",
}
for i, f := range s.Failures {
if i >= maxFailShown {
u.More = len(s.Failures) - maxFailShown
break
}
u.Items = append(u.Items, UIFailItem{
Name: filepath.Base(f.Path),
Reason: humanizeFailure(f.Error),
})
for _, g := range s.FailureBreakdown.Groups {
u.Groups = append(u.Groups, UIFailGroup{Category: g.Category, Count: g.Count})
}
return u
}
@@ -509,11 +432,10 @@ func (a *App) GetState() UIState {
}
sync := loadSyncStatus()
st.ExtractedOK = sync.ExtractedOK
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
st.Steps = buildSteps(sync, st.Syncing)
st.Skipped = buildSkipped(sync)
st.Failures = buildFailures(sync)
st.Progress = buildProgress(sync)
// 引擎有問題才把「回報問題」卡叫出來(含記錄檔路徑)。
// 沒事時不顯示——否則「哪裡看 log」會變成常駐噪音,真出事時反而沒人看。
st.EngineTrouble = !collectorAlive()
+33 -21
View File
@@ -73,14 +73,13 @@ function pageHome(s) {
</div>
</div>
${cardTrouble(s)}
${cardFailures(s.failures)}
${cardProgress(s.progress)}
${cardSkipped(s.skipped)}
<div class="card">
<h3>總計</h3>
<div class="kv" style="margin-top:10px">
<div><div class="big-num">${s.accounts.length}</div><div class="k">個知識庫</div></div>
<div><div class="big-num">${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}</div><div class="k">個資料夾在看守</div></div>
<div><div class="big-num">${s.extractedOK || 0}</div><div class="k">份已整理</div></div>
</div>
</div>`;
}
@@ -107,28 +106,40 @@ function cardTrouble(s) {
</div>`;
}
// 失敗的檔案:列出**檔名與原因**
// leo 2026-08-06 實測:畫面只寫「⚠ 3 份失敗」,他只能回報「沒推到雲端」,
// 而原因本來就在 status.json 裡 —— 數字不能代替原因。
function cardFailures(f) {
if (!f || !f.items || !f.items.length) return '';
// 🔴 leo 2026-08-06:「**可以顯示沒通過的名稱,旁邊有個三角形,點擊看到失敗細節,
// default 摺疊起來,重點是找到問題在哪裏**」
// ——他要的是先看到「哪些檔沒過」,細節是需要時才展開。
// 先前把原因直接攤在旁邊,害檔名欄被擠成一行一個字(實撞截圖)。
// 用原生 <details>:三角形、鍵盤可操作、預設收合,都不必自己寫
// 的檔案:分母 + 三個分類(t2102026-08-08,取代 08-06 逐檔白話翻譯)
//
// 🔴 leo 08-08 轉述封測者 Evan:「我有 9000 個檔,雲端只有 101 張卡,畫面卻說
// 『20 份沒送進知識庫』——這幾個數字到底是怎麼回事?」病根是首頁每個數字都是
// 本輪的,使用者問的是總量——這張卡改講總量,四個數字(分母+已送上去+排隊中+
// 送不上去)加起來要對得起來,看完的感覺要是「我知道還沒傳,你不要擔心」。
//
// 🔴 leo 08-08:「我不要枚舉每個檔案可能的問題和解法,應該是統計的」「不解釋細節,
// 無法上傳的也摺疊,想看細節才展開」──「送不上去」預設摺疊,展開只有分類與份數,
// 不逐檔列名、不解釋、不給解法;細節去 Docs 說明文件
//
// 🔴 分類判斷只住在後端 collector/progress.go 的 ClassifyFailure 一個接縫——
// 這裡完全不認得任何分類名稱字串,`g.category` 原樣印出、順序照後端給的陣列,
// 不在前端排序或分支判斷(t214 之後分類要改成資料驅動,才只需要動後端那一個檔)。
function cardProgress(p) {
if (!p || !p.total) return '';
return `
<div class="card">
<h3>${esc(f.title)}</h3>
<div class="d">${esc(f.note)}</div>
<div class="faillist">
${f.items.map((it) => `
<details class="fail">
<summary>${esc(it.name)}</summary>
<div class="failwhy">${esc(it.reason)}</div>
</details>`).join('')}
<h3>你的檔案</h3>
<div class="kv" style="margin-top:10px;flex-wrap:wrap">
<div><div class="big-num">${p.total}</div><div class="k">共幾份</div></div>
<div><div class="big-num">${p.done}</div><div class="k">已送上去</div></div>
<div><div class="big-num">${p.pending}</div><div class="k">排隊中</div></div>
<div><div class="big-num">${p.cantSync}</div><div class="k">送不上去</div></div>
</div>
${f.more > 0 ? `<div class="d" style="margin-top:8px">…還有 ${f.more} 份</div>` : ''}
${p.cantSync > 0 ? `
<details class="fail" style="margin-top:14px">
<summary>看看是哪些原因</summary>
<ul class="breaklist">
${(p.groups || []).map((g) => `<li><span>${esc(g.category)}</span><span>${g.count} 份</span></li>`).join('')}
</ul>
<div class="d" style="margin-top:8px">這些會自動重試,你不用重丟;細節與怎麼處理,看說明文件。</div>
<div class="acts"><button class="ghost" data-openurl="https://rag.arcrun.dev/docs/">開啟使用說明</button></div>
</details>` : ''}
</div>`;
}
@@ -263,6 +274,7 @@ function wire() {
on('aiSave', saveAI);
on('uCheck', checkUpdate); on('uDownload', downloadUpdate); on('uApply', applyUpdate);
document.querySelectorAll('[data-portal]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.portal); });
document.querySelectorAll('[data-openurl]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.openurl); });
document.querySelectorAll('[data-addto]').forEach((b) => { b.onclick = () => addFolder(Number(b.dataset.addto)); });
document.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
+13 -9
View File
@@ -221,10 +221,9 @@ input[type=text], input[type=password] {
.step .t { flex: 1; }
.step .m { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }
/* 失敗清單:檔名一行、細節收在三角形裡(leo 2026-08-06
/* 展開收合的細節區塊(<details>,原為 leo 2026-08-06 逐檔失敗卡所寫
「顯示沒通過的名稱,旁邊有個三角形,點擊看到失敗細節,default 摺疊起來」)。
先前重用 .step 的橫向格線,害中文檔名被擠成一行一個字。 */
.faillist { margin-top: 12px; }
t2102026-08-08)沿用同一套三角形樣式,改包「送不上去」的分類統計。 */
.fail { border-top: 1px solid rgba(var(--ink-rgb), .10); }
.fail:first-child { border-top: 0; }
.fail > summary {
@@ -232,14 +231,19 @@ input[type=text], input[type=password] {
padding: 10px 2px;
font-size: 14px;
line-height: 1.5;
word-break: break-word; /* 長檔名要能換行,但不是一字一行 */
word-break: break-word;
list-style: revert; /* 保留原生三角形 */
}
.fail > summary::marker { color: var(--amber); }
.fail[open] > summary { font-weight: 600; }
.failwhy {
padding: 2px 2px 12px 18px;
font-size: 13px;
line-height: 1.7;
color: rgba(var(--ink-rgb), .68);
/* 「送不上去」展開後的分類清單:只有分類名稱與份數兩欄,不逐檔列名
t210 leo 08-08:「不解釋細節……想看細節才展開」——展開後也只到分類為止)。 */
.breaklist { margin: 8px 0 0; padding: 0; list-style: none; }
.breaklist li {
display: flex; justify-content: space-between; gap: 12px;
font-size: 13.5px; padding: 6px 2px;
border-top: 1px solid rgba(var(--ink-rgb), .08);
}
.breaklist li:first-child { border-top: 0; }
.breaklist li span:last-child { color: rgba(var(--ink-rgb), .55); white-space: nowrap; }