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()
|
||||
|
||||
@@ -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>:三角形、鍵盤可操作、預設收合,都不必自己寫。
|
||||
// 你的檔案:分母 + 三個分類(t210,2026-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);
|
||||
|
||||
@@ -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; }
|
||||
t210(2026-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; }
|
||||
|
||||
@@ -578,6 +578,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
skippedOther := 0
|
||||
var skippedOtherNames []string
|
||||
|
||||
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
|
||||
var totalProgress SyncProgress
|
||||
var stuckReasons []string
|
||||
|
||||
accountDetails := map[string]AccountSyncStatus{}
|
||||
for _, acc := range accounts {
|
||||
if acc.CypherURL == "" || acc.Namespace == "" {
|
||||
@@ -627,7 +631,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
|
||||
multi := len(accCfg.Folders()) > 1
|
||||
for _, root := range accCfg.Folders() {
|
||||
r, e, p := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
|
||||
r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
|
||||
totalProgress = totalProgress.Add(rp.Progress)
|
||||
stuckReasons = append(stuckReasons, rp.StuckReasons...)
|
||||
if multi {
|
||||
for i := range r {
|
||||
r[i].Root = root
|
||||
@@ -718,6 +724,23 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if len(st.SkippedDocs) > MaxSkippedListed {
|
||||
st.SkippedDocs = st.SkippedDocs[:MaxSkippedListed] // 總數仍在 SkippedDocCount,UI 說「等 N 個」
|
||||
}
|
||||
|
||||
// t210:Unreadable 由呼叫端補進來(progress.go 的欄位註解)——G-6.2 的「讀不了的檔」
|
||||
// 根本沒進過 manifest,Progress() 算不到它們。併進 Total 才守得住 leo 08-08 驗法①
|
||||
// 「四個數字相加等於總數」(不變式:Total == Done+Pending+Stuck+Unreadable)。
|
||||
st.Progress = totalProgress
|
||||
st.Progress.Unreadable = st.SkippedDocCount
|
||||
st.Progress.Total += st.Progress.Unreadable
|
||||
// 「送不上去」=Stuck+Unreadable 的分類統計——Stuck 那些的真因已在 stuckReasons,
|
||||
// Unreadable 那些**本來就是**格式不支援(G-6.2 掃描白名單擋下的),直接重用
|
||||
// convert.go 的 ErrUnsupported 原文過同一個 ClassifyFailure 入口,不在這裡另造
|
||||
// 分類字串(t214 之後要把分類改成資料驅動,也只需要動 ClassifyFailure 一個接縫)。
|
||||
failReasons := append([]string{}, stuckReasons...)
|
||||
for i := 0; i < st.Progress.Unreadable; i++ {
|
||||
failReasons = append(failReasons, ErrUnsupported.Error())
|
||||
}
|
||||
st.FailureBreakdown = BuildFailureBreakdown(failReasons)
|
||||
|
||||
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
|
||||
if cfg.Extractor != "" {
|
||||
for _, r := range results {
|
||||
@@ -863,24 +886,34 @@ func accountsConnected(cfg *DirectConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// rootProgress=單一資料夾(一份 manifest)算出來的 t210 統計素材。
|
||||
//
|
||||
// 為什麼跟 Progress 綁在一起回傳:兩者都是同一份 Manifest 快照的產物,
|
||||
// 呼叫端(RunDirectOnce)要跨帳號、跨資料夾把它們累加成總量,缺一不可
|
||||
// ——Progress 少了 StuckReasons 就湊不出「送不上去」展開後的分類統計。
|
||||
type rootProgress struct {
|
||||
Progress SyncProgress // 這一根的 Total/Done/Pending/Stuck(Unreadable 由呼叫端補,見 progress.go)
|
||||
StuckReasons []string // 已放棄自動重試那些條目的 LastError 原文,交給 ClassifyFailure 分類
|
||||
}
|
||||
|
||||
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
|
||||
// runNow:整輪 RunDirectOnce 共用的時間點(排序/冷卻判斷一致、好測試)。
|
||||
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload) {
|
||||
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload, rootProgress) {
|
||||
results := []DirectResult{}
|
||||
exit := 0
|
||||
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
}
|
||||
absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot))
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
}
|
||||
cfg.migrateManifestIfNeeded(absRoot, absManifest) // t86b:一次性遷移舊格式帳本
|
||||
m, err := LoadManifest(absManifest, absRoot)
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
}
|
||||
// 2026-08-07 task 3:Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意,
|
||||
// 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。
|
||||
@@ -903,7 +936,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
SkipDirNames: map[string]bool{"system-dev": true},
|
||||
})
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
}
|
||||
|
||||
now := runNow.Unix()
|
||||
@@ -1167,7 +1200,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
exit = 1
|
||||
}
|
||||
}
|
||||
return results, exit, payload
|
||||
|
||||
// t210:manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
|
||||
// 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。
|
||||
rp := rootProgress{Progress: m.Progress()}
|
||||
for _, e := range m.Entries {
|
||||
if e != nil && e.FailCount >= MaxFailBeforeSkip {
|
||||
// LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫,
|
||||
// 這裡不判斷任何識別字,即使 LastError 是空字串也照樣送(ClassifyFailure("") 落「其他」,
|
||||
// 不會漏算份數)。
|
||||
rp.StuckReasons = append(rp.StuckReasons, e.LastError)
|
||||
}
|
||||
}
|
||||
return results, exit, payload, rp
|
||||
}
|
||||
|
||||
// runDirect 是 `collector direct` 子命令主體。
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// progress_wiring_test.go — t210:把 progress.go 的地基(Progress()/ClassifyFailure/
|
||||
// BuildFailureBreakdown,已在 progress_test.go 單獨驗過)跟 RunDirectOnce 的接線也測一遍。
|
||||
//
|
||||
// 驗的是 leo 08-08 驗法①③:
|
||||
//
|
||||
// ① 四個數字(Done/Pending/Stuck/Unreadable)相加要等於 Total——這正是 Evan 封測
|
||||
// 回報「9000/101/20 兜不起來」的病,這裡用一輪同時湊出四種狀態的真實跑法覆現+守住。
|
||||
// ③ 斷網/閒置一輪後數字不歸零:Progress/FailureBreakdown 是**現況快照**(每輪原地
|
||||
// 重算),不是靠 CarryForwardActivity 才不歸零——這裡故意驗一輪「什麼都沒發生」
|
||||
// (已放棄重試的跳過、還在退避窗內的跳過、已完成的沒有事件)之後數字原封不動。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestT210ProgressWiring(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(name, content string) {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 雲端版本探測與畫面無關,stub 掉避免真實網路呼叫拖慢測試(同 direct_multi_test.go 手法)。
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
// Gemini 替身:pageName 是 "stuck" 或 "pending" 的一律萃取失敗(模擬「本地萃取失敗」
|
||||
// 這一種無法同步的成因),其餘("ok")成功萃出一張最簡卡片。用 prompt 裡「# <pageName>」
|
||||
// 那行分辨是哪個檔(gemmaPrompt 的契約:第一行必須是「# <pageName>」)。
|
||||
restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
switch {
|
||||
case strings.Contains(string(body), "「# stuck」"), strings.Contains(string(body), "「# pending」"):
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("上游炸了(測試用)"))
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("ok", "測試")}}},
|
||||
}},
|
||||
})
|
||||
}
|
||||
})
|
||||
defer restoreGemma()
|
||||
|
||||
// 雲端 ingest 端點:卡片成功送達就回 200(stuck/pending 根本不會萃出卡,不會打到這裡)。
|
||||
cypher := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer cypher.Close()
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: manifestPath,
|
||||
CypherURL: cypher.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
// ForceSync:測試用真實時間跑不完 1m→5m→…→6h 的退避階梯,force 讓每輪都真的嘗試,
|
||||
// 只影響「要不要重試」,不影響 FailCount 怎麼記——跟正式情境「使用者按立刻同步」是
|
||||
// 同一條路徑,不是另開後門。
|
||||
ForceSync: true,
|
||||
}
|
||||
|
||||
// 先讓 stuck.md 連續失敗到 MaxFailBeforeSkip(8)次,成為「已放棄自動重試」。
|
||||
write("stuck.md", "# 原稿 stuck 內容")
|
||||
for i := 0; i < MaxFailBeforeSkip; i++ {
|
||||
if _, exit, _ := RunDirectOnce(cfg, false); exit == 0 {
|
||||
t.Fatalf("第 %d 輪應該失敗(exit!=0),卻是 0", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一輪裡湊齊四種狀態:ok.md 會成功(Done)、pending.md 只失敗這一輪
|
||||
// (Pending,FailCount=1 < 8)、skip.doc 是不支援的格式(Unreadable,根本沒進
|
||||
// manifest)、stuck.md 維持已放棄重試(Stuck,force 讓它再撞一次牆,FailCount 繼續
|
||||
// 往上加但不影響「已達上限」這個判斷)。
|
||||
write("ok.md", "# 原稿 ok 內容")
|
||||
write("pending.md", "# 原稿 pending 內容")
|
||||
write("skip.doc", "舊版 Word,還不支援")
|
||||
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit == 0 {
|
||||
t.Fatalf("這輪 stuck/pending 都會失敗,exit 不該是 0:results=%+v", results)
|
||||
}
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(manifestPath))
|
||||
if err != nil {
|
||||
t.Fatalf("讀 status.json 失敗:%v", err)
|
||||
}
|
||||
|
||||
// ── 驗法①:四個數字相加等於總數(Evan 封測的病:9000/101/20 兜不起來)──
|
||||
p := st.Progress
|
||||
t.Logf("round1 Progress=%+v FailureBreakdown=%+v", p, st.FailureBreakdown)
|
||||
if sum := p.Done + p.Pending + p.Stuck + p.Unreadable; sum != p.Total {
|
||||
t.Fatalf("🔴 數字對不起來:%d+%d+%d+%d=%d ≠ 總數 %d\n完整 Progress=%+v",
|
||||
p.Done, p.Pending, p.Stuck, p.Unreadable, sum, p.Total, p)
|
||||
}
|
||||
if p.Done != 1 {
|
||||
t.Errorf("Done 應為 1(ok.md),got %d", p.Done)
|
||||
}
|
||||
if p.Pending != 1 {
|
||||
t.Errorf("Pending 應為 1(pending.md,FailCount=1 < %d),got %d", MaxFailBeforeSkip, p.Pending)
|
||||
}
|
||||
if p.Stuck < 1 {
|
||||
t.Errorf("Stuck 應至少 1(stuck.md 已達 %d 次失敗上限),got %d", MaxFailBeforeSkip, p.Stuck)
|
||||
}
|
||||
if p.Unreadable != 1 {
|
||||
t.Errorf("Unreadable 應為 1(skip.doc,G-6.2 白名單擋下、根本沒進 manifest),got %d", p.Unreadable)
|
||||
}
|
||||
if p.Total != 4 {
|
||||
t.Errorf("Total 應為 4(ok+pending+stuck+skip),got %d(%+v)", p.Total, p)
|
||||
}
|
||||
|
||||
// FailureBreakdown 的分組加總要等於「送不上去」(Stuck+Unreadable),
|
||||
// 且只有分類與份數——不含檔名、不含解法(呼叫端/前端都不該認得分類名稱,
|
||||
// 這裡只是照 collector 已經分好的結果核對總量,不重新判斷)。
|
||||
wantCantSync := p.Stuck + p.Unreadable
|
||||
if st.FailureBreakdown.Total != wantCantSync {
|
||||
t.Fatalf("FailureBreakdown.Total=%d,應等於 Stuck+Unreadable=%d", st.FailureBreakdown.Total, wantCantSync)
|
||||
}
|
||||
groupSum := 0
|
||||
for _, g := range st.FailureBreakdown.Groups {
|
||||
groupSum += g.Count
|
||||
}
|
||||
if groupSum != wantCantSync {
|
||||
t.Fatalf("FailureBreakdown.Groups 加總=%d,應等於 %d:%+v", groupSum, wantCantSync, st.FailureBreakdown.Groups)
|
||||
}
|
||||
// Unreadable 那一份必然分類成「格式不支援」(G-6.2 白名單擋下的定義就是這樣)。
|
||||
foundUnsupported := false
|
||||
for _, g := range st.FailureBreakdown.Groups {
|
||||
if g.Category == FailUnsupportedFormat {
|
||||
foundUnsupported = true
|
||||
}
|
||||
}
|
||||
if !foundUnsupported {
|
||||
t.Errorf("skip.doc 應該被分類進「%s」,實際分組:%+v", FailUnsupportedFormat, st.FailureBreakdown.Groups)
|
||||
}
|
||||
|
||||
// ── 驗法③:斷網/閒置一輪後數字不歸零(現況快照,不是本輪計數)──
|
||||
// 這輪關掉 ForceSync:stuck.md 已達上限、pending.md 還在退避窗內、ok.md 內容沒變,
|
||||
// 全部不會真的觸發任何萃取/上傳動作——模擬「斷網一輪」或「什麼都沒發生的一輪」。
|
||||
// exit 值不重要(上一輪的失敗紀錄可能還在別的欄位),重點是 Progress 有沒有被清空。
|
||||
cfg.ForceSync = false
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st2, err := LoadSyncStatus(StatusFilePath(manifestPath))
|
||||
if err != nil {
|
||||
t.Fatalf("讀第二次 status.json 失敗:%v", err)
|
||||
}
|
||||
t.Logf("round2(idle) Progress=%+v FailureBreakdown=%+v", st2.Progress, st2.FailureBreakdown)
|
||||
if st2.Progress != p {
|
||||
t.Fatalf("🔴 閒置一輪後數字變了(不該歸零/不該亂動):before=%+v after=%+v", p, st2.Progress)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,22 @@ type SyncStatus struct {
|
||||
// leo 08-06 封測者放了 .md 說「無法通過」,畫面只有「有 1 個不是文件的檔案」,
|
||||
// 沒人判斷得出那到底是什麼檔。
|
||||
SkippedOtherNames []string `json:"skipped_other_names,omitempty"`
|
||||
|
||||
// ── t210 統計層(2026-08-08,Evan 封測:「9000 個檔,雲端只有 101 張卡,
|
||||
// 畫面卻說 20 份沒送——這幾個數字到底是怎麼回事?」)──────────────────────
|
||||
//
|
||||
// Progress=總量進度快照(見 progress.go 的 SyncProgress/(*Manifest).Progress())。
|
||||
// 由 RunDirectOnce 跨帳號、跨資料夾 Add() 累加,並把 G-6.2 的 SkippedDocCount
|
||||
// 併進 Unreadable/Total(Progress() 算不到「根本沒進 manifest」的檔)。
|
||||
//
|
||||
// FailureBreakdown=「送不上去」(Stuck+Unreadable)展開後的分類統計——
|
||||
// 只有分類與份數,沒有檔名、沒有解法(取代 08-06 那套逐檔 humanizeFailure)。
|
||||
//
|
||||
// ⚠️ 與 SkippedDocCount 同類,**都不進 CarryForwardActivity**:manifest 每輪
|
||||
// 重建、涵蓋現況所有檔,這裡原地算出來就是對的——也因此斷網/閒置一輪後
|
||||
// 不會被清成 0(現況快照,不是本輪計數)。
|
||||
Progress SyncProgress `json:"progress"`
|
||||
FailureBreakdown FailureBreakdown `json:"failure_breakdown"`
|
||||
}
|
||||
|
||||
// MaxSkippedListed:status.json 裡最多逐檔列幾個。
|
||||
|
||||
Reference in New Issue
Block a user