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]