diff --git a/db/commands.go b/db/commands.go index c9a81e9..74a92a3 100644 --- a/db/commands.go +++ b/db/commands.go @@ -6,19 +6,22 @@ import ( "time" ) -// CommandRecord is a paired Bash tool_use + tool_result from the activity table. +// CommandRecord is a paired tool_use + tool_result from the activity table +// (any tool, not just Bash). Command holds the raw tool input (JSON). type CommandRecord struct { ID int64 `json:"id"` ExpID int64 `json:"exploration_id"` Worker string `json:"worker"` + Tool string `json:"tool"` Command string `json:"command"` Output string `json:"output"` IsError bool `json:"is_error"` CreatedAt time.Time `json:"created_at"` } -// ListCommands returns Bash command executions (tool_use + paired tool_result) -// across all explorations, with optional filtering and pagination. +// ListCommands returns tool executions (tool_use + paired tool_result) across all +// explorations, with optional filtering and pagination. Covers every tool, not +// just Bash; q matches the tool name or its input. func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) { if size <= 0 { size = 50 @@ -28,7 +31,7 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco } offset := page * size - where := `WHERE u.tool = 'Bash' AND u.kind = 'tool_use'` + where := `WHERE u.kind = 'tool_use'` args := []any{} argN := 1 @@ -38,7 +41,7 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco argN++ } if q != "" { - where += fmt.Sprintf(` AND u.detail ILIKE $%d`, argN) + where += fmt.Sprintf(` AND (u.tool ILIKE $%d OR u.detail ILIKE $%d)`, argN, argN) args = append(args, "%"+q+"%") argN++ } @@ -52,7 +55,7 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco // data query: join tool_use with its tool_result dataQ := ` -SELECT u.id, u.exploration_id, COALESCE(u.worker,''), COALESCE(u.detail,''), +SELECT u.id, u.exploration_id, COALESCE(u.worker,''), COALESCE(u.tool,''), COALESCE(u.detail,''), COALESCE(r.detail,''), COALESCE(r.is_error, false), u.created_at FROM activity u LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result' @@ -70,7 +73,7 @@ LIMIT $` + fmt.Sprintf("%d", argN) + ` OFFSET $` + fmt.Sprintf("%d", argN+1) out := []CommandRecord{} for rows.Next() { var c CommandRecord - if err := rows.Scan(&c.ID, &c.ExpID, &c.Worker, &c.Command, &c.Output, &c.IsError, &c.CreatedAt); err != nil { + if err := rows.Scan(&c.ID, &c.ExpID, &c.Worker, &c.Tool, &c.Command, &c.Output, &c.IsError, &c.CreatedAt); err != nil { return nil, 0, err } out = append(out, c) diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go index 2d6a9fa..37c256d 100644 --- a/llmrec/llmrec.go +++ b/llmrec/llmrec.go @@ -18,16 +18,18 @@ import ( // Recorder wraps an llm.Provider and records every completion call. type Recorder struct { - inner llm.Provider - pg *db.DB - model string // model name (from config, not in CompletionRequest) - prof string // LLM profile name (from llm_profiles) + inner llm.Provider + pg *db.DB + model string // model name (from config, not in CompletionRequest) + prof string // LLM profile name (from llm_profiles) + enabled func() bool // reports whether recording is currently on; nil = always record } -// Wrap returns a Provider that records all calls to pg. -// profName is the LLM profile name (e.g. "default"); may be empty. -func Wrap(inner llm.Provider, pg *db.DB, model, profName string) *Recorder { - return &Recorder{inner: inner, pg: pg, model: model, prof: profName} +// Wrap returns a Provider that records calls to pg when enabled() reports true. +// profName is the LLM profile name (e.g. "default"); may be empty. A nil enabled +// predicate records unconditionally (backward-compatible default). +func Wrap(inner llm.Provider, pg *db.DB, model, profName string, enabled func() bool) *Recorder { + return &Recorder{inner: inner, pg: pg, model: model, prof: profName, enabled: enabled} } // parseSession extracts task id and worker role from session strings like @@ -61,6 +63,11 @@ func parseSession(s string) (taskID, worker string) { // harness via transcript.WithSessionID; reading it per-call is race-free even when // planner and multiple workers share one Recorder instance. func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + // Recording off → passthrough with zero overhead: no request serialization, + // no response accumulation, no DB insert. + if r.enabled != nil && !r.enabled() { + return r.inner.Stream(ctx, req) + } start := time.Now() session := transcript.SessionIDFrom(ctx) taskID, worker := parseSession(session) diff --git a/server/commands.go b/server/commands.go index cd8e8c2..3f63397 100644 --- a/server/commands.go +++ b/server/commands.go @@ -5,7 +5,7 @@ import ( "strconv" ) -// pgListCommands returns Bash command executions filtered from the activity table. +// pgListCommands returns tool executions (any tool) from the activity table. func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page := atoiDefault(q.Get("page"), 0) diff --git a/server/manager.go b/server/manager.go index 346d31e..52ea0a8 100644 --- a/server/manager.go +++ b/server/manager.go @@ -64,6 +64,7 @@ type Manager struct { tasks map[string]*Task active string trafficOn bool // 流量捕获开关(默认关;settings.traffic_capture) + llmRecOn bool // LLM 录制开关(默认关;settings.llm_record) // 联网搜索开关与来源(默认关;settings.web_search_*)。brave-free 需要 braveKey;tavily 需要 tavilyKey。 // webSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关。 webSearchOn bool @@ -82,6 +83,7 @@ const ( settingTavilyKey = "tavily_search_api_key" settingWebSearchProxy = "web_search_proxy" settingWorkers = "workers" + settingLLMRecord = "llm_record" // defaultWebSearchBackend is used when web search is on but no backend was picked. defaultWebSearchBackend = "ddgs" // defaultWorkers is the concurrent work-agent count when the setting is unset. @@ -158,6 +160,8 @@ func NewManager(dir, proxyAddr string) (*Manager, error) { // Asset auto-completion engine (§5): HTTP probes routed through the recording // proxy (via m.ProxyAddr, which honors the traffic-capture toggle). m.trafficOn = pg.GetBool(settingTrafficCapture, false) + // LLM 录制开关(默认关)。录制器每次调用时读取此标志。 + m.llmRecOn = pg.GetBool(settingLLMRecord, false) // Load persisted web-search config (default: off, ddgs). m.webSearchOn = pg.GetBool(settingWebSearchOn, false) if v, ok, _ := pg.GetSetting(settingWebSearchBackend); ok && v != "" { @@ -206,6 +210,27 @@ func (m *Manager) SetTrafficEnabled(on bool) error { return nil } +// LLMRecordEnabled reports whether LLM request/response recording is on +// (默认关;settings.llm_record). The recorder consults this per call, so the +// toggle takes effect immediately without rebuilding agents. +func (m *Manager) LLMRecordEnabled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.llmRecOn +} + +// SetLLMRecordEnabled persists and applies the LLM-record toggle. Effective at +// once — no applyLLM needed, since the recorder reads the flag on every call. +func (m *Manager) SetLLMRecordEnabled(on bool) error { + if err := m.pg.SetBool(settingLLMRecord, on); err != nil { + return err + } + m.mu.Lock() + m.llmRecOn = on + m.mu.Unlock() + return nil +} + // WebSearch returns the current web-search config: whether it is enabled, the // backend ("ddgs" | "brave-free" | "tavily"), the Brave API key, the Tavily API // key (each empty unless set), and the dedicated egress proxy (empty = direct). diff --git a/server/server.go b/server/server.go index a8a40e7..52f5389 100644 --- a/server/server.go +++ b/server/server.go @@ -336,7 +336,7 @@ func (s *Server) applyLLM(cfg agent.Config) error { s.cfgMu.Lock() profName := s.llmProf s.cfgMu.Unlock() - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled) pl, wk := s.buildPlannerWorker(prov, cfg) s.engine.UseLLM(pl, wk) @@ -400,7 +400,7 @@ func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) { } // Wrap with recorder, tagged with this profile's name. if p, _ := s.m.pg.ProfileByID(id); p != nil { - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled) } pl, wk := s.buildPlannerWorker(prov, cfg) s.profAgents[id] = &profBundle{pl: pl, wk: wk} @@ -427,7 +427,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent { } // Wrap with recorder, tagged with this profile's name. if p, _ := s.m.pg.ProfileByID(id); p != nil { - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled) } tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts")) win := cfg.CompactionWindow() @@ -1458,6 +1458,7 @@ func (s *Server) settingsPayload() map[string]any { pyStored, _, _ := s.m.pg.GetSetting(settingPythonInterp) return map[string]any{ "traffic_capture": s.m.TrafficEnabled(), + "llm_record": s.m.LLMRecordEnabled(), "web_search_enabled": on, "web_search_backend": backend, "brave_key_set": strings.TrimSpace(braveKey) != "", @@ -1488,6 +1489,7 @@ func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) { func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { var req struct { TrafficCapture *bool `json:"traffic_capture"` + LLMRecord *bool `json:"llm_record"` // LLM 录制开关(默认关);即时生效,无需重建 agent // Web search. WebSearchEnabled/Backend toggle the tool + backend; BraveKey/TavilyKey // are optional — omit (null) to leave a stored key untouched, send "" to clear. WebSearchEnabled *bool `json:"web_search_enabled"` @@ -1514,6 +1516,13 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { return } } + if req.LLMRecord != nil { + // 录制器每次调用读取该标志,切换即时生效,无需 applyLLM 重建。 + if err := s.m.SetLLMRecordEnabled(*req.LLMRecord); err != nil { + writeErr(w, 500, err.Error()) + return + } + } changed := false if req.TrafficCapture != nil { if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil { diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 4036892..470f134 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -1539,7 +1539,9 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) { } // pgListModels fetches available models from the provider's API endpoint. -// Supports both OpenAI-format (GET /models) and Anthropic-format (GET /v1/models). +// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for +// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on +// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root. func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { var req struct { Provider string `json:"provider"` // "openai" | "anthropic" @@ -1567,29 +1569,48 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") provider := strings.TrimSpace(req.Provider) - // Build the models endpoint URL and request headers per provider format. - var modelsURL string - var hdr http.Header + // Candidate endpoints to try in order. Some Anthropic-compatible providers + // (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose + // the model list only on their OpenAI-format path — so for anthropic we fall + // back to the OpenAI endpoint at the stripped root. + type candidate struct { + url string + hdr http.Header + } + bearerHdr := func() http.Header { + h := http.Header{} + h.Set("Authorization", "Bearer "+apiKey) + return h + } + anthropicHdr := func() http.Header { + h := http.Header{} + h.Set("x-api-key", apiKey) + h.Set("anthropic-version", "2023-06-01") + return h + } + + var candidates []candidate switch provider { case "openai": if baseURL == "" { baseURL = "https://api.openai.com/v1" } - baseURL = strings.TrimSuffix(baseURL, "/chat/completions") - baseURL = strings.TrimRight(baseURL, "/") - modelsURL = baseURL + "/models" - hdr = http.Header{} - hdr.Set("Authorization", "Bearer "+apiKey) + b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/") + candidates = append(candidates, candidate{b + "/models", bearerHdr()}) default: // anthropic if baseURL == "" { baseURL = "https://api.anthropic.com" } - baseURL = strings.TrimSuffix(baseURL, "/v1/messages") - baseURL = strings.TrimRight(baseURL, "/") - modelsURL = baseURL + "/v1/models" - hdr = http.Header{} - hdr.Set("x-api-key", apiKey) - hdr.Set("anthropic-version", "2023-06-01") + b := strings.TrimRight(strings.TrimSuffix(baseURL, "/v1/messages"), "/") + candidates = append(candidates, candidate{b + "/v1/models", anthropicHdr()}) + // Fallback for Anthropic-compatible third parties whose model list lives on + // the OpenAI path: strip a trailing /anthropic and try the OpenAI endpoint. + if root := strings.TrimRight(strings.TrimSuffix(b, "/anthropic"), "/"); root != b { + candidates = append(candidates, + candidate{root + "/models", bearerHdr()}, + candidate{root + "/v1/models", bearerHdr()}, + ) + } } // Build HTTP client with optional proxy. @@ -1601,42 +1622,57 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { } client := &http.Client{Transport: transport, Timeout: 30 * time.Second} - httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, modelsURL, nil) - if err != nil { - writeJSON(w, 200, map[string]any{"ok": false, "error": "构建请求失败: " + err.Error()}) - return - } - httpReq.Header = hdr - - resp, err := client.Do(httpReq) - if err != nil { - writeJSON(w, 200, map[string]any{"ok": false, "error": "请求失败: " + err.Error()}) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - if resp.StatusCode != http.StatusOK { - writeJSON(w, 200, map[string]any{"ok": false, "error": fmt.Sprintf("API 返回 %d: %s", resp.StatusCode, string(body[:min(len(body), 512)]))}) - return - } - - // Parse response: both OpenAI and Anthropic return {"data": [{"id": "..."},...]}. - var parsed struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &parsed); err != nil { - writeJSON(w, 200, map[string]any{"ok": false, "error": "解析响应失败: " + err.Error()}) - return - } - models := make([]string, 0, len(parsed.Data)) - for _, m := range parsed.Data { - if m.ID != "" { - models = append(models, m.ID) + // Try each candidate; return the first that yields a non-empty model list. + var lastErr string + emptyOK := false + for _, c := range candidates { + httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, c.url, nil) + if err != nil { + lastErr = "构建请求失败: " + err.Error() + continue } + httpReq.Header = c.hdr + resp, err := client.Do(httpReq) + if err != nil { + lastErr = "请求失败: " + err.Error() + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Sprintf("API 返回 %d: %s", resp.StatusCode, string(body[:min(len(body), 512)])) + continue + } + // Both OpenAI and Anthropic return {"data": [{"id": "..."},...]}. + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + lastErr = "解析响应失败: " + err.Error() + continue + } + models := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + models = append(models, m.ID) + } + } + if len(models) > 0 { + writeJSON(w, 200, map[string]any{"ok": true, "models": models}) + return + } + emptyOK = true // 200 but no model ids — keep trying other candidates } - writeJSON(w, 200, map[string]any{"ok": true, "models": models}) + if emptyOK { + writeJSON(w, 200, map[string]any{"ok": true, "models": []string{}}) + return + } + if lastErr == "" { + lastErr = "未获取到模型列表" + } + writeJSON(w, 200, map[string]any{"ok": false, "error": lastErr}) } // --- prompt template helpers (Go text/template + catalog 白名单) --- diff --git a/web/src/app/(main)/function/commands/page.tsx b/web/src/app/(main)/function/commands/page.tsx index d49959c..918805a 100644 --- a/web/src/app/(main)/function/commands/page.tsx +++ b/web/src/app/(main)/function/commands/page.tsx @@ -43,12 +43,13 @@ function fmtTime(ts: string) { }); } -// extractCommand parses the tool_use detail (JSON like {"command":"..."}) and -// returns just the command string. Falls back to raw text if not JSON. -function extractCommand(raw: string): string { +// toolInput renders a tool's raw input for display. Bash's {"command":"..."} is +// unwrapped to the bare command; other tools show their pretty-printed JSON args. +function toolInput(raw: string): string { try { const obj = JSON.parse(raw); if (obj && typeof obj.command === "string") return obj.command; + return JSON.stringify(obj, null, 2); } catch { /* not JSON */ } return raw; } @@ -114,7 +115,7 @@ export default function CommandsPage() {
-

命令记录

+

工具执行

{total}
@@ -124,7 +125,7 @@ export default function CommandsPage() {
setQuery(e.target.value)} className="h-8 pl-8" @@ -187,21 +188,22 @@ export default function CommandsPage() { 时间 任务 Worker - 命令 + 工具 + 输入 状态 {loading && commands.length === 0 ? ( - + ) : commands.length === 0 ? ( - - 暂无命令记录 + + 暂无工具执行记录 ) : ( @@ -225,9 +227,14 @@ export default function CommandsPage() { {cmd.worker || "-"} + + + {cmd.tool || "-"} + + - {truncate(extractCommand(cmd.command), CMD_MAX_LEN)} + {truncate(toolInput(cmd.command), CMD_MAX_LEN)} @@ -256,6 +263,9 @@ export default function CommandsPage() { {selected.worker || "-"} + + {selected.tool || "-"} + {fmtTime(selected.created_at)} @@ -277,11 +287,11 @@ export default function CommandsPage() {
- 输入 Command + 输入 Input
-                    {extractCommand(selected.command)}
+                    {toolInput(selected.command)}
                   
diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx index e8de9ee..2be4866 100644 --- a/web/src/app/(main)/function/llm-records/page.tsx +++ b/web/src/app/(main)/function/llm-records/page.tsx @@ -14,6 +14,7 @@ import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { Table, TableBody, @@ -74,11 +75,39 @@ export default function LLMRecordsPage() { const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); + // Recording on/off toggle (settings.llm_record; default off). When off the + // backend records nothing. + const [recEnabled, setRecEnabled] = React.useState(false); + const [recBusy, setRecBusy] = React.useState(false); + // Inline detail panel (Burp-style split, not a dialog) const [selected, setSelected] = React.useState(null); const [detail, setDetail] = React.useState(null); const [detailLoading, setDetailLoading] = React.useState(false); + // Load recording toggle state on mount. + React.useEffect(() => { + let alive = true; + api + .settings() + .then((s) => { if (alive) setRecEnabled(!!s.llm_record); }) + .catch(() => {}); + return () => { alive = false; }; + }, []); + + const toggleRecording = async (on: boolean) => { + setRecBusy(true); + setRecEnabled(on); // optimistic + try { + const s = await api.setSettings({ llm_record: on }); + setRecEnabled(!!s.llm_record); + } catch { + setRecEnabled(!on); // revert on failure + } finally { + setRecBusy(false); + } + }; + // Debounce session filter. React.useEffect(() => { const t = setTimeout(() => setSessionQ(session.trim()), 300); @@ -168,6 +197,26 @@ export default function LLMRecordsPage() { + {/* Recording on/off — off means no LLM calls are recorded */} +
+ + +
+
{rangeStart}–{rangeEnd} / {total} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 09f3155..83c2af8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -450,7 +450,7 @@ export const api = { if (!r.ok) throw new Error(await r.text()); }, - // ---- commands (Bash execution history) ---- + // ---- commands (tool execution history, any tool) ---- commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => { const sp = new URLSearchParams(); if (params?.task) sp.set("task", params.task); diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 7e1157e..5e7f89e 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -464,6 +464,7 @@ export const tools: Tool[] = [ // ── Settings ───────────────────────────────────────────────────────────────── export const settings: Settings = { traffic_capture: true, + llm_record: false, web_search_enabled: true, web_search_backend: "ddgs", brave_key_set: false, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 1c31573..8ca2091 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -338,6 +338,7 @@ export interface TrafficDetail { // ---- App settings (runtime toggles) ---- export interface Settings { traffic_capture: boolean; + llm_record: boolean; // LLM 录制开关(默认关);关闭时不记录任何 LLM 调用 // Web search. brave_key_set / tavily_key_set reflect whether a key is stored // (the values are never returned). On PUT, send the corresponding field to set/clear. web_search_enabled: boolean; @@ -555,7 +556,8 @@ export interface CommandRecord { id: number; exploration_id: number; worker: string; - command: string; + tool: string; + command: string; // raw tool input (JSON) output: string; is_error: boolean; created_at: string; diff --git a/web/src/navigation/sidebar/sidebar-items.ts b/web/src/navigation/sidebar/sidebar-items.ts index fe699ad..2cb8b6a 100644 --- a/web/src/navigation/sidebar/sidebar-items.ts +++ b/web/src/navigation/sidebar/sidebar-items.ts @@ -68,7 +68,7 @@ export const sidebarItems: NavGroup[] = [ { id: "tasks", title: "任务", url: "/function/tasks", icon: Target }, { id: "findings", title: "发现", url: "/function/findings", icon: Bug }, { id: "traffic", title: "流量", url: "/function/traffic", icon: Activity }, - { id: "commands", title: "命令", url: "/function/commands", icon: Terminal }, + { id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal }, { id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio }, { id: "assets", title: "资产", url: "/function/assets", icon: Network }, { id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },