This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new d86cfa4  feat: support deep link
d86cfa4 is described below

commit d86cfa429fd4a7d63e2cc8b9115eaafb2573e0be
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 12:21:10 2026 +0800

    feat: support deep link
---
 agenthistory/history.go                           | 367 ++++++++++++++++++++++
 controllers/agent.go                              |  68 +++-
 controllers/provider.go                           |  24 ++
 object/provider_link.go                           | 119 +++++++
 routers/router.go                                 |   1 +
 web/src/App.tsx                                   |  18 +-
 web/src/backend/ProviderBackend.ts                |   5 +
 web/src/components/ProviderSourcePicker.tsx       |  55 +++-
 web/src/components/agent-config/detail-dialog.tsx |   2 +-
 web/src/components/shared/app-sidebar.tsx         | 203 +++++++-----
 web/src/components/shared/misc.tsx                |   4 +-
 web/src/components/ui/alert.tsx                   |   2 +-
 web/src/lib/agent-configs.ts                      |   7 +-
 web/src/locales/en/data.json                      |   9 +
 web/src/locales/zh/data.json                      |  11 +-
 web/src/nav.ts                                    |   4 +-
 web/src/pages/AgentConfigsPage.tsx                |   6 +-
 web/src/pages/AgentDashboardPage.tsx              |   4 +-
 web/src/pages/AgentDetailPage.tsx                 |   2 +-
 web/src/pages/AgentSessionsPage.tsx               |  45 ++-
 web/src/pages/CertEditPage.tsx                    |   2 +-
 web/src/pages/DashboardPage.tsx                   |   6 +-
 web/src/pages/LlmRecordsPage.tsx                  |   4 +-
 web/src/pages/ProviderListPage.tsx                |  25 +-
 web/src/types.ts                                  |   6 +
 25 files changed, 876 insertions(+), 123 deletions(-)

diff --git a/agenthistory/history.go b/agenthistory/history.go
new file mode 100644
index 0000000..87e3c6f
--- /dev/null
+++ b/agenthistory/history.go
@@ -0,0 +1,367 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package agenthistory reads the conversation transcripts agents already keep
+// on disk. Monitoring only ever sees what happens after it is turned on, while
+// these files are the sessions that already happened, so the Sessions page has
+// something to show on the first day.
+package agenthistory
+
+import (
+       "bufio"
+       "encoding/json"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+       "sync"
+       "time"
+)
+
+// Session is one transcript, described without its contents.
+type Session struct {
+       Agent       string `json:"agent"`
+       SessionKey  string `json:"sessionKey"`
+       Title       string `json:"title"`
+       RecordCount int    `json:"recordCount"`
+       FirstTime   string `json:"firstTime"`
+       LastTime    string `json:"lastTime"`
+       // Path is where the transcript is, so the page can say where it came 
from.
+       Path string `json:"path"`
+       // Cwd is the directory the agent worked in, when the transcript 
records one.
+       Cwd string `json:"cwd"`
+       // Historical marks a session read off disk rather than collected by 
monitoring.
+       Historical bool `json:"historical"`
+}
+
+const (
+       // maxTranscripts bounds a scan: a heavy user has thousands of these, 
and the
+       // newest are the ones worth reading.
+       maxTranscripts = 500
+       // maxLineBytes skips a line longer than this rather than holding it in 
memory.
+       // A single tool result can be megabytes.
+       maxLineBytes = 1 << 20
+       // maxTitleRunes keeps a title to something a table cell can show.
+       maxTitleRunes = 120
+)
+
+// transcriptDirs are where each agent keeps its own transcripts, relative to a
+// home directory. Both write one JSONL file per session.
+var transcriptDirs = []struct {
+       agent string
+       parts []string
+}{
+       {"claude-code", []string{".claude", "projects"}},
+       {"codex-cli", []string{".codex", "sessions"}},
+}
+
+type cacheKey struct {
+       path string
+       size int64
+       unix int64
+}
+
+var (
+       cacheMutex sync.Mutex
+       cache      = map[cacheKey]Session{}
+       scans      = map[string]scanResult{}
+)
+
+// scanTTL is how long a whole scan stands. The Sessions page polls for live
+// records, and walking thousands of files at that rate would be the most
+// expensive thing Gateway does.
+const scanTTL = 30 * time.Second
+
+type scanResult struct {
+       sessions []Session
+       at       time.Time
+}
+
+// Scan reads the transcripts under one home directory. Results are cached per
+// file until it changes, and the whole walk is cached briefly, so polling the
+// page re-reads nothing.
+func Scan(home string) []Session {
+       cacheMutex.Lock()
+       if previous, found := scans[home]; found && time.Since(previous.at) < 
scanTTL {
+               cacheMutex.Unlock()
+               return previous.sessions
+       }
+       cacheMutex.Unlock()
+
+       sessions := scan(home)
+
+       cacheMutex.Lock()
+       scans[home] = scanResult{sessions: sessions, at: time.Now()}
+       cacheMutex.Unlock()
+       return sessions
+}
+
+func scan(home string) []Session {
+       sessions := []Session{}
+       for _, source := range transcriptDirs {
+               root := filepath.Join(append([]string{home}, 
source.parts...)...)
+               for _, file := range newestTranscripts(root) {
+                       if session, ok := read(source.agent, file); ok {
+                               sessions = append(sessions, session)
+                       }
+               }
+       }
+
+       sort.SliceStable(sessions, func(left, right int) bool {
+               return sessions[left].LastTime > sessions[right].LastTime
+       })
+       return sessions
+}
+
+type transcript struct {
+       path string
+       info os.FileInfo
+}
+
+// newestTranscripts finds the .jsonl files under root, newest first and 
capped.
+func newestTranscripts(root string) []transcript {
+       found := []transcript{}
+       // A missing directory means the agent was never installed here, which 
is not
+       // an error worth reporting from a page that lists whatever it finds.
+       filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) 
error {
+               if err != nil || entry.IsDir() || 
!strings.HasSuffix(entry.Name(), ".jsonl") {
+                       return nil
+               }
+               info, err := entry.Info()
+               if err != nil {
+                       return nil
+               }
+               found = append(found, transcript{path: path, info: info})
+               return nil
+       })
+
+       sort.SliceStable(found, func(left, right int) bool {
+               return 
found[left].info.ModTime().After(found[right].info.ModTime())
+       })
+       if len(found) > maxTranscripts {
+               found = found[:maxTranscripts]
+       }
+       return found
+}
+
+func read(agent string, file transcript) (Session, bool) {
+       key := cacheKey{path: file.path, size: file.info.Size(), unix: 
file.info.ModTime().UnixNano()}
+       cacheMutex.Lock()
+       cached, found := cache[key]
+       cacheMutex.Unlock()
+       if found {
+               return cached, true
+       }
+
+       session, ok := parse(agent, file)
+       if !ok {
+               return Session{}, false
+       }
+
+       cacheMutex.Lock()
+       cache[key] = session
+       cacheMutex.Unlock()
+       return session, true
+}
+
+// line is the union of the two formats: Claude Code writes the session id and
+// the message at the top level, Codex wraps everything in "payload".
+type line struct {
+       Type      string          `json:"type"`
+       Timestamp string          `json:"timestamp"`
+       SessionId string          `json:"sessionId"`
+       Cwd       string          `json:"cwd"`
+       Message   json.RawMessage `json:"message"`
+       Payload   struct {
+               Id      string          `json:"id"`
+               Type    string          `json:"type"`
+               Role    string          `json:"role"`
+               Cwd     string          `json:"cwd"`
+               Content json.RawMessage `json:"content"`
+       } `json:"payload"`
+}
+
+func parse(agent string, file transcript) (Session, bool) {
+       handle, err := os.Open(file.path)
+       if err != nil {
+               return Session{}, false
+       }
+       defer handle.Close()
+
+       session := Session{
+               Agent:      agent,
+               Path:       file.path,
+               LastTime:   file.info.ModTime().Local().Format(time.RFC3339),
+               Historical: true,
+       }
+       // The file name is the session id for Claude Code, and carries it for 
Codex;
+       // the transcript itself overrides this when it names one.
+       session.SessionKey = sessionKeyFromName(file.info.Name())
+
+       scanner := bufio.NewScanner(handle)
+       scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
+       for scanner.Scan() {
+               var entry line
+               if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
+                       continue
+               }
+
+               if entry.SessionId != "" {
+                       session.SessionKey = entry.SessionId
+               }
+               if entry.Type == "session_meta" && entry.Payload.Id != "" {
+                       session.SessionKey = entry.Payload.Id
+               }
+               if session.Cwd == "" {
+                       session.Cwd = firstNonEmpty(entry.Cwd, 
entry.Payload.Cwd)
+               }
+               if when := strings.TrimSpace(entry.Timestamp); when != "" {
+                       if session.FirstTime == "" {
+                               session.FirstTime = when
+                       }
+                       session.LastTime = when
+               }
+
+               if role, content := roleAndContent(entry); role != "" {
+                       session.RecordCount++
+                       if session.Title == "" && role == "user" {
+                               session.Title = usableTitle(content)
+                       }
+               }
+       }
+
+       if session.SessionKey == "" {
+               return Session{}, false
+       }
+       if session.FirstTime == "" {
+               session.FirstTime = session.LastTime
+       }
+       return session, true
+}
+
+// roleAndContent picks the messages out of a transcript, in either format. 
Only
+// the conversation counts: the tool calls and events around it are what
+// monitoring is for.
+func roleAndContent(entry line) (string, json.RawMessage) {
+       if entry.Type == "user" || entry.Type == "assistant" {
+               var message struct {
+                       Role    string          `json:"role"`
+                       Content json.RawMessage `json:"content"`
+               }
+               if err := json.Unmarshal(entry.Message, &message); err != nil {
+                       return entry.Type, nil
+               }
+               return firstNonEmpty(message.Role, entry.Type), message.Content
+       }
+
+       if entry.Type == "response_item" && entry.Payload.Type == "message" {
+               // A Codex transcript replays the whole prompt, including the 
instructions
+               // the agent sends itself. Those are not part of the 
conversation.
+               if entry.Payload.Role == "developer" || entry.Payload.Role == 
"system" {
+                       return "", nil
+               }
+               return entry.Payload.Role, entry.Payload.Content
+       }
+       return "", nil
+}
+
+// preambles start the context an agent injects into the first user turn. A
+// session is remembered by what was asked, so a turn opening with one of these
+// is passed over and the next one is tried.
+var preambles = []string{
+       "<",
+       "Here is a list of plugins",
+       "Caveat:",
+       "This session is being continued",
+       "<system-reminder>",
+       "<environment_details>",
+}
+
+// usableTitle returns the message only when it reads like something a person
+// typed, so that the injected context does not become every session's name.
+func usableTitle(content json.RawMessage) string {
+       text := title(content)
+       for _, preamble := range preambles {
+               if strings.HasPrefix(text, preamble) {
+                       return ""
+               }
+       }
+       return text
+}
+
+// title turns a message body into one line. Both formats allow a plain string
+// or a list of typed parts, and only the text parts are worth showing.
+func title(content json.RawMessage) string {
+       if len(content) == 0 {
+               return ""
+       }
+
+       var text string
+       if err := json.Unmarshal(content, &text); err == nil {
+               return trimTitle(text)
+       }
+
+       var parts []struct {
+               Type string `json:"type"`
+               Text string `json:"text"`
+       }
+       if err := json.Unmarshal(content, &parts); err != nil {
+               return ""
+       }
+       for _, part := range parts {
+               if strings.TrimSpace(part.Text) != "" {
+                       return trimTitle(part.Text)
+               }
+       }
+       return ""
+}
+
+func trimTitle(text string) string {
+       text = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(text, 
"\r", " "), "\n", " "))
+       // A pasted file or a command wrapper starts with a tag the reader does 
not
+       // need, and it would fill the whole line.
+       if strings.HasPrefix(text, "<") {
+               if end := strings.Index(text, ">"); end > 0 && end < 
len(text)-1 {
+                       text = strings.TrimSpace(text[end+1:])
+               }
+       }
+
+       runes := []rune(text)
+       if len(runes) > maxTitleRunes {
+               return string(runes[:maxTitleRunes]) + "..."
+       }
+       return string(runes)
+}
+
+// sessionKeyFromName reads the id out of a transcript's file name:
+// "<uuid>.jsonl" for Claude Code, "rollout-<date>-<uuid>.jsonl" for Codex.
+func sessionKeyFromName(name string) string {
+       base := strings.TrimSuffix(name, ".jsonl")
+       const uuidLength = 36
+       if len(base) >= uuidLength {
+               if candidate := base[len(base)-uuidLength:]; 
strings.Count(candidate, "-") == 4 {
+                       return candidate
+               }
+       }
+       return base
+}
+
+func firstNonEmpty(values ...string) string {
+       for _, value := range values {
+               if strings.TrimSpace(value) != "" {
+                       return value
+               }
+       }
+       return ""
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 58a6a94..d72110b 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -18,10 +18,13 @@ import (
        "encoding/json"
        "net"
        "path/filepath"
+       "sort"
        "strconv"
        "strings"
 
        "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agenthistory"
+       "github.com/apache/casbin-gateway/agenthome"
        "github.com/apache/casbin-gateway/agentmonitor"
        "github.com/apache/casbin-gateway/agentpatch"
        "github.com/apache/casbin-gateway/agentprovider"
@@ -190,9 +193,68 @@ func (c *ApiController) GetAgentSessions() {
        if c.RequireAdmin() {
                return
        }
-       c.ResponseOk(agentmonitor.ListSessions(agentmonitor.RecordQuery{
-               Agent: c.Input().Get("agent"),
-       }))
+
+       agentId := c.Input().Get("agent")
+       live := agentmonitor.ListSessions(agentmonitor.RecordQuery{Agent: 
agentId})
+
+       // The transcripts on disk are the sessions that already happened, so 
they
+       // are listed next to the monitored ones rather than only after Patch.
+       sessions := make([]agenthistory.Session, 0, len(live))
+       seen := map[string]bool{}
+       for _, session := range live {
+               seen[sessionSeenKey(session.Agent, session.SessionKey)] = true
+               sessions = append(sessions, agenthistory.Session{
+                       Agent:       session.Agent,
+                       SessionKey:  session.SessionKey,
+                       Title:       session.Title,
+                       RecordCount: session.RecordCount,
+                       FirstTime:   session.FirstTime,
+                       LastTime:    session.LastTime,
+               })
+       }
+       for _, session := range historicalSessions(agentId) {
+               if seen[sessionSeenKey(session.Agent, session.SessionKey)] {
+                       continue
+               }
+               sessions = append(sessions, session)
+       }
+
+       sort.SliceStable(sessions, func(left, right int) bool {
+               return sessions[left].LastTime > sessions[right].LastTime
+       })
+       c.ResponseOk(sessions)
+}
+
+// sessionSeenKey identifies one session across the two sources, so a session
+// that monitoring already reported is not listed twice.
+func sessionSeenKey(agentId string, sessionKey string) string {
+       return agentId + "/" + sessionKey
+}
+
+// historicalSessions reads the transcripts of every account with an agent on
+// this machine. A home Gateway cannot open is skipped: the page lists what it
+// can read, and says nothing about the rest.
+func historicalSessions(agentId string) []agenthistory.Session {
+       installations, err := agent.Scan(false)
+       if err != nil {
+               return nil
+       }
+
+       sessions := []agenthistory.Session{}
+       scanned := map[string]bool{}
+       for _, installation := range installations {
+               home, err := agenthome.Resolve(installation.Owner)
+               if err != nil || scanned[home] {
+                       continue
+               }
+               scanned[home] = true
+               for _, session := range agenthistory.Scan(home) {
+                       if agentId == "" || strings.EqualFold(session.Agent, 
agentId) {
+                               sessions = append(sessions, session)
+                       }
+               }
+       }
+       return sessions
 }
 
 // AddAgentRecord accepts reports from a hook or MCP process launched locally 
by
diff --git a/controllers/provider.go b/controllers/provider.go
index 3eca23d..e1d56fd 100644
--- a/controllers/provider.go
+++ b/controllers/provider.go
@@ -158,6 +158,30 @@ func (c *ApiController) AddProvider() {
        c.ServeJSON()
 }
 
+// ParseProviderLink reads a vendor's "add this provider" link and answers with
+// what it would fill in, without storing anything: the link comes from a
+// website, so the person adding it sees the values first.
+func (c *ApiController) ParseProviderLink() {
+       if c.RequireSignedIn() {
+               return
+       }
+
+       var form struct {
+               Link string `json:"link"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       provider, err := object.ParseProviderLink(c.GetSessionUsername(), 
form.Link)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(provider)
+}
+
 // UpdateProvider updates an existing provider.
 func (c *ApiController) UpdateProvider() {
        if c.RequireSignedIn() {
diff --git a/object/provider_link.go b/object/provider_link.go
new file mode 100644
index 0000000..3d90377
--- /dev/null
+++ b/object/provider_link.go
@@ -0,0 +1,119 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "fmt"
+       "net/url"
+       "strings"
+)
+
+// providerLinkScheme is CC Switch's. Vendors already publish "add to CC 
Switch"
+// links in this format, so Gateway reads that one rather than inventing a
+// second one nobody would be given.
+const providerLinkScheme = "ccswitch"
+
+// providerLinkApps are the values CC Switch accepts in "app". Only the wire
+// format matters here, and only Claude speaks Anthropic's.
+var providerLinkApps = map[string]string{
+       "claude":    "anthropic",
+       "codex":     "openai",
+       "gemini":    "openai",
+       "grokbuild": "openai",
+       "opencode":  "openai",
+       "openclaw":  "openai",
+       "hermes":    "openai",
+}
+
+// ParseProviderLink turns a "ccswitch://v1/import?resource=provider&..." link
+// into a provider, filling in what the link says and leaving the rest to the
+// form. Nothing is stored: the caller shows the result and the person adding 
it
+// decides, because a link like this arrives from a vendor's website.
+func ParseProviderLink(owner string, raw string) (*Provider, error) {
+       parsed, err := url.Parse(strings.TrimSpace(raw))
+       if err != nil {
+               return nil, fmt.Errorf("this is not a link: %w", err)
+       }
+       if parsed.Scheme != providerLinkScheme {
+               return nil, fmt.Errorf("a provider link starts with %q, not 
%q", providerLinkScheme+"://", parsed.Scheme)
+       }
+       if parsed.Host != "v1" {
+               return nil, fmt.Errorf("unsupported link version: %s", 
parsed.Host)
+       }
+       if strings.Trim(parsed.Path, "/") != "import" {
+               return nil, fmt.Errorf("unsupported link path: %s", parsed.Path)
+       }
+
+       query := parsed.Query()
+       if resource := query.Get("resource"); resource != "provider" {
+               return nil, fmt.Errorf("this link carries a %q, which Gateway 
cannot import", resource)
+       }
+
+       app := query.Get("app")
+       protocol, known := providerLinkApps[app]
+       if !known {
+               return nil, fmt.Errorf("unknown app in the link: %q", app)
+       }
+
+       name := strings.TrimSpace(query.Get("name"))
+       if name == "" {
+               return nil, fmt.Errorf("the link does not say what to call this 
provider")
+       }
+
+       provider := &Provider{
+               Owner:       owner,
+               DisplayName: name,
+               Type:        protocol,
+               Status:      "enabled",
+               AuthMode:    ProviderAuthProvider,
+               BaseUrl:     firstEndpoint(query.Get("endpoint")),
+               ApiKey:      strings.TrimSpace(query.Get("apiKey")),
+               Models:      linkModels(query),
+               Notes:       query.Get("notes"),
+               Icon:        strings.TrimSpace(query.Get("icon")),
+       }
+       // The icon field takes a site to read a favicon from, which is what
+       // "homepage" is; CC Switch's own "icon" is a name from its icon set.
+       if provider.Icon == "" {
+               provider.Icon = strings.TrimSpace(query.Get("homepage"))
+       }
+       return provider, nil
+}
+
+// firstEndpoint takes the primary of the comma-separated list the format 
allows.
+func firstEndpoint(endpoint string) string {
+       for _, candidate := range strings.Split(endpoint, ",") {
+               if trimmed := strings.TrimSpace(candidate); trimmed != "" {
+                       return trimmed
+               }
+       }
+       return ""
+}
+
+// linkModels collects the model names the link carries. CC Switch names one
+// model per Claude tier; here they are all just models this provider serves.
+func linkModels(query url.Values) []string {
+       models := []string{}
+       seen := map[string]bool{}
+       for _, key := range []string{"model", "opusModel", "sonnetModel", 
"haikuModel"} {
+               name := strings.TrimSpace(query.Get(key))
+               if name == "" || seen[name] {
+                       continue
+               }
+               seen[name] = true
+               models = append(models, name)
+       }
+       return models
+}
diff --git a/routers/router.go b/routers/router.go
index 1a2ba3c..6f4bc06 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -109,6 +109,7 @@ func initAPI() {
        beego.Router("/api/get-providers", &controllers.ApiController{}, 
"GET:GetProviders")
        beego.Router("/api/get-provider", &controllers.ApiController{}, 
"GET:GetProvider")
        beego.Router("/api/add-provider", &controllers.ApiController{}, 
"POST:AddProvider")
+       beego.Router("/api/parse-provider-link", &controllers.ApiController{}, 
"POST:ParseProviderLink")
        beego.Router("/api/update-provider", &controllers.ApiController{}, 
"POST:UpdateProvider")
        beego.Router("/api/delete-provider", &controllers.ApiController{}, 
"POST:DeleteProvider")
        beego.Router("/api/get-provider-models", &controllers.ApiController{}, 
"POST:GetProviderModels")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index f99cb32..88c5039 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -23,7 +23,7 @@ import * as Conf from "@/Conf";
 import * as Setting from "@/Setting";
 import {findGroupOf, selectedKeyOf} from "@/nav";
 import {AppHeader} from "@/components/shared/app-header";
-import {AppSidebar, persistOpenKeys, readSavedOpenKeys} from 
"@/components/shared/app-sidebar";
+import {AppSidebar, persistOpenKeys, readSavedOpenKeys, useIsDesktop} from 
"@/components/shared/app-sidebar";
 import {Loading} from "@/components/shared/loading";
 import {TooltipProvider} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
@@ -77,6 +77,10 @@ export default function App() {
     return stored;
   });
   const [collapsed, setCollapsed] = React.useState(() => 
localStorage.getItem(collapsedKey) === "true");
+  // Below md the rail is a drawer, so the same header button opens it instead
+  // of narrowing a column that is not on screen.
+  const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
+  const isDesktop = useIsDesktop();
   const location = useLocation();
 
   const selectedKey = selectedKeyOf(location.pathname);
@@ -127,6 +131,10 @@ export default function App() {
   }, [openKeys, collapsed]);
 
   const toggleSidebar = () => {
+    if (!isDesktop) {
+      setMobileNavOpen(value => !value);
+      return;
+    }
     setCollapsed(value => {
       localStorage.setItem(collapsedKey, String(!value));
       return !value;
@@ -234,12 +242,14 @@ export default function App() {
           onOpenKeysChange={setOpenKeys}
           isAdmin={Setting.isAdminUser(account)}
           gatewayEnabled={gatewayEnabled}
+          mobileOpen={mobileNavOpen}
+          onMobileOpenChange={setMobileNavOpen}
         />
 
         <div
           className={cn(
-            "flex min-h-screen flex-col transition-[margin] duration-200",
-            collapsed ? "ml-16" : "ml-64",
+            "flex min-h-screen min-w-0 flex-col transition-[margin] 
duration-200",
+            collapsed ? "md:ml-16" : "md:ml-64",
           )}
         >
           <AppHeader
@@ -310,7 +320,7 @@ export default function App() {
                   path="/llm-records"
                   element={requireSignin(user => <LlmRecordsPage 
account={user} />)}
                 />
-                <Route path="/dashboard" element={requireSignin(() => 
<DashboardPage />)} />
+                <Route path="/proxy-analytics" element={requireSignin(() => 
<DashboardPage />)} />
               </Routes>
             </React.Suspense>
           </main>
diff --git a/web/src/backend/ProviderBackend.ts 
b/web/src/backend/ProviderBackend.ts
index ce06b22..a447452 100644
--- a/web/src/backend/ProviderBackend.ts
+++ b/web/src/backend/ProviderBackend.ts
@@ -41,6 +41,11 @@ export function addProvider(provider: Provider) {
   return request("/api/add-provider", "POST", provider);
 }
 
+/** What a vendor's "add this provider" link says, without storing any of it. 
*/
+export function parseProviderLink(link: string) {
+  return request<Provider>("/api/parse-provider-link", "POST", {link: link});
+}
+
 export function updateProvider(owner: string, name: string, provider: 
Provider) {
   return request(`/api/update-provider${query({id: itemId(owner, name)})}`, 
"POST", provider);
 }
diff --git a/web/src/components/ProviderSourcePicker.tsx 
b/web/src/components/ProviderSourcePicker.tsx
index cab35fd..284e593 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -13,10 +13,11 @@
 // limitations under the License.
 
 import * as React from "react";
-import {KeyRound, LogIn, Search, Settings2} from "lucide-react";
+import {KeyRound, Link2, LogIn, Search, Settings2} from "lucide-react";
 import i18next from "i18next";
 
 import {ProviderIcon} from "@/components/ProviderIcon";
+import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
 import {
   providerSources,
@@ -93,8 +94,25 @@ function SourceCard({source, onPick}: {source: 
ProviderSource; onPick: (source:
  * hosting other people's, a site reselling many — because that is the thing
  * worth knowing about one, and there are too many of them to read as one list.
  */
-export function ProviderSourcePicker({onPick}: {onPick: (source: 
ProviderSource) => void}) {
+export function ProviderSourcePicker({
+  onPick,
+  onLink,
+}: {
+  onPick: (source: ProviderSource) => void;
+  /** A vendor's own "add this provider" link, pasted rather than typed out. */
+  onLink?: (link: string) => Promise<void>;
+}) {
   const [query, setQuery] = React.useState("");
+  const [link, setLink] = React.useState("");
+  const [importing, setImporting] = React.useState(false);
+
+  const importLink = () => {
+    if (onLink === undefined || link.trim() === "") {
+      return;
+    }
+    setImporting(true);
+    onLink(link.trim()).then(() => setImporting(false));
+  };
 
   const needle = query.trim().toLowerCase();
   const matches = (source: ProviderSource) =>
@@ -126,8 +144,37 @@ export function ProviderSourcePicker({onPick}: {onPick: 
(source: ProviderSource)
         </div>
       </div>
 
+      {onLink === undefined ? null : (
+        <div className="grid gap-1.5 rounded-lg border border-dashed p-3">
+          <span className="flex items-center gap-2 text-sm font-medium">
+            <Link2 className="size-4" />
+            {i18next.t("provider:Paste a provider link")}
+          </span>
+          <div className="flex flex-wrap gap-2">
+            <Input
+              value={link}
+              onChange={event => setLink(event.target.value)}
+              onKeyDown={event => {
+                if (event.key === "Enter") {
+                  event.preventDefault();
+                  importLink();
+                }
+              }}
+              placeholder="ccswitch://v1/import?resource=provider&..."
+              className="min-w-0 flex-1"
+            />
+            <Button type="button" variant="outline" loading={importing} 
onClick={importLink}>
+              {i18next.t("provider:Read the link")}
+            </Button>
+          </div>
+          <span className="text-muted-foreground text-xs">
+            {i18next.t("provider:Paste a provider link hint")}
+          </span>
+        </div>
+      )}
+
       {leading.length === 0 ? null : (
-        <div className="grid gap-3 sm:grid-cols-2">
+        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
           {leading.map(source => (
             <SourceCard key={source.key} source={source} onPick={onPick} />
           ))}
@@ -139,7 +186,7 @@ export function ProviderSourcePicker({onPick}: {onPick: 
(source: ProviderSource)
           <span className="text-muted-foreground text-xs font-medium uppercase 
tracking-wide">
             {categoryTitle(group.category)}
           </span>
-          <div className="grid gap-3 sm:grid-cols-2">
+          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
             {group.sources.map(source => (
               <SourceCard key={source.key} source={source} onPick={onPick} />
             ))}
diff --git a/web/src/components/agent-config/detail-dialog.tsx 
b/web/src/components/agent-config/detail-dialog.tsx
index 9442bdc..5b373e2 100644
--- a/web/src/components/agent-config/detail-dialog.tsx
+++ b/web/src/components/agent-config/detail-dialog.tsx
@@ -110,7 +110,7 @@ export function DetailDialog({
                   {item.digest}
                 </Badge>
               </SimpleTooltip>
-              {item.modified ? (
+              {formatModified(item.modified) ? (
                 <span className="text-muted-foreground">
                   {`${i18next.t("agentConfig:Changed")} 
${formatModified(item.modified)}`}
                 </span>
diff --git a/web/src/components/shared/app-sidebar.tsx 
b/web/src/components/shared/app-sidebar.tsx
index ac7aeb7..61dbd8b 100644
--- a/web/src/components/shared/app-sidebar.tsx
+++ b/web/src/components/shared/app-sidebar.tsx
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import * as React from "react";
 import {Link} from "react-router-dom";
 import {ChevronDown, type LucideIcon} from "lucide-react";
 import i18next from "i18next";
@@ -45,6 +46,23 @@ export function persistOpenKeys(keys: string[]) {
   }
 }
 
+/** Tailwind's md breakpoint, in JS, for the decisions CSS cannot make. */
+export function useIsDesktop() {
+  const [isDesktop, setIsDesktop] = React.useState(
+    () => typeof window === "undefined" || window.matchMedia("(min-width: 
768px)").matches,
+  );
+
+  React.useEffect(() => {
+    const query = window.matchMedia("(min-width: 768px)");
+    const update = () => setIsDesktop(query.matches);
+    update();
+    query.addEventListener("change", update);
+    return () => query.removeEventListener("change", update);
+  }, []);
+
+  return isDesktop;
+}
+
 function NavLink({
   to,
   active,
@@ -98,6 +116,8 @@ export function AppSidebar({
   onOpenKeysChange,
   isAdmin,
   gatewayEnabled,
+  mobileOpen,
+  onMobileOpenChange,
 }: {
   collapsed: boolean;
   selectedKey: string;
@@ -105,7 +125,15 @@ export function AppSidebar({
   onOpenKeysChange: (keys: string[]) => void;
   isAdmin: boolean;
   gatewayEnabled: boolean;
+  /** On a narrow screen the rail is a drawer instead, and this is whether it 
is out. */
+  mobileOpen: boolean;
+  onMobileOpenChange: (open: boolean) => void;
 }) {
+  // Below md the rail is a full-width drawer, where icons without labels would
+  // be a worse menu than the one there is room for.
+  const isDesktop = useIsDesktop();
+  const railCollapsed = collapsed && isDesktop;
+
   const toggleGroup = (key: string) => {
     onOpenKeysChange(openKeys.includes(key) ? openKeys.filter(item => item !== 
key) : [...openKeys, key]);
   };
@@ -118,88 +146,105 @@ export function AppSidebar({
     .filter(group => !group.children || group.children.length > 0);
 
   return (
-    <aside
-      className={cn(
-        "bg-sidebar fixed inset-y-0 left-0 z-40 flex flex-col border-r 
transition-[width] duration-200",
-        collapsed ? "w-16" : "w-64",
-      )}
-    >
-      <div className={cn("flex h-13 shrink-0 items-center border-b", collapsed 
? "justify-center px-0" : "px-5")}>
-        <Link to="/" className="flex items-center overflow-hidden">
-          <img
-            src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
-            alt="Casbin Gateway"
-            className={cn("w-auto object-contain transition-all", collapsed ? 
"h-5" : "h-7 max-w-[150px]")}
-          />
-        </Link>
-      </div>
-
-      <nav className="scrollbar-thin flex-1 space-y-0.5 overflow-y-auto p-2">
-        {groups.map(group => {
-          if (!group.children) {
-            return (
-              <NavLink
-                key={group.key}
-                to={group.path ?? group.key}
-                active={selectedKey === group.key}
-                collapsed={collapsed}
-                icon={group.icon}
-                label={i18next.t(group.label)}
-              />
-            );
-          }
+    <>
+      {/* Below md the rail slides over the page, so it needs something to
+          dismiss it that is not one of its own links. */}
+      <div
+        className={cn(
+          "fixed inset-0 z-30 bg-black/40 md:hidden",
+          mobileOpen ? "block" : "hidden",
+        )}
+        onClick={() => onMobileOpenChange(false)}
+      />
+      {/* Slid in and out on "left" rather than a transform: the rail is a
+          tooltip anchor, and a transformed ancestor moves those with it. */}
+      <aside
+        className={cn(
+          "bg-sidebar fixed inset-y-0 z-40 flex w-64 flex-col border-r 
transition-[left,width] duration-200 md:left-0",
+          collapsed ? "md:w-16" : "md:w-64",
+          mobileOpen ? "left-0" : "-left-64",
+        )}
+      >
+        <div className={cn("flex h-13 shrink-0 items-center border-b px-5", 
railCollapsed && "justify-center px-0")}>
+          <Link to="/" className="flex items-center overflow-hidden">
+            <img
+              src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
+              alt="Casbin Gateway"
+              className={cn("w-auto object-contain transition-all", 
railCollapsed ? "h-5" : "h-7 max-w-[150px]")}
+            />
+          </Link>
+        </div>
+
+        <nav
+          className="scrollbar-thin flex-1 space-y-0.5 overflow-y-auto p-2"
+          onClick={() => onMobileOpenChange(false)}
+        >
+          {groups.map(group => {
+            if (!group.children) {
+              return (
+                <NavLink
+                  key={group.key}
+                  to={group.path ?? group.key}
+                  active={selectedKey === group.key}
+                  collapsed={railCollapsed}
+                  icon={group.icon}
+                  label={i18next.t(group.label)}
+                />
+              );
+            }
+
+            const groupLabel = i18next.t(group.label);
+            const isOpen = openKeys.includes(group.key);
+            const hasActiveChild = group.children.some(child => child.key === 
selectedKey);
 
-          const groupLabel = i18next.t(group.label);
-          const isOpen = openKeys.includes(group.key);
-          const hasActiveChild = group.children.some(child => child.key === 
selectedKey);
+            if (railCollapsed) {
+              return (
+                <NavLink
+                  key={group.key}
+                  to={group.children[0].path}
+                  active={hasActiveChild}
+                  collapsed
+                  icon={group.icon}
+                  label={groupLabel}
+                />
+              );
+            }
 
-          if (collapsed) {
             return (
-              <NavLink
-                key={group.key}
-                to={group.children[0].path}
-                active={hasActiveChild}
-                collapsed
-                icon={group.icon}
-                label={groupLabel}
-              />
+              <div key={group.key}>
+                <button
+                  type="button"
+                  onClick={() => toggleGroup(group.key)}
+                  className={cn(
+                    "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 
text-sm transition-colors",
+                    hasActiveChild ? "text-sidebar-foreground font-medium" : 
"text-sidebar-foreground/70",
+                    "hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
+                  )}
+                  aria-expanded={isOpen}
+                >
+                  {group.icon ? <group.icon className="size-4 shrink-0" /> : 
null}
+                  <span className="flex-1 truncate 
text-left">{groupLabel}</span>
+                  <ChevronDown className={cn("size-3.5 shrink-0 
transition-transform", isOpen && "rotate-180")} />
+                </button>
+                {isOpen ? (
+                  <div className="mt-0.5 space-y-0.5">
+                    {group.children.map(child => (
+                      <NavLink
+                        key={child.key}
+                        to={child.path}
+                        active={selectedKey === child.key}
+                        collapsed={false}
+                        label={i18next.t(child.label)}
+                        nested
+                      />
+                    ))}
+                  </div>
+                ) : null}
+              </div>
             );
-          }
-
-          return (
-            <div key={group.key}>
-              <button
-                type="button"
-                onClick={() => toggleGroup(group.key)}
-                className={cn(
-                  "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 
text-sm transition-colors",
-                  hasActiveChild ? "text-sidebar-foreground font-medium" : 
"text-sidebar-foreground/70",
-                  "hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
-                )}
-                aria-expanded={isOpen}
-              >
-                {group.icon ? <group.icon className="size-4 shrink-0" /> : 
null}
-                <span className="flex-1 truncate text-left">{groupLabel}</span>
-                <ChevronDown className={cn("size-3.5 shrink-0 
transition-transform", isOpen && "rotate-180")} />
-              </button>
-              {isOpen ? (
-                <div className="mt-0.5 space-y-0.5">
-                  {group.children.map(child => (
-                    <NavLink
-                      key={child.key}
-                      to={child.path}
-                      active={selectedKey === child.key}
-                      collapsed={false}
-                      label={i18next.t(child.label)}
-                      nested
-                    />
-                  ))}
-                </div>
-              ) : null}
-            </div>
-          );
-        })}
-      </nav>
-    </aside>
+          })}
+        </nav>
+      </aside>
+    </>
   );
 }
diff --git a/web/src/components/shared/misc.tsx 
b/web/src/components/shared/misc.tsx
index 37b3c81..97366e6 100644
--- a/web/src/components/shared/misc.tsx
+++ b/web/src/components/shared/misc.tsx
@@ -100,8 +100,8 @@ export function CodeText({
   copyable?: boolean;
 }) {
   return (
-    <span className={cn("inline-flex max-w-full items-center gap-1", 
className)}>
-      <code className="bg-muted truncate rounded px-1.5 py-0.5 font-mono 
text-xs">{children}</code>
+    <span className={cn("inline-flex min-w-0 max-w-full items-center gap-1", 
className)}>
+      <code className="bg-muted min-w-0 truncate rounded px-1.5 py-0.5 
font-mono text-xs">{children}</code>
       {copyable ? <CopyButton value={children} /> : null}
     </span>
   );
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
index 3f3e513..86f653e 100644
--- a/web/src/components/ui/alert.tsx
+++ b/web/src/components/ui/alert.tsx
@@ -19,7 +19,7 @@ import {AlertCircleIcon, CheckCircle2Icon, InfoIcon, 
TriangleAlertIcon} from "lu
 import {cn} from "@/lib/utils";
 
 const alertVariants = cva(
-  "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg 
border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] 
has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5",
+  "relative grid w-full grid-cols-[0_minmax(0,1fr)] items-start gap-y-0.5 
rounded-lg border px-4 py-3 text-sm 
has-[>svg]:grid-cols-[calc(var(--spacing)*4)_minmax(0,1fr)] has-[>svg]:gap-x-3 
[&>svg]:size-4 [&>svg]:translate-y-0.5",
   {
     variants: {
       variant: {
diff --git a/web/src/lib/agent-configs.ts b/web/src/lib/agent-configs.ts
index 7197864..ce08c78 100644
--- a/web/src/lib/agent-configs.ts
+++ b/web/src/lib/agent-configs.ts
@@ -178,8 +178,13 @@ export function originTitle(item: AgentConfigItem) {
   return i18next.t("agentConfig:From a plugin detail").replace("{plugin}", 
item.origin || "");
 }
 
+/** 2000-01-01: below this an mtime was never really set. */
+const earliestPlausibleModified = 946684800;
+
 export function formatModified(modified: number | undefined) {
-  if (!modified) {
+  // Some packaging tools unpack files with the mtime zeroed, and "1/1/1970" as
+  // the date of a skill reads as a bug rather than as "nobody recorded one".
+  if (!modified || modified < earliestPlausibleModified) {
     return "";
   }
   return new Date(modified * 1000).toLocaleString();
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 1680ffb..b79b752 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -63,6 +63,7 @@
     "Fallback hint": "Providers tried, in the order picked, when the bound one 
cannot answer.",
     "Filter": "Filter",
     "First activity": "First activity",
+    "From the transcript": "From the transcript",
     "Gateway": "Gateway",
     "Gateway mode hint": "The agent is pointed at the local proxy, so 
switching to another provider takes effect on the next request without 
restarting the agent.",
     "Install Method": "Install Method",
@@ -78,6 +79,7 @@
     "Monitoring enabled": "Monitoring enabled",
     "Monitoring, no activity yet": "Monitoring, no activity yet",
     "No agent records yet - patch an agent to start collecting them": "No 
activity yet - turn on monitoring for an agent to start collecting it",
+    "No agent sessions yet": "No agent sessions yet",
     "No agent sessions yet - patch an agent to start collecting them": "No 
sessions yet - turn on monitoring for an agent to start collecting them",
     "No other provider speaks this API": "No other provider speaks this API",
     "No provider": "No provider",
@@ -114,6 +116,7 @@
     "Scan": "Rescan",
     "Session": "Session",
     "Session title": "Session title",
+    "Source": "Source",
     "Start": "Start",
     "Start app hint": "Opens the app on the machine Gateway runs on.",
     "Start console hint": "Opens a console window running the CLI on the 
machine Gateway runs on.",
@@ -344,6 +347,8 @@
     "Password": "Password",
     "Path": "Path",
     "Previous": "Previous",
+    "Proxy Analytics": "Proxy Analytics",
+    "Proxy Log": "Proxy Log",
     "Records": "Records",
     "Refresh": "Refresh",
     "Request Count Over Time": "Request Count Over Time",
@@ -547,6 +552,8 @@
     "Notes hint": "For whoever reads this later: which account the key belongs 
to, when it expires, who pays for it.",
     "Nothing to test": "There is nothing to test yet",
     "Page description": "One endpoint in front of your model vendors. Gateway 
holds the API key, so the agents never have it — or forwards the agent's own 
sign-in and holds nothing.",
+    "Paste a provider link": "Paste a provider link",
+    "Paste a provider link hint": "Vendors publish these as \"add to CC 
Switch\" buttons. Gateway reads the same link and fills the form in below, and 
stores nothing until you confirm.",
     "Path hint": "A dotted path into the answer, where a number indexes an 
array: balance_infos.0.total_balance.",
     "Preset": "Preset",
     "Preset hint": "Fills in the paths for a family of sites, so they do not 
have to be read out of the vendor's API documentation.",
@@ -558,6 +565,7 @@
     "Provider not found": "Provider not found",
     "Provider saved": "Provider saved",
     "Providers": "Providers",
+    "Read the link": "Read the link",
     "Recovering": "Recovering",
     "Refresh balances": "Refresh balances",
     "Remaining": "Remaining",
@@ -576,6 +584,7 @@
     "Switch hint": "Point an agent at this provider",
     "Switched": "Switched",
     "Test Connectivity": "Test Connectivity",
+    "This link cannot be read": "This link cannot be read",
     "Type": "Type",
     "Usage": "Usage",
     "Usage hint": "Point any client that speaks this provider's API at Gateway 
with these variables.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b0e7bc4..01a2899 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -63,6 +63,7 @@
     "Fallback hint": "当绑定的供应商无法响应时,按所选顺序依次尝试的备用供应商。",
     "Filter": "筛选",
     "First activity": "首次活动",
+    "From the transcript": "来自会话记录",
     "Gateway": "网关",
     "Gateway mode hint": "把 Agent 指向本地代理,切换供应商在下一次请求即生效,无需重启 Agent。",
     "Install Method": "安装方式",
@@ -73,11 +74,12 @@
     "Last {count} records": "最近 {count} 条记录",
     "MCP target": "MCP 目标",
     "Model": "模型",
-    "Monitored": "已开启监控",
+    "Monitored": "监控采集",
     "Monitoring disabled": "已关闭监控",
     "Monitoring enabled": "已开启监控",
     "Monitoring, no activity yet": "监控中,暂无活动",
     "No agent records yet - patch an agent to start collecting them": 
"还没有活动记录,开启某个智能体的监控后才会开始收集",
+    "No agent sessions yet": "还没有会话",
     "No agent sessions yet - patch an agent to start collecting them": 
"还没有会话,开启某个智能体的监控后才会开始收集",
     "No other provider speaks this API": "没有其他供应商使用相同的 API 协议",
     "No provider": "未设置供应商",
@@ -114,6 +116,7 @@
     "Scan": "重新扫描",
     "Session": "会话",
     "Session title": "会话标题",
+    "Source": "来源",
     "Start": "启动",
     "Start app hint": "在运行 Gateway 的机器上打开该应用。",
     "Start console hint": "在运行 Gateway 的机器上打开一个控制台窗口来运行该命令行。",
@@ -344,6 +347,8 @@
     "Password": "密码",
     "Path": "路径",
     "Previous": "上一页",
+    "Proxy Analytics": "代理统计",
+    "Proxy Log": "代理日志",
     "Records": "日志",
     "Refresh": "刷新",
     "Request Count Over Time": "请求数趋势",
@@ -547,6 +552,8 @@
     "Notes hint": "写给以后的自己:这个密钥属于哪个账号、什么时候过期、由谁付费。",
     "Nothing to test": "暂时没有可探测的内容",
     "Page description": "挡在模型厂商前面的统一入口。API Key 由 Gateway 保管,Agent 拿不到;也可以转发 
Agent 自己的登录,什么都不保管。",
+    "Paste a provider link": "粘贴供应商链接",
+    "Paste a provider link hint": "中转站通常把它做成“添加到 CC Switch”的按钮。Gateway 
读的是同一种链接,读完只会把下面的表单填好,你确认之前不会保存任何东西。",
     "Path hint": "返回内容里的点分路径,数字表示数组下标:balance_infos.0.total_balance。",
     "Preset": "预设",
     "Preset hint": "为某一类站点填好字段路径,省去翻厂商 API 文档。",
@@ -558,6 +565,7 @@
     "Provider not found": "供应商不存在",
     "Provider saved": "供应商已保存",
     "Providers": "供应商",
+    "Read the link": "读取链接",
     "Recovering": "恢复中",
     "Refresh balances": "刷新余额",
     "Remaining": "余额",
@@ -576,6 +584,7 @@
     "Switch hint": "把某个智能体指向这个供应商",
     "Switched": "已切换",
     "Test Connectivity": "测试连接",
+    "This link cannot be read": "这个链接读不出来",
     "Type": "类型",
     "Usage": "使用方法",
     "Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 854d057..7b7fdfe 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -90,8 +90,8 @@ export const navGroups: NavGroup[] = [
       {key: "/certs", label: "general:Certs", path: "/certs", icon: 
ShieldCheck},
       {key: "/rules", label: "general:Rules", path: "/rules", icon: 
ListFilter},
       {key: "/nodes", label: "general:Nodes", path: "/nodes", icon: Server},
-      {key: "/records", label: "general:Records", path: "/records", icon: 
ScrollText},
-      {key: "/dashboard", label: "general:Gateway Analytics", path: 
"/dashboard", icon: ChartColumn},
+      {key: "/records", label: "general:Proxy Log", path: "/records", icon: 
ScrollText},
+      {key: "/proxy-analytics", label: "general:Proxy Analytics", path: 
"/proxy-analytics", icon: ChartColumn},
     ],
   },
   {key: "/settings", label: "setting:Settings", icon: Settings, path: 
"/settings", adminOnly: true},
diff --git a/web/src/pages/AgentConfigsPage.tsx 
b/web/src/pages/AgentConfigsPage.tsx
index 3128282..296dd94 100644
--- a/web/src/pages/AgentConfigsPage.tsx
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -306,7 +306,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
           <span className="text-muted-foreground text-xs">
             {counted(record.files ?? 0, "agentConfig:1 file", 
"agentConfig:{files} files", "{files}")}
             {record.bytes ? ` · ${formatBytes(record.bytes)}` : ""}
-            {record.modified ? (
+            {formatModified(record.modified) ? (
               <>
                 <br />
                 {`${i18next.t("agentConfig:Changed")} 
${formatModified(record.modified)}`}
@@ -481,8 +481,8 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
             </Tabs>
 
             {location ? (
-              <span className="flex items-center gap-2 text-xs">
-                <span 
className="text-muted-foreground">{i18next.t("agentConfig:Read from")}</span>
+              <span className="flex min-w-0 items-center gap-2 text-xs">
+                <span className="text-muted-foreground 
shrink-0">{i18next.t("agentConfig:Read from")}</span>
                 <CodeText copyable>{location}</CodeText>
                 {kind === "skill" && (source.skillsDirs?.length ?? 0) > 1 ? (
                   <SimpleTooltip
diff --git a/web/src/pages/AgentDashboardPage.tsx 
b/web/src/pages/AgentDashboardPage.tsx
index e6b5c71..0b49a18 100644
--- a/web/src/pages/AgentDashboardPage.tsx
+++ b/web/src/pages/AgentDashboardPage.tsx
@@ -289,7 +289,7 @@ export default function AgentDashboardPage({account}: 
{account: Account}) {
 
       {error ? <MessageAlert title={error} /> : null}
 
-      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
         <StatCard label={i18next.t("agent:Installed")} value={agents.length} 
icon={Bot} />
         <StatCard label={i18next.t("agent:Monitored")} value={patchedCount} 
icon={ShieldCheck} tone="success" />
         <StatCard label={i18next.t("agent:Agent Sessions")} 
value={sessionCount} icon={MessageSquare} />
@@ -328,7 +328,7 @@ export default function AgentDashboardPage({account}: 
{account: Account}) {
           />
         </Card>
       ) : (
-        <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
+        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
           {agents.map(agent => (
             <AgentCard
               key={agentKey(agent)}
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index dbc0db5..544e46b 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -444,7 +444,7 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
         </Alert>
       )}
 
-      <div className="grid gap-4 lg:grid-cols-2">
+      <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
         <RuntimeCard
           agent={agent}
           status={runtimeOf(runtime, agent)}
diff --git a/web/src/pages/AgentSessionsPage.tsx 
b/web/src/pages/AgentSessionsPage.tsx
index 5edbe56..2cbcad8 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, MessageSquare, RefreshCw} from "lucide-react";
+import {Bot, FileText, MessageSquare, RefreshCw} from "lucide-react";
 import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
@@ -23,6 +23,7 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleTooltip} from "@/components/ui/tooltip";
 import {MessageAlert} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
@@ -73,13 +74,21 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
       key: "session",
       render: (_value, session) => (
         <div className="flex min-w-0 flex-col">
-          <Link
-            
to={`/agent-records?agent=${encodeURIComponent(session.agent)}&session=${encodeURIComponent(session.sessionKey)}`}
-            className="text-primary truncate font-medium hover:underline"
-          >
-            {session.title || session.sessionKey}
-          </Link>
-          <span className="text-muted-foreground truncate 
text-xs">{session.sessionKey}</span>
+          {/* A session read off disk has no monitoring records behind it, so
+              there is nothing for the link to open. */}
+          {session.historical ? (
+            <span className="truncate font-medium">{session.title || 
session.sessionKey}</span>
+          ) : (
+            <Link
+              
to={`/agent-records?agent=${encodeURIComponent(session.agent)}&session=${encodeURIComponent(session.sessionKey)}`}
+              className="text-primary truncate font-medium hover:underline"
+            >
+              {session.title || session.sessionKey}
+            </Link>
+          )}
+          <span className="text-muted-foreground truncate text-xs">
+            {session.cwd || session.sessionKey}
+          </span>
         </div>
       ),
     },
@@ -95,11 +104,27 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
         </Badge>
       ),
     },
+    {
+      title: i18next.t("agent:Source"),
+      key: "historical",
+      width: "150px",
+      render: (_value, session) =>
+        session.historical ? (
+          <SimpleTooltip title={session.path}>
+            <Badge variant="muted">
+              <FileText className="size-3" />
+              {i18next.t("agent:From the transcript")}
+            </Badge>
+          </SimpleTooltip>
+        ) : (
+          <Badge variant="success">{i18next.t("agent:Monitored")}</Badge>
+        ),
+    },
     {
       title: i18next.t("agent:Records"),
       key: "recordCount",
       dataIndex: "recordCount",
-      width: "120px",
+      width: "110px",
     },
     {
       title: i18next.t("agent:First activity"),
@@ -133,7 +158,7 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
         pageSize={20}
         searchable
         emptyIcon={MessageSquare}
-        emptyText={i18next.t("agent:No agent sessions yet - patch an agent to 
start collecting them")}
+        emptyText={i18next.t("agent:No agent sessions yet")}
         toolbar={
           <Button variant="outline" size="sm" onClick={load} loading={loading}>
             <RefreshCw />
diff --git a/web/src/pages/CertEditPage.tsx b/web/src/pages/CertEditPage.tsx
index a520907..5f476d5 100644
--- a/web/src/pages/CertEditPage.tsx
+++ b/web/src/pages/CertEditPage.tsx
@@ -148,7 +148,7 @@ export default function CertEditPage() {
         </Field>
       </Section>
 
-      <div className="grid gap-4 lg:grid-cols-2">
+      <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
         <Section
           title={i18next.t("cert:Certificate")}
           columns={1}
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index 2404d44..3ed2923 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -136,14 +136,14 @@ export default function DashboardPage() {
         </div>
       </div>
 
-      <div className="grid gap-4 lg:grid-cols-4">
+      <div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
         <StatisticCard title={i18next.t("general:Total Request Count")} 
value={totalRequestCount} />
         <div className="lg:col-span-3">
           <BarChartCard title="Request Count Over Time" 
data={requestCountOverTime} />
         </div>
       </div>
 
-      <div className="grid gap-4 lg:grid-cols-4">
+      <div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
         <div className="lg:col-span-3">
           <PieChartCard
             title="Sites"
@@ -153,7 +153,7 @@ export default function DashboardPage() {
         <StatisticCard title={i18next.t("general:Unique IP Count")} 
value={uniqueIpCount} />
       </div>
 
-      <div className="grid gap-4 lg:grid-cols-3">
+      <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
         <DataTable
           columns={[
             {
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
index 0c78aa1..70b9baf 100644
--- a/web/src/pages/LlmRecordsPage.tsx
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -528,7 +528,7 @@ export default function LlmRecordsPage({account}: {account: 
Account}) {
           title={i18next.t("llm:Recording is off")}
           description={i18next.t("llm:Recording is off detail")}
           action={
-            <div className="flex gap-2">
+            <div className="flex flex-wrap gap-2">
               <Button size="sm" variant="outline" onClick={() => 
setRecordMode("metadata")} loading={changingMode}>
                 <Play />
                 {i18next.t("llm:Record metadata")}
@@ -562,7 +562,7 @@ export default function LlmRecordsPage({account}: {account: 
Account}) {
       ) : null}
       {error ? <MessageAlert title={error} /> : null}
 
-      <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
         <StatCard
           label={i18next.t("llm:Requests")}
           value={(stats?.requests ?? 0).toLocaleString()}
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index eef1eff..55f0880 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -49,6 +49,7 @@ import {
   baseUrlPresets,
   customSource,
   providerSlug,
+  providerSources,
   usesClientAuth,
   type ProviderSource,
 } from "@/lib/providers";
@@ -228,6 +229,24 @@ export default function ProviderListPage({account}: 
{account: Account}) {
     setSource(picked);
   };
 
+  // A vendor's link fills the same form a card would, and then stops: what it
+  // carries — a base URL and often a key — is shown before anything is stored.
+  const importLink = (link: string) => {
+    return ProviderBackend.parseProviderLink(link)
+      .then(res => {
+        if (res.status !== "ok") {
+          Setting.showMessage("error", res.msg || i18next.t("provider:This 
link cannot be read"));
+          return;
+        }
+        const parsed = res.data;
+        setForm({...newProvider(account.name, parsed.displayName), ...parsed, 
name: providerSlug(parsed.displayName)});
+        setNameError("");
+        setSource(providerSources.find(item => item.key === customSource) ?? 
null);
+        setAddOpen(true);
+      })
+      .catch(error => Setting.showMessage("error", `${error}`));
+  };
+
   // The upstream is probed before the provider is stored, so a key that was
   // pasted wrong is caught here rather than by the first agent that uses it.
   const submitProvider = () => {
@@ -321,7 +340,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
             <CardDescription>{i18next.t("provider:No providers yet 
detail")}</CardDescription>
           </CardHeader>
           <CardContent>
-            <ProviderSourcePicker onPick={openAddDialog} />
+            <ProviderSourcePicker onPick={openAddDialog} onLink={importLink} />
           </CardContent>
         </Card>
       ) : (
@@ -342,7 +361,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
             </div>
           </div>
 
-          <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
+          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 
xl:grid-cols-3">
             {data.map(provider => (
               <ProviderGridCard
                 key={providerIdOf(provider)}
@@ -401,7 +420,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
         }
       >
         {source === null ? (
-          <ProviderSourcePicker onPick={pickSource} />
+          <ProviderSourcePicker onPick={pickSource} onLink={importLink} />
         ) : (
           <>
             <Field label={i18next.t("provider:Source")}>
diff --git a/web/src/types.ts b/web/src/types.ts
index c361fa5..af74e06 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -297,6 +297,12 @@ export interface AgentSession {
   recordCount: number;
   firstTime: string;
   lastTime: string;
+  /** Where the transcript is, for a session read off disk. */
+  path?: string;
+  /** The directory the agent was working in, when the transcript records one. 
*/
+  cwd?: string;
+  /** True when the session comes from the agent's own transcript, not 
monitoring. */
+  historical?: boolean;
 }
 
 export interface LlmRecord {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to