diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fc525..202ff18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,17 @@ --- +## 0.18.48(2026-08-28) + +- 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。 +- **打勾是真的**:以前只要沒有錯誤訊息就顯示「自動同步中」,連空資料夾、連小幫手還沒回報的資料夾都一樣。現在那個記號直接對應「這個資料夾裡幾份進去了/總共幾份」,數字對不上就不會給你打勾。 +- **一次連線失敗不再讓知識庫版本消失**:以前只要有一輪沒問到,畫面就說「目前連不上這個知識庫,查不到版本」——即使它下一秒就通了。現在問不到只影響「有沒有新版可更新」,你原本的版本號還在。 + + +## 0.18.47(2026-08-28) + +- 這一版你不會看到任何不一樣:收檔、整理、同步、畫面全部跟上一版相同,不必特地更新。改的是我們自己出貨前的自我檢查。以前每次重新編譯程式,就會多出兩顆二十幾 MB 的編譯結果混進原始碼裡,害那道檢查誤判成「程式又被改過了」而擋下出貨——實際上一行程式都沒動。現在編譯出來的東西不再混進去,那道檢查量到的就真的只有程式本身。 + ## 0.18.46(2026-08-28) - 修好上一版帶進來的問題:雲端只是回得慢,卻被講成「沒有回應」,還害後面的檔被跳過。上一版我替每一種請求設了時間上限,其中「送出一份筆記」給了 60 秒——但實際量出來它本來就要 33 到 57 秒。結果尾巴被剪掉,兩份檔判成失敗,接著整個帳號被判定沒回應,**後面 9 份檔一份都沒送**。這一版把上限依實測值放寬(送知識的請求 5 分鐘、只登記結構的 3 分鐘),並且只有在**連續**等不到回覆時才會暫停該帳號——中間只要成功過一次就重新計算。 diff --git a/cmd/arcrun-app/.gitignore b/cmd/arcrun-app/.gitignore index 4a24edc..697186d 100644 --- a/cmd/arcrun-app/.gitignore +++ b/cmd/arcrun-app/.gitignore @@ -16,3 +16,17 @@ dist-msix/ # Python 匯入快取(daemon-version.py 被 import 時產生)。可重現的產物,且綁 Python 版本 # (檔名帶 cpython-314)⇒ 不進版控。2026-08-18 D95 第三輪:第一輪搬檔時誤把一顆帶了進來。 __pycache__/ + +# 🔴 `go build ./...` 在本目錄留下的執行檔(inkstone/arcrun-rag#158,2026-08-28)。 +# Go 對「單一 main 套件」會把成品寫成**目錄名**,所以任何人在這裡跑一次 go build +# 就會生出 `arcrun-app` 這顆 20 MB 的二進位。它一路被追蹤到 2026-08-28 為止。 +# +# 為什麼非拿掉不可(實測,不是推論):這顆檔案**在版本指紋的涵蓋範圍內** +# (`daemon-version.py` 的 `scan_source()` 走 `git ls-files -co --exclude-standard`) +# ⇒ 只是重編一次、一行原始碼都沒改,指紋就從 1325784830f592ff 變成 eba122f83317b67d +# ⇒ 出貨線的 `daemon-source-check` 判「0.18.46 打包之後,1 個檔案變動過」而擋下。 +# **把編譯產物算進「原始碼指紋」,那個指紋就量不到它宣稱要量的東西。** +# +# ⚠️ 錨在本目錄(開頭的 `/`):不要寫成 `arcrun-app`,那會連子目錄裡同名的東西一起蓋掉。 +# 打包腳本要的產物在 `build/bin/`(早就 ignore),不是這一顆——三支腳本都沒引用它。 +/arcrun-app diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index d7379cd..be34a4f 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.46", - "fingerprint": "1325784830f592ff", + "version": "0.18.48", + "fingerprint": "a440a58a629f960c", "files": { - ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "0ccd77301d2cc432a93699d90f45d1c012020cc447b0cda6db79655bc928822e", + ".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38", + "CHANGELOG.md": "51b85dc1e96ce190a8652df8a696bd664229b1c5bcb6cf695ced905dc5956d1b", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8", @@ -19,12 +19,11 @@ "cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa", "cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9", "cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6", - "cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8", + "cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37", "cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9", - "cmd/arcrun-app/app.go": "274129ed77c4f4194f6b2007cb47e4ffe536b15b6450fe89b14fbb31e6a16395", + "cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698", "cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96", "cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c", - "cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79", "cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2", "cmd/arcrun-app/assets/store/Square150x150Logo.png": "27942d943096ba23f036b030726e905f6df6123428cd7d8ec687ce5b11e42db1", "cmd/arcrun-app/assets/store/Square310x310Logo.png": "f610d521767347974fcd68006055d06c5366e99c247d31a2b9221bae34d67798", @@ -72,10 +71,12 @@ "cmd/arcrun-app/diagnostics_export.go": "19328a384d653a24c557f5ee7497d62f1c1eaeba37976819b07e44f002b8862b", "cmd/arcrun-app/diagnostics_export_test.go": "00fbd5a436a82652d28f9974d29dcbaf57a432ec0df05bf491e6f9755bbff00b", "cmd/arcrun-app/diagnostics_stage_manual_test.go": "e1c9fe6b8d6d6e39c74efec5c8874bf4a7922cf5b03789e49c40ef24231d1f00", - "cmd/arcrun-app/dist/Arcrun-v0.18.3.dmg": "bfe9282ca591b62390e53e439a9f97ed71e6c7c7ece2dd61e10b75e77b0ae991", "cmd/arcrun-app/dock_darwin.go": "25f07f57eea6dbacdc4eaf652ad56b83b4641ec6392f6a4a4943b67f3f24156a", "cmd/arcrun-app/dock_other.go": "b5f7bb020ec20c9ed27436b5be15aa16798a180d1bf200b4158c5bdbfe3e6be7", "cmd/arcrun-app/firstrun_test.go": "90d74db3e6a7b77617d50559a181229c0e720c20ead35b9554cb72e5212c7303", + "cmd/arcrun-app/folder_badge.go": "ee2b3a62fc7424816268267e573be5b59174d1fcc3a492dded6b9b220506b1a5", + "cmd/arcrun-app/folder_badge_fixture_test.go": "38c74b7571f60d24c3d1b337e79f38df153b589f3cd911cfbaf8369e4759ba18", + "cmd/arcrun-app/folder_badge_test.go": "61377b6cbf51be32ee5b4fdc40724afd9a040e1041886c96f364d480bdd02801", "cmd/arcrun-app/freshinstall_e2e_test.go": "59d8c8c75365f7821f838baca9abd93f167bebd0998779a54c5fe33e390e7331", "cmd/arcrun-app/frontend/index.html": "3cbf1ec22539e0e0ad7e8e86b1857cf752e042ecfd52e816f7d2bdcd7cf99a91", "cmd/arcrun-app/frontend/package-lock.json": "a4020582cf3fb5f2413451d5d5498a54ceadae153d28e3f59f81f6a7b2c97caa", @@ -90,8 +91,8 @@ "cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c", "cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55", "cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494", - "cmd/arcrun-app/frontend/src/main.js": "b0d9e4980cdf46a379888542ee266cfd68af7d4cadcb74420ef7db3a26ffffcb", - "cmd/arcrun-app/frontend/src/style.css": "4fb87b139100b826c02c71a69599da2c4e8b93d3f7855c2a9b10e6677fecfbb2", + "cmd/arcrun-app/frontend/src/main.js": "327877a5bb79b79589e09b902e7002e33d275a413451ff90f230bb2e92418b52", + "cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a", "cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c", "cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f", "cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256", @@ -113,7 +114,6 @@ "cmd/arcrun-app/upgrade_repair_test.go": "65413d0b07b6c011de2af2783247d0f602a680a199229b52a77e176626c8ef26", "cmd/arcrun-app/wails.json": "a6448229972e3194026f6a407a427a5601ce6f5deb43d1fb7c5b5226512d8bc5", "cmd/collector/main.go": "fd04291f6cd4833f00593a0580baeaa231875da6d26636fca4f51090f0a80c80", - "collector": "ef769ff086a4da691b4bd646de4efb6957204efa829545e43cbcb99bf9503b70", "convert.go": "fb3b52354b94f527ccffa5600620749a5fa6009834530aac04b7d141bb3e3b4b", "convert_docx.go": "5d4f459ff866c86ee78424503e1c9a410e1dbfd0ece40d63a603b94b61b7ddaf", "convert_pdf.go": "e4955008f9bf9e458304fa0baa087ae7a37ac52e9fb66df3e99f22a4c513b9b1", @@ -124,7 +124,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", - "direct.go": "1ccb56abf877f75a61c47cbc9d5797739f58dbc9f321de4f29d9022320f2e41e", + "direct.go": "ba19086aa177884405c038a8841cd5b6b2ef2bf2043d18a708ff7e6c6c9ca45f", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -139,6 +139,7 @@ "extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76", "extract_workersai.go": "8300691e1a41df4096d316686efbd0016e8df4d482be55b8ce4388b53bd9e1c5", "extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3", + "folder_progress_wiring_test.go": "c5a4575395f1cee5d1c0ee58728a034113534a3f0d456e4e0d23563da840d314", "folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089", "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", @@ -172,7 +173,7 @@ "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", "probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef", "probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980", - "progress.go": "c2c86ecfea5a8227eb40018c55d48f26c4f2de1b8775fde9acd231cba8b7bb37", + "progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a", "progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb", "progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd", "quota.go": "034a2a682b66606e9ab531a251e5157109c9a66ddca6e3ffcecc8fcd10dc0aa3", @@ -196,7 +197,7 @@ "supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3", "supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e", "supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7", - "sync_status.go": "a4c2b10de55b0a01e2c723946812c7eaa59a6ff0c0d2d3c8512b37935f4e205e", + "sync_status.go": "799d3481c361647c9cf1ae40ac4c550d6690a87f843c514f8b830d811e7bba1f", "sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2", "t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51", "template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 6e98a47..5e25663 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -16,5 +16,7 @@ "0.18.43": "34f17e1e6a190eaa", "0.18.44": "19e26497ae609682", "0.18.45": "dc05513103f93306", - "0.18.46": "1325784830f592ff" + "0.18.46": "1325784830f592ff", + "0.18.47": "0decb0810da46a3f", + "0.18.48": "a440a58a629f960c" } diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 6c576b3..1c192d5 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -122,6 +122,10 @@ type syncStatus struct { // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。 // 同上:形狀定義在 collector/sync_status.go,這裡原樣接住。 Resync map[string]collector.ResyncStatus `json:"resync,omitempty"` + // arcrun-rag#159:逐個看守資料夾的同步現況(key=資料夾路徑)。 + // 同上:形狀定義在 collector/progress.go/sync_status.go,這裡原樣接住。 + // 這是資料夾那一列打不打勾的**唯一**依據(見 folder_badge.go)。 + FolderProgress map[string]collector.SyncProgress `json:"folder_progress,omitempty"` } type skippedDoc struct { @@ -247,7 +251,20 @@ type UIFolder struct { // arcrun-rag#140:雲端上找不到先前送過的檔案(知識庫被重裝/清空過),正在自動補送。 // 🔴 這張票的病有一半是「**沒有任何地方會說話**」——檔案在資料夾裡、AI 卻查不到, // 而且查不出為什麼。所以補送不能靜悄悄地跑,這一行就是那句話的落點。 - ResyncNote string `json:"resyncNote,omitempty"` // 一句人話(沒事=空字串,前端不畫) + // + // ⚠️ 2026-08-28(arcrun-rag#159)**這一行不再畫在畫面上**——leo:「補送中是什麼 + // 意思?不要發明奇怪狀態」。同步狀態改由下面 Sync/SyncTip 那一格的圖示表達, + // 而它算的是真的份數(見 folder_badge.go)。這個欄位留著給診斷檔與 status.json, + // #140 的「不能靜悄悄地重送」由圖示接手:補送中的檔在 manifest 裡就是還沒送成功, + // 圖示本來就不會打勾。 + ResyncNote string `json:"resyncNote,omitempty"` + + // arcrun-rag#159:這一列的同步狀態圖示。Sync 是機器代碼(ok/working/trouble/ + // unknown),SyncTip 是滑過去看得到的一句短話。判準與文案都在 folder_badge.go + // ——**寫在 Go 這一側**,因為「打勾是不是真的」要能被測試守著, + // 而前端只負責把代碼換成一個圖示。 + Sync string `json:"sync"` + SyncTip string `json:"syncTip"` } type UIAccount struct { Name string `json:"name"` @@ -257,7 +274,11 @@ type UIAccount struct { // 連結」)——一個使用者可能連著不只一個知識庫,之前只有小幫手自己的版本會提示更新, // 每個知識庫各自的雲端版本完全沒有畫面。判準與 portal 版本卡同一套 // (collector.EvalCloudUpdate,不是 t103 的相容底線),這裡只翻成人話,不重新判斷。 - CloudVerKnown bool `json:"cloudVerKnown"` // false=查不到(連不上/還沒查過),前端要老實說「查不到」 + CloudVerKnown bool `json:"cloudVerKnown"` // false=**從來沒查到過**這台的版本(不是「這一輪連不上」,見 direct.go #159) + // arcrun-rag#159:這一輪 /health 通不通。與 CloudVerKnown 是兩件事—— + // 版本是事實(查到過就一直知道),可達性是當下狀態(會抽風)。 + // 前端拿它把版本號調淡並在 tooltip 說明,**不拿它把版本抹掉**。 + CloudVerFresh bool `json:"cloudVerFresh"` CloudVerStale bool `json:"cloudVerStale"` // true=有新版可更新 CloudVerMine string `json:"cloudVerMine,omitempty"` // 這個知識庫目前的版本(可能連 Known=false 時也有值) CloudVerLatest string `json:"cloudVerLatest,omitempty"` // 已知的最新版 @@ -518,6 +539,10 @@ func (a *App) GetState() UIState { if rs, ok := sync.Resync[f]; ok { uf.ResyncNote = rs.Note } + // #159:這一列打不打勾。key 與 collector 寫入時同一把(資料夾絕對路徑)。 + // 查不到=collector 還沒回報過這個資料夾 ⇒ folderBadge 回 unknown,不猜。 + fp, known := sync.FolderProgress[f] + uf.Sync, uf.SyncTip = folderBadge(fp, known) ui.Folders = append(ui.Folders, uf) } // 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失, @@ -533,6 +558,7 @@ func (a *App) GetState() UIState { // t215:per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost), // 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。 if accSt, ok := sync.AccountDetails[ui.Host]; ok { + ui.CloudVerFresh = accSt.CloudCheckOK ui.CloudVerKnown = accSt.CloudUpdateKnown ui.CloudVerStale = accSt.CloudUpdateStale ui.CloudVerMine = accSt.CloudVersion diff --git a/cmd/arcrun-app/folder_badge.go b/cmd/arcrun-app/folder_badge.go new file mode 100644 index 0000000..d3288e8 --- /dev/null +++ b/cmd/arcrun-app/folder_badge.go @@ -0,0 +1,82 @@ +// folder_badge.go — 資料夾那一列的**同步狀態圖示**(`inkstone/arcrun-rag#159`)。 +// +// leo 2026-08-28 原話: +// +// 「補送中是什麼意思?**不要發明奇怪狀態**⋯⋯他要知道的是『**我的資料夾是否同步了**』, +// **有同步打勾就好**」 +// 「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」 +// +// ── 這支檔存在的理由 ───────────────────────────────────────────────────── +// 在這之前,那一列的標籤是這樣算的(frontend/src/main.js:545): +// +// ${f.resyncNote ? '補送中' : '自動同步中'} +// +// 也就是說它量的**不是同步狀態**,是「#140 那句補送說明是不是空字串」。 +// 於是 2026-08-28 leo 的畫面上七列有六列寫「補送中」——連 `youlinhsieh-test1` +// (實測 `pending=0`、`repaired=2`,補送早就做完了)也照樣標補送中。 +// ⇒ **標籤跟事實脫鉤**,而不是措辭不好聽。 +// +// 現在改成從 `collector.SyncProgress` 算——那是 `(*Manifest).Progress()` 的原件, +// 與首頁大數字、診斷檔用的是同一個函式。**畫面上打的勾,跟首頁的數字同源。** +// +// ── 三種狀態,沒有第四種 ───────────────────────────────────────────────── +// +// ✅ ok Done == Total 每一份我認得的檔都送進知識庫了,而且送上去之後沒再改過 +// 🔄 working 還沒送完,但沒有出錯的跡象 +// ⚠️ trouble 已經放棄自動重試(Stuck),或**一份都還沒成功而且每一份都失敗過** +// +// 🔴 第三種的第二個條件是 leo 那句「Geek6688 很久沒碰了也顯示補送中?」逼出來的。 +// 實查他機器上的 manifest(2026-08-28):`geek6688-test1` 的 14 份**每一份的 +// fail_count 都大於 0**(7 與 3),錯誤是雲端回 `HTTP 500 Node post_block failed: +// Too many subrequests`,`done=0`。 +// ⇒ 這不是「排隊中」,是「一直在撞牆」。標成同步中=叫他等一件正在壞掉的事。 +// ⇒ 但**單看「有檔案在重試」不夠**:`pms` 有 110 份待處理、其中只有 4 份在重試、 +// 已經送成功 23 份——那是健康的積壓,標警告就變成新的噪音(實測 9 個資料夾裡 +// 會有 7 個亮警告,等於回到 leo 抱怨的「滿畫面都是狀態」)。 +// **所以第二個條件要求 `Done == 0`:一份都沒成功、而且全都在失敗,才叫撞牆。** +// +// 🔴 剛加進來、還沒開始送的資料夾(Failing == 0)落在 working,不會誤報警告。 +package main + +import ( + "fmt" + + collector "arcrun-rag/collector" +) + +// 資料夾狀態的機器代碼。前端只認這三個字串,圖示與顏色由 CSS 決定 +// ——狀態名不進畫面,畫面上只有一個圖示(GUI 的目的是讓人少讀字)。 +const ( + folderSyncOK = "ok" + folderSyncWorking = "working" + folderSyncTrouble = "trouble" + // folderSyncUnknown=collector 還沒回報過這個資料夾(剛加進來、第一輪還沒跑完)。 + // **不能落到 ok**——那會是「還沒查就打勾」,正是這張票的紅線。 + folderSyncUnknown = "unknown" +) + +// folderBadge 把一個資料夾的同步現況翻成「一個狀態代碼+一句短提示」。 +// +// known=status.json 裡有沒有這個資料夾的紀錄。沒有就是 unknown,不猜。 +// +// 提示句刻意短:它住在 tooltip 裡,而 leo 的紅線是「**不要把長句子搬進 tooltip +// 裡繼續長**」。要看細節的人有診斷檔(疑難排解那張卡),不是靠這一行。 +func folderBadge(p collector.SyncProgress, known bool) (state, tip string) { + if !known { + return folderSyncUnknown, "還在確認這個資料夾" + } + switch { + case p.Total == 0: + // 資料夾是空的、或裡面沒有我讀得了的檔。**不是「同步好了」**, + // 所以不打勾——打勾要對應「東西真的在知識庫裡」。 + return folderSyncUnknown, "還沒有可整理的檔案" + case p.Done == p.Total: + return folderSyncOK, fmt.Sprintf("已同步 · %d 份", p.Done) + case p.Stuck > 0: + return folderSyncTrouble, fmt.Sprintf("%d 份一直送不上去", p.Stuck) + case p.Done == 0 && p.Failing > 0: + return folderSyncTrouble, fmt.Sprintf("%d 份都還沒送成功", p.Failing) + default: + return folderSyncWorking, fmt.Sprintf("同步中 · 還有 %d 份", p.Total-p.Done) + } +} diff --git a/cmd/arcrun-app/folder_badge_fixture_test.go b/cmd/arcrun-app/folder_badge_fixture_test.go new file mode 100644 index 0000000..72497c2 --- /dev/null +++ b/cmd/arcrun-app/folder_badge_fixture_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + collector "arcrun-rag/collector" +) + +// TestDumpBadgeFixture 拿**這台機器上真的 status.json**跑一次 folderBadge, +// 把結果印出來/落成畫面驗收要用的 fixture(`inkstone/arcrun-rag#159`)。 +// +// 🔴 為什麼不用手寫 fixture:驗收要貼的是「改後的實際畫面」,而畫面上的圖示 +// 必須是**真的那個函式**算出來的。手抄一份 fixture 就是第二套實作, +// 貼出來的截圖也就不再是證據。 +// +// 預設跳過(要真機資料才有意義):`DUMP_BADGE_FIXTURE=1 go test -run TestDumpBadgeFixture` +func TestDumpBadgeFixture(t *testing.T) { + if os.Getenv("DUMP_BADGE_FIXTURE") == "" { + t.Skip("設 DUMP_BADGE_FIXTURE=1 才跑(要讀這台機器真的 status.json)") + } + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".arcrun-rag") + + var st struct { + FolderProgress map[string]collector.SyncProgress `json:"folder_progress"` + Resync map[string]collector.ResyncStatus `json:"resync"` + } + b, err := os.ReadFile(filepath.Join(dir, "status.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &st); err != nil { + t.Fatal(err) + } + + // status.json 還沒有 folder_progress(那台跑的是舊版)⇒ 從 manifest 現算, + // 用的仍是 (*Manifest).Progress() 本尊,不是另一套數法。 + if len(st.FolderProgress) == 0 { + st.FolderProgress = map[string]collector.SyncProgress{} + ms, _ := filepath.Glob(filepath.Join(dir, "manifest-official-*.json")) + for _, mp := range ms { + m, err := collector.LoadManifest(mp, "") + if err != nil || m == nil || m.Root == "" { + continue + } + st.FolderProgress[m.Root] = m.Progress() + } + } + + roots := make([]string, 0, len(st.FolderProgress)) + for r := range st.FolderProgress { + roots = append(roots, r) + } + sort.Strings(roots) + out := []map[string]any{} + for _, r := range roots { + p := st.FolderProgress[r] + state, tip := folderBadge(p, true) + fmt.Printf("%-56s total=%-5d done=%-5d pending=%-5d stuck=%-3d failing=%-4d → %-8s %s\n", + filepath.Base(r), p.Total, p.Done, p.Pending, p.Stuck, p.Failing, state, tip) + out = append(out, map[string]any{ + "path": r, "sync": state, "syncTip": tip, + "resyncNote": st.Resync[r].Note, + }) + } + if dest := os.Getenv("BADGE_FIXTURE_OUT"); dest != "" { + j, _ := json.MarshalIndent(out, "", " ") + if err := os.WriteFile(dest, j, 0o644); err != nil { + t.Fatal(err) + } + t.Logf("fixture 寫到 %s", dest) + } +} diff --git a/cmd/arcrun-app/folder_badge_test.go b/cmd/arcrun-app/folder_badge_test.go new file mode 100644 index 0000000..74a61c4 --- /dev/null +++ b/cmd/arcrun-app/folder_badge_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "strings" + "testing" + + collector "arcrun-rag/collector" +) + +// TestFolderBadge 守住「打勾是真的」這條紅線(`inkstone/arcrun-rag#159`)。 +// +// leo 的紅線原文:「打勾要是**真的**:說得出『打勾』的判準是什麼, +// **不能是『沒有錯誤就打勾』**」。 +// ⇒ 下面每一格都在問同一件事:**這個狀態對應得上 manifest 裡的哪個事實?** +func TestFolderBadge(t *testing.T) { + cases := []struct { + name string + p collector.SyncProgress + known bool + want string + }{ + { + // 打勾的唯一判準:認得的檔**每一份**都送上去了,而且送上去之後沒再改過 + // (Done 的定義見 progress.go:IngestedHash != "" && == ContentHash)。 + name: "全部送完才打勾", + p: collector.SyncProgress{Total: 19, Done: 19}, + known: true, + want: folderSyncOK, + }, + { + name: "差一份就不准打勾", + p: collector.SyncProgress{Total: 19, Done: 18, Pending: 1}, + known: true, + want: folderSyncWorking, + }, + { + // 🔴 「沒有錯誤就打勾」的反例:沒有任何失敗,但也還沒送完。 + name: "零錯誤但沒送完=同步中,不是打勾", + p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110}, + known: true, + want: folderSyncWorking, + }, + { + // leo 2026-08-28 桌面實測值:geek6688-test1(14 份全在重試、一份都沒成功, + // 雲端回 HTTP 500 Too many subrequests)。這是「不再標成進行中」的那一格。 + name: "一份都沒成功而且全都在重試=警告", + p: collector.SyncProgress{Total: 14, Done: 0, Pending: 14, Failing: 14}, + known: true, + want: folderSyncTrouble, + }, + { + // 對照組:健康的積壓不准亮警告,不然畫面又變回「滿滿都是狀態」。 + // 實測值取自 pms(110 待處理裡只有 4 份在重試,已經成功 23 份)。 + name: "有成功過的積壓=同步中,不亮警告", + p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110, Failing: 4}, + known: true, + want: folderSyncWorking, + }, + { + // 剛加進來、還沒開始送:一份都沒成功,但也還沒失敗過 ⇒ 不准嚇人。 + name: "剛加進來還沒送=同步中", + p: collector.SyncProgress{Total: 20, Done: 0, Pending: 20}, + known: true, + want: folderSyncWorking, + }, + { + // 已放棄自動重試=**不會自己好**,就算大多數都送成功了也要講。 + name: "有放棄重試的就亮警告", + p: collector.SyncProgress{Total: 4184, Done: 817, Pending: 3342, Stuck: 25, Failing: 95}, + known: true, + want: folderSyncTrouble, + }, + { + // 空資料夾不准打勾——打勾要對應「東西真的在知識庫裡」。 + name: "沒有可整理的檔案不打勾", + p: collector.SyncProgress{}, + known: true, + want: folderSyncUnknown, + }, + { + // 🔴 collector 還沒回報 ⇒ 零值。舊寫法會讓它看起來「什麼問題都沒有」, + // 在這裡它必須是 unknown,不能落到 ok。 + name: "還沒回報過不准打勾", + p: collector.SyncProgress{}, + known: false, + want: folderSyncUnknown, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, tip := folderBadge(c.p, c.known) + if got != c.want { + t.Fatalf("folderBadge(%+v, known=%v) = %q,預期 %q", c.p, c.known, got, c.want) + } + if strings.TrimSpace(tip) == "" { + t.Fatalf("每個狀態都要講得出自己是什麼(leo 驗收條件 3),但 tip 是空的") + } + // 紅線:提示句是**產品文案**,不准出現狀態碼/技術詞;也不准長成一段說明 + // (leo:「不要把長句子搬進 tooltip 裡繼續長」)。 + for _, bad := range []string{"HTTP", "500", "error", "token", "manifest", "pending", "stuck"} { + if strings.Contains(strings.ToLower(tip), strings.ToLower(bad)) { + t.Fatalf("提示句出現技術詞 %q:%q", bad, tip) + } + } + if n := len([]rune(tip)); n > 20 { + t.Fatalf("提示句 %d 字,太長了(上限 20):%q", n, tip) + } + }) + } +} diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index 8a172d1..f1596bb 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -166,25 +166,39 @@ function cardKbVersions(s) { `; } -// kbVersionLine:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯, -// 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。 +// kbVersionLine:單一知識庫的版本,首頁卡與各庫頁共用同一份(不讓兩處各寫各的)。 // -// 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」): -// ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法) -// ② Known 且已最新 → 淡字「已是最新版」 -// ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版 +// 🔴 `inkstone/arcrun-rag#159`(leo 2026-08-28):「**GUI 是國際通用,圖形指示, +// 不限語文**,所以任何出現文字都要謹慎,文字表示『我的設計不夠好所以要靠文字來補』」 +// ⇒ 這一行以前是三句話(「有新版可更新(目前 x → 最新 y)」「已是最新版(x)」 +// 「目前連不上這個知識庫,查不到版本」)。現在只剩**版本號與圖示**: +// +// 1.4.60 ✅ 已是最新 +// 1.4.46 → 1.4.58 ⬆️ 有新版(按鈕,按下去帶 email 開安裝頁) +// ○ 從來沒查到過這台的版本 +// +// 說明都收進 title(滑過去才出現),版面上一個字都不佔。 +// +// 🔴 「這一輪連不上」**不再抹掉版本號**(見 collector/direct.go 那段註解): +// 版本查到過就是事實,抽風的是網路。連不上時版本號調淡,滑過去講得出來。 function kbVersionLine(a) { + const dim = a.cloudVerFresh ? '' : ' dim'; + const tip = a.cloudVerFresh ? '' : '上次查到的版本(現在暫時連不上)'; if (!a.cloudVerKnown) { - const detail = a.cloudVerMine - ? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)` - : '目前連不上這個知識庫,查不到版本'; - return `${detail}`; + // 分得出「沒查到這台的版本」與「查到了但暫時不知道最新版是多少」—— + // 前者連版本號都沒有,後者有版本號可以顯示。 + if (!a.cloudVerMine) { + return ``; + } + return `${esc(a.cloudVerMine)}`; } if (a.cloudVerStale) { - return `有新版可更新(目前 ${esc(a.cloudVerMine)} → 最新 ${esc(a.cloudVerLatest)}) - `; + return `${esc(a.cloudVerMine)} → ${esc(a.cloudVerLatest)} + `; } - return `已是最新版(${esc(a.cloudVerMine)})`; + return `${esc(a.cloudVerMine)} + `; } // installURLFor:與 portal 版本卡同一個做法——落後才需要按,按下去帶 email 讓安裝頁 @@ -320,7 +334,18 @@ function cardSkipped(k) { // data:path → 樹(undefined=還沒問、null=問過但小幫手還沒回報) // open:path → 這個資料夾的樹展開了沒 // nodes:path → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen) -const treeState = { data: {}, open: {}, nodes: {} }; +// why:path → { 節點路徑: 這一列的「為什麼」被點開了沒 }(arcrun-rag#159) +const treeState = { data: {}, open: {}, nodes: {}, why: {} }; + +// ── 資料夾那一列的狀態圖示(`inkstone/arcrun-rag#159`)──────────────────── +// +// leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好, +// 畫面上都是字壓力很大**」「他要知道的是『我的資料夾是否同步了』,**有同步打勾就好**」 +// +// 🔴 這裡**只做代碼→圖示的對照**,一個判斷都不做。 +// 「該不該打勾」住在 Go 那一側(cmd/arcrun-app/folder_badge.go,有測試守著)—— +// 前端自己判斷就會變成第二套判準,遲早跟後端說的不一樣。 +const SYNC_ICON = { ok: '✅', working: '🔄', trouble: '⚠️', unknown: '○' }; // 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。 function treeBoxId(path) { @@ -390,13 +415,12 @@ function renderFolderTree(path) { // (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。 // 兩者講同一句話,等於拿我們自己編的答案回答使用者。 if (tree === null) { - box.innerHTML = `
同步小幫手還沒掃到這個資料夾的結構。 - 第一次同步跑完之後就會出現——檔案多的話要等久一點,按上面的「立刻同步」可以催它。
`; + box.innerHTML = `
還沒掃到這個資料夾,第一次同步跑完就會出現。
`; return; } const nodes = tree.nodes || []; if (!nodes.length) { - box.innerHTML = `
這個資料夾目前沒有任何內容(指定了就會在這裡,等你放東西進去)。
`; + box.innerHTML = `
這個資料夾目前是空的。
`; return; } const r = rollupTree(nodes); @@ -421,25 +445,37 @@ function renderFolderTree(path) { + `${kids.length ? '' : ''}` + `${kids.length && open ? '📂' : '📁'}` + `${esc(n.name || '(未命名)')}`; - if (n.skipped && n.total_files === 0) { - // 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。 - const why = n.skip_reason || '(小幫手沒說明理由)'; - row += `整個資料夾未收` - + `${esc(why)}`; - } else if (n.skipped) { - // #180:走進去了、數字是真的,只是這一層一個檔都沒收 - // ⇒ **數字照顯示**(他要知道底下有幾個),理由也照講。 - const why = n.skip_reason || '(小幫手沒說明理由)'; - row += `${s.synced} / ${s.total}` - + `${esc(why)}`; - } else { - const full = s.total > 0 && s.synced === s.total; - row += `${s.synced} / ${s.total}`; - const why = gapWhy(s); - if (why) row += `${esc(why)}`; - } - if (kids.length && !open) row += `…還有 ${kids.length} 個子資料夾`; + // ── 一列只有:三角形 + 資料夾名 + `X / Y`(`inkstone/arcrun-rag#159`)── + // + // leo 2026-08-28:「**上下廢話刪除**,**產生 GUI 就是要讓人減少讀字降低負擔, + // 你在 GUI 寫這麼多字剛好違背它的原理**」——他的截圖上同一句 + // 「這是安裝範本時鋪下來的空白樣板…」**在一棵樹裡出現了 5 次**。 + // + // 🔴 **「收起來」不等於「刪掉」**(本票紅線):那些理由是使用者要救回被跳過的 + // 資料夾時的線索(`inkstone/arcrun-rag#136`)。所以理由沒有被拿掉, + // 只是**不再預設佔畫面**——點那個數字就展開這一列自己的理由。 + // 🔴 也**不塞進 tooltip**(同一條紅線:「不要把長句子搬進 tooltip 裡繼續長」), + // tooltip 只有「點一下看原因」這種操作提示。 + let why = n.skipped + ? (n.skip_reason || '(小幫手沒說明理由)') + : gapWhy(s); + // 收檔策略那句話(原本掛在樹的上方,leo 圈掉了)改掛在**根那一列**—— + // 它講的就是這個監看根,點根的數字就看得到,資訊沒有消失。 + if (n.parent === '-' && tree.reason) why = why ? `${tree.reason}(${why})` : tree.reason; + // 整棵沒走進去(#180 之後:skipped 且 total_files 為 0)⇒ 不准顯示 0/0, + // 那會是我們自己編的數字。用「—」表示「這個數字我沒有」。 + const noCount = n.skipped && n.total_files === 0; + const full = !noCount && s.total > 0 && s.synced === s.total; + const shown = noCount ? '—' : `${s.synced} / ${s.total}`; + row += `${shown}`; html += row + ``; + if (why && (treeState.why[path] || {})[n.path]) { + html += `
${esc(why)}
`; + } if (open) kids.forEach(emit); } (r.kids['-'] || []).forEach(emit); @@ -448,17 +484,22 @@ function renderFolderTree(path) { nodes.forEach((n) => { seen[n.path] = true; }); nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); }); + // 🔴 樹的上方與下方**什麼都不留**(`inkstone/arcrun-rag#159`,leo 在截圖上圈了三處): + // ① 上方的「全部展開/全部收合 + 這是一般資料夾或筆記庫…」整條拿掉 + // ② 下方的「數字是『已同步 / 這個資料夾底下總共』…」整段拿掉 + // ③ 下方的補送說明拿掉(狀態改由資料夾那一列的圖示表達) + // leo 原話:「**上下廢話刪除,只留乾淨 Tree**」。 + // + // 收檔策略那句話(tree.reason)沒有消失——它跟每一列的理由走同一條路: + // 點根那一列的數字就看得到(emit() 裡的 hasWhy)。 + // + // **唯一的例外是截斷警告**:它只在樹真的不完整時才出現,而不講就是讓畫面 + // 宣稱「這是全部」——那是說謊,不是廢話。 let foot = ''; if (tree.truncated) { - foot += `
資料夾太多,只顯示前 ${nodes.length} 個(實際有 ${tree.total_nodes} 個)。
`; + foot = `
只顯示前 ${nodes.length} 個資料夾(實際有 ${tree.total_nodes} 個)。
`; } - foot += `
數字是「已同步 / 這個資料夾底下總共」。兩者不相等是正常的—— - 差額是還讀不了的格式、不在收檔範圍的檔案(程式碼等),或還在處理中。
`; - box.innerHTML = `
` - + `` - + `` - + (tree.reason ? `${esc(tree.reason)}` : '') - + `
${html}
${foot}`; + box.innerHTML = `
${html}
${foot}`; wireTree(); } @@ -467,8 +508,13 @@ function renderFolderTree(path) { async function toggleFolderTree(path) { treeState.open[path] = !treeState.open[path]; const box = $(treeBoxId(path)); + // #159:那顆按鈕現在只有一個 CSS 畫的三角形(沒有文字),轉向由 class 決定。 const btn = document.querySelector(`[data-tree="${cssq(path)}"]`); - if (btn) btn.textContent = (treeState.open[path] ? '▾ ' : '▸ ') + '資料夾結構'; + if (btn) { + btn.setAttribute('aria-expanded', String(!!treeState.open[path])); + const row = btn.closest('.folder'); + if (row) row.classList.toggle('open', !!treeState.open[path]); + } if (!box) return; box.style.display = treeState.open[path] ? '' : 'none'; if (!treeState.open[path]) return; @@ -487,16 +533,6 @@ async function toggleFolderTree(path) { // 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。 function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); } -function setAllTreeNodes(path, open) { - const tree = treeState.data[path]; - if (!tree) return; - const st = {}; - (tree.nodes || []).forEach((n) => { st[n.path] = open; }); - if (!open) st[''] = true; // 全部收合時根自己留著,不然整棵消失、看起來像壞了 - treeState.nodes[path] = st; - renderFolderTree(path); -} - function wireTree() { document.querySelectorAll('[data-tnode]').forEach((el) => { const toggle = () => { @@ -510,11 +546,19 @@ function wireTree() { el.onclick = toggle; el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }; }); - document.querySelectorAll('[data-texpand]').forEach((el) => { - el.onclick = () => setAllTreeNodes(el.dataset.texpand, true); - }); - document.querySelectorAll('[data-tcollapse]').forEach((el) => { - el.onclick = () => setAllTreeNodes(el.dataset.tcollapse, false); + // #159:點數字=展開這一列自己的「為什麼」。理由沒有被刪掉,只是不再預設佔畫面。 + // stopPropagation:這個數字常常長在「整列可點=展開子資料夾」的列上, + // 不擋住的話點原因會順便把子資料夾收掉。 + document.querySelectorAll('[data-twhy]').forEach((el) => { + const toggle = (e) => { + e.stopPropagation(); + const root = el.dataset.twroot, np = el.dataset.twhy; + const st = treeState.why[root] || (treeState.why[root] = {}); + st[np] = !st[np]; + renderFolderTree(root); + }; + el.onclick = toggle; + el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } }; }); } @@ -540,14 +584,17 @@ function pageLib(s, idx) { : `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`} ${f.retireError ? `
${esc(f.retireError)}
` : ''}` : ` -
+
+ ${esc(f.path)} - ${f.resyncNote ? '補送中' : '自動同步中'} - - + ${SYNC_ICON[f.sync] || SYNC_ICON.unknown} +
- - ${f.resyncNote ? `
${esc(f.resyncNote)}
` : ''}`).join('') + `).join('') || `
這個知識庫還沒有資料夾
按右上的「加入資料夾」,選一個要自動整理的資料夾。
`}`; } diff --git a/cmd/arcrun-app/frontend/src/style.css b/cmd/arcrun-app/frontend/src/style.css index 4825047..e77086c 100644 --- a/cmd/arcrun-app/frontend/src/style.css +++ b/cmd/arcrun-app/frontend/src/style.css @@ -144,6 +144,41 @@ button.ghost:hover { color: var(--err); background: transparent; } .folder .tag.retiring { color: rgba(var(--ink-rgb), .5); } .folder-note { margin: -3px 0 9px; padding: 0 15px; font-size: 12.5px; } +/* ══ arcrun-rag#159:那一列只剩「三角形 + 路徑 + 兩個圖示」══════════════ + leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好, + 畫面上都是字壓力很大**」「**展開三角形按鈕放資料夾上**」 + ⇒ 三角形移到列首(跟樹裡面的展開鈕同一個造型,使用者只要學一次)。 */ +.folder > .tw { + flex: none; width: 22px; height: 22px; padding: 0; border: none; background: none; + display: inline-flex; align-items: center; justify-content: center; +} +.folder > .tw:hover { background: none; } +/* 三角形用 CSS 畫,不用 ▸▾ 字元——理由同樹裡那顆(見 .ftrow .tw i): + 那兩個字在 Windows 的中文字型裡是全形,大小與位置每台機器都不一樣。 */ +.folder > .tw i { + display: block; width: 0; height: 0; + border-left: 6px solid rgba(var(--ink-rgb), .5); + border-top: 5px solid transparent; border-bottom: 5px solid transparent; + transition: transform .12s; +} +.folder.open > .tw i { transform: rotate(90deg); } +.folder > .tw:hover i { border-left-color: var(--amber); } +/* 狀態圖示。**一個字都沒有**——說明住在 title/aria-label 裡,滑過去才出現。 */ +.folder .fstat { flex: none; font-size: 14px; line-height: 1; cursor: default; } +/* 🔴 視覺層級要對得上意思:**只有需要你出手的那一格才准大聲**。 + 「同步中」是常態、不需要任何人做什麼 ⇒ 壓到跟灰階一樣安靜; + ⚠️ 維持原色,因為它是唯一一個「你可能要看一下」的狀態。 + (不壓的話 🔄 的藍色會比 ⚠️ 還跳,畫面就在對使用者喊錯的東西。) */ +.folder .fstat.working { filter: grayscale(.85); opacity: .5; } +/* 還沒回報過的那一格刻意做得很淡:它不是「沒問題」,是「還不知道」。 */ +.folder .fstat.unknown { color: rgba(var(--ink-rgb), .3); font-size: 12px; } +/* 圖示按鈕(移除):跟三角形同一個量級,hover 才變紅——不預設嚷嚷。 */ +.folder .ico { + flex: none; padding: 4px 6px; border: none; background: none; + font-size: 14px; line-height: 1; filter: grayscale(1); opacity: .7; +} +.folder .ico:hover { background: none; filter: none; opacity: 1; } + .big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; } .kv { display: flex; gap: 26px; margin-top: 4px; } .kv .k { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); } @@ -299,6 +334,14 @@ input[type=text], input[type=password] { .kbrow .nm { font-size: 14px; font-weight: 600; flex: none; } .kbrow .right { display: flex; align-items: center; gap: 10px; font-size: 13px; } .kbver { margin-bottom: 14px; font-size: 13.5px; display: flex; align-items: center; gap: 10px; } +/* arcrun-rag#159:版本那一格只有**數字與圖示**,說明收進 title。 + 等寬字讓 `1.4.46 → 1.4.58` 這種對照掃視時對得齊。 */ +.kbv { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: rgba(var(--ink-rgb), .7); } +/* 這一輪連不上 ⇒ 版本號調淡表示「這是上次查到的」。**不是把它抹掉**—— + 版本查到過就是事實,抽風的是網路(見 collector/direct.go #159 那段)。 */ +.kbv.dim { opacity: .45; } +/* 從來沒查到過:一個很淡的圈,跟資料夾那一列的 unknown 同一個語彙。 */ +.kbv.unknown { font-family: inherit; color: rgba(var(--ink-rgb), .3); } .warn { color: var(--err); font-size: 13px; } #side .nav .dot.warn { width: 7px; height: 7px; border-radius: 50%; flex: none; @@ -432,12 +475,10 @@ input[type=text], input[type=password] { margin: -3px 0 9px; padding: 8px 10px 6px; background: var(--well); border-radius: 10px; } -.fthead { - display: flex; align-items: center; gap: 8px; flex-wrap: wrap; - padding-bottom: 7px; margin-bottom: 5px; - border-bottom: 1px solid rgba(var(--ink-rgb), .08); -} -.fthead button { padding: 4px 9px; font-size: 12px; } +/* 🔴 arcrun-rag#159:`.fthead`(全部展開/全部收合+收檔策略那句話)**整條移除**。 + leo 2026-08-28 在截圖上圈掉它:「上下廢話刪除,只留乾淨 Tree」 + 「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」。 + 收檔策略那句話沒有被刪掉——它改掛在根那一列的數字上,點一下就看得到。 */ /* 樹本體:檔案總管那種「緊湊但點得到」的密度。 overflow-x 讓很深的樹自己捲,不去撐破右側版面。 */ .ftbody { overflow-x: auto; padding: 1px 0; } @@ -474,6 +515,19 @@ input[type=text], input[type=password] { } /* 全部同步完=這一格沒有待辦,用完整墨色讓它從灰階裡浮出來(不另發明「成功色」)。 */ .ftrow .num.full { color: var(--ink); } +/* #159:這一列有「為什麼」可以看時,數字自己就是那顆按鈕(畫面上不多一個字、 + 也不多一顆按鈕)。虛線底線是唯一的提示——**它不是裝飾,是可點的告示**。 */ +.ftrow .num.hasWhy { + cursor: pointer; + border-bottom: 1px dotted rgba(var(--ink-rgb), .35); +} +.ftrow .num.hasWhy:hover { color: var(--ink); border-bottom-color: var(--amber); } +.ftrow .num.hasWhy:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; } +/* 點開之後那一行理由。預設不存在——這就是「收起來不等於刪掉」的落點。 */ +.ftwhy { + font-size: 12px; line-height: 1.6; padding: 1px 4px 4px; + color: rgba(var(--ink-rgb), .55); white-space: normal; max-width: 46em; +} /* 🔴 選擇器**不要**綁在 .ftrow 底下:頁首那句「為什麼只收這些」也用同一個 class, 綁死就只有樹裡面吃得到樣式、頁首那句會掉回 16px 的內文級——實撞過一次, 畫面上那句話比整棵樹都大聲,喧賓奪主。 */ @@ -484,12 +538,6 @@ input[type=text], input[type=password] { font-size: 12px; color: rgba(var(--ink-rgb), .45); flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } -.fthead .why { flex: 1; min-width: 0; line-height: 1.6; } -/* 整棵沒走進去:語氣是告知不是錯誤,所以不用 --err。 */ -.ftrow .skip { - font-size: 11.5px; color: rgba(var(--ink-rgb), .5); - border: 1px solid rgba(var(--ink-rgb), .18); border-radius: 5px; padding: 0 5px; -} .ftmsg { padding: 6px 2px 2px; font-size: 12px; line-height: 1.65; color: rgba(var(--ink-rgb), .45); diff --git a/direct.go b/direct.go index 5ca23c1..ed3b240 100644 --- a/direct.go +++ b/direct.go @@ -742,6 +742,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge folderTrees := map[string]FolderTree{} var knownRoots []string + // `inkstone/arcrun-rag#159`:每個看守資料夾這一輪的同步現況(key=資料夾路徑)。 + // 值是 (*Manifest).Progress() 的原件——與 totalProgress 同一個來源,只是沒有累加。 + folderProgress := map[string]SyncProgress{} + // t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。 var totalProgress SyncProgress var stuckReasons []string @@ -768,8 +772,28 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // t103:per-account 雲端版本偵測 cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL) + // 🔴 `inkstone/arcrun-rag#159`:**一次探測失敗 ≠ 這台知識庫連不上。** + // + // leo 2026-08-28 的畫面上,`youlin.hsieh.dev` 那行紅字寫「目前連不上這個 + // 知識庫,查不到版本」——而同一台機器上 curl 打同一支 /health 是 HTTP 200 + // 亞秒回應。實查當下 status.json:**三個帳號全部** `cloud_check_ok=false`、 + // 錯誤都是 `dial tcp: ... no such host`,而剛開的 Go 行程(cgo 與純 Go + // 兩種 resolver 都試過)解得出來、GET 也 200。 + // ⇒ 那是這台機器當下的 DNS 抽風,不是知識庫掛了。 + // + // 舊寫法把「這一輪沒查到」直接呈現成「查不到版本」,於是**一次抽風就抹掉 + // 我們早就知道的事實**。改成:查不到就沿用上一輪查到的版本 + //(同 CarryForwardActivity 的理由),只有**從來沒查到過**才是真的不知道。 + // CloudCheckOK 仍照實記這一輪的可達性——那是另一件事,不混在一起。 + if strings.TrimSpace(cloudVer) == "" { + if prevAcc, ok := prevStatus.AccountDetails[accHost]; ok { + cloudVer = prevAcc.CloudVersion + } + } // t215:這個帳號要不要更新——與 portal 版本卡同一套判準(見檔頭)。 - cloudUpd := EvalCloudUpdate(cloudVer, cloudOK, latestRelease, latestOK) + // 第二個參數傳「我們知不知道它的版本」而不是「這一輪連不連得上」: + // 版本是**事實**,可達性是**當下狀態**,兩者不是同一個問題。 + cloudUpd := EvalCloudUpdate(cloudVer, strings.TrimSpace(cloudVer) != "", latestRelease, latestOK) accSt := AccountSyncStatus{ LastSync: time.Now().Format(time.RFC3339), CloudVersion: cloudVer, @@ -861,6 +885,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...) // #104:這一根用了什麼策略、少收了什麼 —— 以前到這裡就被丟掉了 // (只有 CLI 的 stderr 講得出來,App 走的這條路一個字都不說)。 + // #159:這一根的同步現況。與 folderPlans 同一個「掃成了才記」的閘—— + // 掃壞的那輪 rootProgress 是零值,記下去畫面會把它讀成「0 份、已同步」 + // =拿我們自己編的答案打勾。沒記的根由下面的沿用機制補上一輪的。 + folderProgress[root] = rp.Progress folderPlans[root] = FolderPlanStatus{ Mode: string(p.Plan.Mode), Reason: p.Plan.Reason, @@ -952,6 +980,21 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge if len(folderPlans) > 0 { st.FolderPlans = folderPlans } + // #159:逐資料夾的同步現況。這一輪沒掃成的根**沿用上一輪** + // (同 MergeFolderTreeStore ② 的理由:畫面不該因為一輪掃壞就說「沒有狀態」); + // 已經不在看守清單上的根自然消失——folderProgress 的 key 只可能來自本輪的 + // knownRoots,移除掉的資料夾不會被沿用回來。 + for _, root := range knownRoots { + if _, ok := folderProgress[root]; ok { + continue + } + if prev, ok := prevStatus.FolderProgress[root]; ok { + folderProgress[root] = prev + } + } + if len(folderProgress) > 0 { + st.FolderProgress = folderProgress + } st.SkippedDocCount = len(skippedSeen) for _, sf := range skippedSeen { st.SkippedDocs = append(st.SkippedDocs, sf) diff --git a/folder_progress_wiring_test.go b/folder_progress_wiring_test.go new file mode 100644 index 0000000..6f114fd --- /dev/null +++ b/folder_progress_wiring_test.go @@ -0,0 +1,138 @@ +package collector + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// TestFolderProgressLandsInStatus 守住 `inkstone/arcrun-rag#159` 的接線: +// **逐資料夾的同步現況真的會寫進 status.json**,而且是每個根各一份。 +// +// 為什麼要有這一條:小幫手畫面上那個打勾完全靠這個欄位。它沒寫出來的話, +// 前端拿不到資料 ⇒ 每一列都會落到 unknown ⇒ 使用者又回到「看不出同步了沒」。 +func TestFolderProgressLandsInStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + base := t.TempDir() + rootA := filepath.Join(base, "A") + rootB := filepath.Join(base, "B") + for _, d := range []string{rootA, rootB} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + _ = os.WriteFile(filepath.Join(rootA, "a1.md"), []byte("# A1"), 0o644) + _ = os.WriteFile(filepath.Join(rootA, "a2.md"), []byte("# A2"), 0o644) + _ = os.WriteFile(filepath.Join(rootB, "b1.md"), []byte("# B1"), 0o644) + + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{rootA, rootB}}}, + MaxRemoved: DefaultMaxRemovedRatio, + } + RunDirectOnce(cfg, false) + + st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest)) + if err != nil { + t.Fatalf("讀不到 status.json:%v", err) + } + if len(st.FolderProgress) != 2 { + t.Fatalf("folder_progress 應該每個看守資料夾各一份(2),got %d:%+v", + len(st.FolderProgress), st.FolderProgress) + } + if got := st.FolderProgress[rootA].Total; got != 2 { + t.Errorf("A 的分母應為 2,got %d", got) + } + if got := st.FolderProgress[rootB].Total; got != 1 { + t.Errorf("B 的分母應為 1,got %d", got) + } + // 🔴 逐資料夾加起來要等於首頁那行總量——它們本來就是同一組數字被切開, + // 對不起來就代表有人另算了一套(那正是這張票要拔掉的病)。 + var sum SyncProgress + for _, p := range st.FolderProgress { + sum = sum.Add(p) + } + if sum.Total != st.Progress.Total-st.Progress.Unreadable { + t.Errorf("逐資料夾合計 %d ≠ 首頁總量 %d(扣掉沒進 manifest 的 %d)", + sum.Total, st.Progress.Total, st.Progress.Unreadable) + } +} + +// TestCloudVersionSurvivesOneFailedProbe 守住票的追加條件 6: +// **「連不上」的判準不能是「這一輪解析失敗」。** +// +// leo 2026-08-28 畫面上寫「目前連不上這個知識庫,查不到版本」, +// 而同一台機器 curl 打同一支 /health 是 HTTP 200——那是機器當下 DNS 抽風。 +// 一次探測失敗不准抹掉我們已經知道的版本。 +func TestCloudVersionSurvivesOneFailedProbe(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + base := t.TempDir() + root := filepath.Join(base, "R") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(root, "r.md"), []byte("# R"), 0o644) + + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}}, + MaxRemoved: DefaultMaxRemovedRatio, + } + + orig := fetchCloudVersion + defer func() { fetchCloudVersion = orig }() + // 「最新版是多少」也要釘住,否則這條測試會依賴 install.arcrun.dev 通不通 + // ——那樣測的就不是我們要測的東西了(而且 CI 離線就紅)。 + origLatest := fetchLatestCloudReleaseRaw + fetchLatestCloudReleaseRaw = func() (string, bool) { return "1.4.58", true } + latestMu.Lock() + latestFetched = time.Time{} // 清掉節流快取,讓 stub 這一輪真的生效 + latestMu.Unlock() + defer func() { + fetchLatestCloudReleaseRaw = origLatest + latestMu.Lock() + latestFetched = time.Time{} + latestMu.Unlock() + }() + + // 第一輪:查得到版本。 + fetchCloudVersion = func(string) (string, bool) { return "1.4.60", true } + RunDirectOnce(cfg, false) + + // 第二輪:探測失敗(模擬 DNS 抽風)。 + fetchCloudVersion = func(string) (string, bool) { return "", false } + RunDirectOnce(cfg, false) + + st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest)) + if err != nil { + t.Fatal(err) + } + if len(st.AccountDetails) != 1 { + t.Fatalf("應該只有一個帳號,got %d", len(st.AccountDetails)) + } + for host, acc := range st.AccountDetails { + if acc.CloudVersion != "1.4.60" { + t.Errorf("%s:一次探測失敗就把版本抹掉了(got %q,應沿用 1.4.60)", host, acc.CloudVersion) + } + if !acc.CloudUpdateKnown { + t.Errorf("%s:版本明明還知道,卻標成「查不到」", host) + } + // 可達性是另一件事,要照實記——不准為了讓畫面好看就說連得上。 + if acc.CloudCheckOK { + t.Errorf("%s:這一輪明明沒連上,cloud_check_ok 卻是 true", host) + } + } +} diff --git a/progress.go b/progress.go index 361b4b4..b2a1032 100644 --- a/progress.go +++ b/progress.go @@ -37,6 +37,16 @@ type SyncProgress struct { Pending int `json:"pending"` // 還沒送,會自動接著做(含退避等待中的) Stuck int `json:"stuck"` // 連續失敗達上限、已暫停自動重試——**不會自己好** Unreadable int `json:"unreadable"` // 格式讀不了,根本沒進 manifest(由呼叫端補進來) + // Failing=Pending 裡「最近一次已經送失敗過、正在退避等重試」的份數 + // (`inkstone/arcrun-rag#159`)。**它是 Pending 的子集合,不進不變式**—— + // 加總永遠只算 Total == Done+Pending+Stuck+Unreadable。 + // + // 🔴 為什麼要分出來:Pending 同時裝著兩種完全不同的處境——「還沒輪到」與 + // 「一直送不上去」。混在一起時畫面只能講「同步中」,而 2026-08-28 leo 桌面上 + // geek6688-test1 的 14 份**每一份都在失敗重試**(雲端回 HTTP 500 Too many + // subrequests,fail_count 是 7 與 3),講「同步中」等於叫他等一件正在壞掉的事。 + // 分出來,畫面才判斷得出該不該示警(見 arcrun-app/folder_badge.go)。 + Failing int `json:"failing"` } // Add 把另一個資料夾/帳號的進度累加進來——首頁與診斷檔講的都是總量。 @@ -47,6 +57,7 @@ func (p SyncProgress) Add(o SyncProgress) SyncProgress { Pending: p.Pending + o.Pending, Stuck: p.Stuck + o.Stuck, Unreadable: p.Unreadable + o.Unreadable, + Failing: p.Failing + o.Failing, } } @@ -69,6 +80,11 @@ func (m *Manifest) Progress() SyncProgress { p.Stuck++ default: p.Pending++ + if e.FailCount > 0 { + // #159:還在排隊,但**已經失敗過**——退避等下一次重試。 + // 與 Pending 並存(子集合),不影響上面的不變式。 + p.Failing++ + } } } return p diff --git a/sync_status.go b/sync_status.go index a66a4a8..9762a92 100644 --- a/sync_status.go +++ b/sync_status.go @@ -105,6 +105,23 @@ type SyncStatus struct { // 與 Retiring 同族的現況快照,見 ResyncStatus 註解。 Resync map[string]ResyncStatus `json:"resync,omitempty"` + // FolderProgress=**逐個看守資料夾**的同步現況(key=資料夾路徑,`inkstone/arcrun-rag#159`)。 + // + // 🔴 為什麼要有:小幫手畫面上每個資料夾都要能一眼看出「同步了沒」,而在這之前 + // 那一格唯一的資料來源是 #140 的補送說明——**它只在有補送的時候才有值**, + // 於是畫面只好拿「有沒有那句話」當狀態。2026-08-28 leo 看到七列有六列寫「補送中」, + // 連 pending 已經是 0 的也照樣寫(實測 youlinhsieh-test1:pending=0/repaired=2, + // 畫面仍標補送中)——那個標籤量的根本不是同步狀態。 + // + // 🔴 **不另算一套**:值就是 (*Manifest).Progress() 的原件,與首頁那行大數字、 + // 診斷檔用同一個函式。逐資料夾加起來會等於首頁總量,因為它們本來就是同一組 + // 數字被切開,不是兩份實作。 + // + // 與 FolderPlans 同族的現況快照;某一輪那個根沒掃成(payload 為 nil)時**沿用 + // 上一輪**,不要讓使用者看到昨天還好好的資料夾突然沒有狀態 + // (同 MergeFolderTreeStore ② 的理由)。 + FolderProgress map[string]SyncProgress `json:"folder_progress,omitempty"` + // 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案, // 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。 // 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。