Files

119 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// probe_workersai.go — 探測「這個帳號的雲端知識庫,Workers AI 萃取通了沒」(t182)。
//
// 🔴 為什麼要探測(leo 2026-08-04 兩句話定的設計):
//
// ① 「順序不可反這件事**門檻太高**了,用戶一定**先看到 daemon 有更新**
// 不會想到更新網頁」
// ② 「你可以設計如果它先做了地端就要求去做雲端,**會去掃一次看雲端是否裝好**,
// **沒裝好就顯示 workers AI 還沒通,一旦通了就顯示可用**」
//
// 背景:Workers AI 萃取要**兩端都新**——daemon 有新版(會打 /portal/daemon/extract
// 且雲端實例也有新版(有這條 route)。daemon 會自動更新、雲端實例要用戶自己重跑安裝器
// ⇒ 中間必然存在一段「daemon 新、雲端舊」的空窗期。
//
// 舊設計把這段空窗丟給用戶自己想通(「你順序做錯了」),那是**我們的設計沒容錯**。
// 新設計:daemon 主動掃、把狀態講出來——
//
// 雲端還沒更新 → 托盤直接顯示「雲端 AI 還沒通 ⇒ 去 portal 按『立即更新』」
// 雲端已更新 → 顯示「雲端 AI 可用」,什麼都不必做
//
// ⚠️ 這是**被動探測**,不是輪詢:只在同步時(RunDirectOnce 每輪一次)順手打一次
// HEAD-ish 的探測請求,且結果快取在 status.json 給托盤讀。不掛任何排程。
package collector
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// probeHTTP 探測用短逾時:只是要知道 route 在不在,不等 LLM 生成。
//
// 🔴 `inkstone/arcrun-rag#153`:這把 20 秒是保險,真正的上限由呼叫端帶進來的
// context 決定。探測是**每輪、每個帳號的第一發**——它撞到不回應的端點時,
// 後面所有帳號都還沒開始,所以它也是最早能認出「這個帳號今天不回應」的位置。
var probeHTTP = &http.Client{Timeout: 20 * time.Second}
// CloudAIState 是「某個雲端實例的 Workers AI 萃取能力」探測結果。
type CloudAIState struct {
Ready bool `json:"ready"` // true=那端有 /portal/daemon/extract(雲端 AI 可用)
Note string `json:"note,omitempty"` // 給用戶看的白話(Ready=false 時說「還沒通、該做什麼」)
}
// ProbeWorkersAI 探測指定實例是否支援雲端萃取。
//
// 手法:送一個**刻意留空 text** 的請求。
// - 舊版實例沒有這條 route ⇒ 404 ⇒ 還沒通。
// - 新版實例有 route,會因為 text 是空的回 400(參數錯)——但 400 正好證明**route 存在**
// ⇒ 算通。這樣探測不會真的燒 LLM 額度,也不會產生垃圾卡片。
// - 401 代表 route 在、只是金鑰不對 ⇒ route 存在,同樣算「雲端有這功能」,
// 金鑰問題由既有的連線流程去報,不混在這裡講。
func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState, error) {
base := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/")
if base == "" {
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, nil
}
body, _ := json.Marshal(map[string]string{"page_name": "", "text": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
if err != nil {
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Arcrun-API-Key", strings.TrimSpace(apiKey))
resp, err := probeHTTP.Do(req)
if err != nil {
// #153:錯誤原件一起回去,呼叫端才分得出「等到超時」與「立刻連不上」——
// 前者要記進斷路器,後者不該(不然一次斷網就把帳號判成停機)。
// 連不上(離線/網路問題)≠ 雲端沒裝。講「查不到」而不是「還沒通」,
// 免得把網路問題誤報成「你沒更新」讓用戶白跑一趟。
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound:
return CloudAIState{
Ready: false,
Note: "雲端 AI 還沒通 ⇒ 你的知識庫是舊版,請到 portal 按「立即更新」重裝一次",
}, nil
case resp.StatusCode < 500:
// 200/400/401… 都代表這條 route 存在=雲端有這個功能。
return CloudAIState{Ready: true}, nil
default:
return CloudAIState{
Ready: false,
Note: fmt.Sprintf("雲端 AI 暫時有狀況(HTTP %d),稍後會自動再試", resp.StatusCode),
}, nil
}
}
// probeWorkersAI=帶「等待閘」的探測(`inkstone/arcrun-rag#153`)。
//
// 為什麼這一發特別重要:它是每輪、每個帳號的**第一發**。撞到不回應的端點時,
// 後面的資料夾一件都還沒開始 ⇒ 這裡認出來,整個帳號的其餘工作就都省下來了。
// 反過來說,漏掉這一發的話,前面所有的閘都白裝——2026-08-28 第一版就是這樣:
// 斷路器跳了,可是一輪還是要 20 秒,因為那 20 秒全花在這一發上。
func (c *DirectConfig) probeWorkersAI() CloudAIState {
if note := c.unreachableNote(); note != "" {
return CloudAIState{Ready: false, Note: note}
}
gate := c.openGate(stepProbeAI)
state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey)
gate.release()
if err == nil {
gate.ok()
}
if perr := gate.record(err); perr != nil {
// 等到超時 ⇒ 講的是「沒有回應」,不是「你的雲端沒裝好」。
// 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。
return CloudAIState{Ready: false, Note: perr.Error()}
}
return state
}