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:
+55
-133
@@ -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-06:collector 早就把「哪個檔失敗、為什麼」寫進 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"`
|
||||
// t210(2026-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=首頁「你的檔案」那張卡(t210,2026-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 已經算好的 SyncProgress/FailureBreakdown
|
||||
// 轉成前端要的形狀——只搬資料,不重新判斷任何分類。
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user