From fedb765742995807e6dbae73f36286367ca7bbc7 Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 26 Jul 2026 01:53:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(t52):=20per-folder=20=E5=BA=AB=E7=AB=A0?= =?UTF-8?q?=E2=80=94=E2=80=94DirectConfig.libraries=20=E5=B0=8D=E6=98=A0?= =?UTF-8?q?=EF=BC=8BlibraryFor()=EF=BC=88=E8=B3=87=E6=96=99=E5=A4=BE?= =?UTF-8?q?=E5=90=8D=20slug=EF=BC=8F=E4=B8=AD=E6=96=87=E9=80=80=E5=BE=8C?= =?UTF-8?q?=E5=82=99=EF=BC=8F=E6=98=8E=E5=88=97=E5=84=AA=E5=85=88=EF=BC=8C?= =?UTF-8?q?go=20test=20=E7=B6=A0=EF=BC=89=EF=BC=9B=E5=85=A9=E6=94=AF=20ing?= =?UTF-8?q?est=20workflow=20=E7=9A=84=20post=5Fblock=20metadata=20?= =?UTF-8?q?=E8=A3=9C=20library=EF=BC=88=E7=9C=9F=E5=85=87=EF=BC=9Ablocks?= =?UTF-8?q?=20=E6=B2=92=E8=93=8B=E7=AB=A0=EF=BC=9D=E5=85=A9=E8=B3=87?= =?UTF-8?q?=E6=96=99=E5=A4=BE=E5=85=A8=E6=AD=B8=20general=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E6=89=98=E7=9B=A4=E9=80=A3=E7=B7=9A=E6=99=82=E5=9B=9E?= =?UTF-8?q?=E5=A0=B1=E5=BA=AB=E8=87=AA=E5=8B=95=E7=99=BB=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/arcrun-tray/main.go | 81 +++++++++++++++++++++++++++++++++++++++++ direct.go | 51 ++++++++++++++++++++++++-- t52_library_test.go | 22 +++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 t52_library_test.go diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index ecfc495..96cc9a6 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -51,6 +51,7 @@ type directConfig struct { InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email) Library string `json:"library,omitempty"` Extractor string `json:"extractor,omitempty"` // t54:連線精靈帶入(claude/gemma) + Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑) IngestWF string `json:"ingest_workflow,omitempty"` RemovedWF string `json:"removed_workflow,omitempty"` PollSec int `json:"poll_interval_sec,omitempty"` @@ -289,6 +290,82 @@ func applyRemoteConfig(cfg *directConfig, r *daemonConfigResp) { } } +// registerLibraries 把「這台機器看守的資料夾各自對應的庫」報上雲端自動登記(t52)。 +// leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵。」 +// 只在連線精靈那一刻做(那時手上才有帳密;daemon 平時不存密碼)。失敗不擋連線。 +func registerLibraries(portalURL, email, password string, cfg *directConfig) error { + base, err := normalizePortalURL(portalURL) + if err != nil { + return err + } + type libItem struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + } + var libs []libItem + seen := map[string]bool{} + for _, folder := range cfg.Folders() { + name := libraryNameFor(cfg, folder) + if name == "" || seen[name] { + continue + } + seen[name] = true + libs = append(libs, libItem{Name: name, DisplayName: filepath.Base(folder)}) + } + if len(libs) == 0 { + return nil + } + body, _ := json.Marshal(map[string]any{"email": strings.TrimSpace(email), "password": password, "libraries": libs}) + req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/libraries", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 400 { + return fmt.Errorf("庫登記回 HTTP %d", res.StatusCode) + } + return nil +} + +// libraryNameFor 與 collector/direct.go 的 libraryFor 同規則(資料夾名 slug;空則退回 library)。 +func libraryNameFor(cfg *directConfig, folder string) string { + if cfg.Libraries != nil { + if v, ok := cfg.Libraries[folder]; ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + base := filepath.Base(strings.TrimRight(folder, string(filepath.Separator))) + var b strings.Builder + lastUnderscore := false + for _, r := range base { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_': + b.WriteRune(r) + lastUnderscore = false + case r >= 'A' && r <= 'Z': + b.WriteRune(r + 32) + lastUnderscore = false + default: + if !lastUnderscore && b.Len() > 0 { + b.WriteRune('_') + lastUnderscore = true + } + } + } + if slug := strings.Trim(b.String(), "_"); slug != "" { + return slug + } + if cfg.Library != "" { + return cfg.Library + } + return "kb" +} + // isConnected 判斷「這台機器已經連上某個知識庫」——沒有就該跳連線精靈。 func isConnected(cfg *directConfig) bool { return strings.TrimSpace(cfg.CypherURL) != "" && strings.TrimSpace(cfg.Namespace) != "" @@ -364,6 +441,10 @@ func main() { dialog.ShowError(err, win) return } + // t52:把本機資料夾對應的庫報上去自動登記(地端幾個資料夾=雲端幾個庫) + if lerr := registerLibraries(urlEntry.Text, emailEntry.Text, pwEntry.Text, cfg); lerr != nil { + fmt.Println("庫登記略過:", lerr) // 不擋連線(可能是還沒選資料夾) + } restartWatch() rebuildTray() dialog.ShowInformation("連上了", "已連上「"+connectionStatusLabel(cfg.InstanceName, cfg.Email)+"」。\n接下來用選單「+ 新增知識資料夾…」挑要同步的資料夾就好。", win) diff --git a/direct.go b/direct.go index 87b370f..1adf2fb 100644 --- a/direct.go +++ b/direct.go @@ -43,7 +43,11 @@ type DirectConfig struct { APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespace,demo 慣例) Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形) InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email) - Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb) + Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備) + // t52(leo 2026-07-25 裁決:「資料夾=庫」——企業有 10 個資料夾,財務/人事不准任何人看, + // 全塞一個庫=找死):每個看守資料夾對應自己的庫。key=資料夾絕對路徑,value=庫名。 + // 未列出的資料夾=用資料夾名 slug 當庫名(librarySlug);再不行才退回 Library。 + Libraries map[string]string `json:"libraries,omitempty"` IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct;extractor 模式不用) RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path}) // —— 四步定稿(daemon-beta t3/t4/t6):本地萃卡模式 —— @@ -56,6 +60,47 @@ type DirectConfig struct { MaxRemoved float64 `json:"max_removed_ratio"` // 大量刪除防呆門檻(空/0=0.4) } +// librarySlug 把資料夾名轉成合法庫名(A-Za-z0-9_-;中文等非 ASCII 轉為底線分段)。 +// 空結果(如純中文名)回 "",交由 libraryFor 退回後備值——不硬造出無意義的庫名。 +func librarySlug(folder string) string { + base := filepath.Base(strings.TrimRight(folder, string(filepath.Separator))) + var b strings.Builder + lastUnderscore := false + for _, r := range base { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_': + b.WriteRune(r) + lastUnderscore = false + case r >= 'A' && r <= 'Z': + b.WriteRune(r + 32) // 統一小寫(庫名大小寫不敏感比對較不易出錯) + lastUnderscore = false + default: + if !lastUnderscore && b.Len() > 0 { + b.WriteRune('_') + lastUnderscore = true + } + } + } + return strings.Trim(b.String(), "_") +} + +// libraryFor 決定某個看守資料夾的資料該蓋哪個庫章(t52)。 +// 優先序:config.Libraries 明列 > 資料夾名 slug > config.Library(後備,預設 kb)。 +func (c *DirectConfig) libraryFor(absRoot string) string { + if c.Libraries != nil { + if lib, ok := c.Libraries[absRoot]; ok && strings.TrimSpace(lib) != "" { + return strings.TrimSpace(lib) + } + } + if slug := librarySlug(absRoot); slug != "" { + return slug + } + if c.Library != "" { + return c.Library + } + return "kb" +} + // expandHome 把開頭的 `~/`(或單獨的 `~`)展開成使用者家目錄的絕對路徑。 // // t39(07-24 安裝器實案):成功頁下載的 config.json 把 manifest 寫成 `~/.arcrun-rag/manifest.json`—— @@ -327,7 +372,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes "page_name": pageNameOf(cardRel), "path": ev.Path, "card_content": string(cardData), - "library": cfg.Library, + "library": cfg.libraryFor(absRoot), }) res.HTTPStatus = status if perr != nil { @@ -349,7 +394,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes "page_name": pageNameOf(ev.Path), "path": ev.Path, "content": string(content), - "library": cfg.Library, + "library": cfg.libraryFor(absRoot), }) res.HTTPStatus = status if perr != nil { diff --git a/t52_library_test.go b/t52_library_test.go new file mode 100644 index 0000000..1fa730e --- /dev/null +++ b/t52_library_test.go @@ -0,0 +1,22 @@ +package main + +import "testing" + +func TestLibraryPerFolder(t *testing.T) { + cfg := &DirectConfig{Library: "kb"} + cases := map[string]string{ + "/Users/x/Desktop/finance": "finance", + "/Users/x/Desktop/HR Docs": "hr_docs", + "/Users/x/Desktop/官方總圖": "kb", + "/Users/x/Desktop/test 01": "test_01", + } + for in, want := range cases { + if got := cfg.libraryFor(in); got != want { + t.Errorf("libraryFor(%q) = %q, want %q", in, got, want) + } + } + cfg.Libraries = map[string]string{"/Users/x/Desktop/官方總圖": "official"} + if got := cfg.libraryFor("/Users/x/Desktop/官方總圖"); got != "official" { + t.Errorf("明列對映應優先,got %q", got) + } +}