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 1970fca fix: improve sessions page
1970fca is described below
commit 1970fca1bfed9e566a581d6a3ae2feeba8e99439
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 13:52:53 2026 +0800
fix: improve sessions page
---
agenthistory/history.go | 66 ++++++++++--
agenthistory/transcript.go | 202 +++++++++++++++++++++++++++++++++++
controllers/agent.go | 33 ++++++
routers/router.go | 1 +
web/src/App.tsx | 5 +
web/src/backend/AgentBackend.ts | 6 ++
web/src/locales/en/data.json | 11 ++
web/src/locales/zh/data.json | 11 ++
web/src/pages/AgentSessionPage.tsx | 206 ++++++++++++++++++++++++++++++++++++
web/src/pages/AgentSessionsPage.tsx | 24 ++---
web/src/types.ts | 22 ++++
11 files changed, 565 insertions(+), 22 deletions(-)
diff --git a/agenthistory/history.go b/agenthistory/history.go
index 87e3c6f..7642672 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -21,6 +21,8 @@ package agenthistory
import (
"bufio"
"encoding/json"
+ "errors"
+ "io"
"os"
"path/filepath"
"sort"
@@ -49,9 +51,11 @@ 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 is the longest line eachLine reads. A single tool
result can
+ // be megabytes, and one of those is passed over rather than held in
memory.
maxLineBytes = 1 << 20
+ // readBufferBytes is how much of a line is read at a time.
+ readBufferBytes = 64 << 10
// maxTitleRunes keeps a title to something a table cell can show.
maxTitleRunes = 120
)
@@ -192,13 +196,55 @@ type line struct {
} `json:"payload"`
}
-func parse(agent string, file transcript) (Session, bool) {
- handle, err := os.Open(file.path)
+// eachLine visits every line of a JSONL transcript and reports how many were
+// passed over for being longer than maxLineBytes. bufio.Scanner is not used
+// here because it ends the whole read at the first such line, which would turn
+// one huge tool result into a session that looks four messages long.
+//
+// The bytes handed to visit are reused for the next line, so a caller that
+// keeps them has to copy them first.
+func eachLine(path string, visit func(data []byte)) (int, error) {
+ handle, err := os.Open(path)
if err != nil {
- return Session{}, false
+ return 0, err
}
defer handle.Close()
+ reader := bufio.NewReaderSize(handle, readBufferBytes)
+ buffer := []byte{}
+ skipping := false
+ skipped := 0
+ for {
+ chunk, err := reader.ReadSlice('\n')
+ partial := errors.Is(err, bufio.ErrBufferFull)
+ if err != nil && !partial && !errors.Is(err, io.EOF) {
+ return skipped, err
+ }
+
+ if !skipping {
+ buffer = append(buffer, chunk...)
+ if len(buffer) > maxLineBytes {
+ buffer, skipping = buffer[:0], true
+ skipped++
+ }
+ }
+
+ if partial {
+ continue
+ }
+
+ if !skipping && len(buffer) > 0 {
+ visit(buffer)
+ }
+ buffer, skipping = buffer[:0], false
+
+ if errors.Is(err, io.EOF) {
+ return skipped, nil
+ }
+ }
+}
+
+func parse(agent string, file transcript) (Session, bool) {
session := Session{
Agent: agent,
Path: file.path,
@@ -209,12 +255,10 @@ func parse(agent string, file transcript) (Session, bool)
{
// 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() {
+ if _, err := eachLine(file.path, func(data []byte) {
var entry line
- if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
- continue
+ if err := json.Unmarshal(data, &entry); err != nil {
+ return
}
if entry.SessionId != "" {
@@ -239,6 +283,8 @@ func parse(agent string, file transcript) (Session, bool) {
session.Title = usableTitle(content)
}
}
+ }); err != nil {
+ return Session{}, false
}
if session.SessionKey == "" {
diff --git a/agenthistory/transcript.go b/agenthistory/transcript.go
new file mode 100644
index 0000000..8ab1e96
--- /dev/null
+++ b/agenthistory/transcript.go
@@ -0,0 +1,202 @@
+// 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
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// Transcript is one session read in full: the description a scan already has,
+// and the conversation inside the file.
+type Transcript struct {
+ Session Session `json:"session"`
+ Messages []Message `json:"messages"`
+ Truncated bool `json:"truncated"`
+}
+
+// Message is one turn of the conversation, in the order the agent wrote it.
+type Message struct {
+ Role string `json:"role"`
+ Time string `json:"time"`
+ Blocks []Block `json:"blocks"`
+}
+
+// Block is one part of a message: prose, the model thinking aloud, a tool call
+// or what that call returned.
+type Block struct {
+ Kind string `json:"kind"`
+ Text string `json:"text"`
+ Tool string `json:"tool"`
+ IsError bool `json:"isError"`
+}
+
+const (
+ blockText = "text"
+ blockThinking = "thinking"
+ blockToolUse = "toolUse"
+ blockToolResult = "toolResult"
+ blockImage = "image"
+)
+
+const (
+ // maxMessages bounds one transcript, so that a session someone left
running
+ // for a week is not asked for in a single response.
+ maxMessages = 2000
+ // maxTextRunes and maxToolRunes cap what one block carries. The prose
is
+ // what the reader came for; a tool call's arguments and its output
only have
+ // to be recognisable.
+ maxTextRunes = 20000
+ maxToolRunes = 2000
+)
+
+// ReadTranscript reads the conversation out of a session a scan already found.
+// The path comes from that scan rather than from the request, so this cannot
be
+// pointed at an arbitrary file.
+//
+// The messages are the same ones Scan counts, so the number the Sessions page
+// shows is the number of messages this returns.
+func ReadTranscript(session Session) (Transcript, error) {
+ transcript := Transcript{Session: session, Messages: []Message{}}
+
+ // A line eachLine passed over is a message this cannot show, so it
counts
+ // towards the same warning as a session too long to send at once.
+ skipped, err := eachLine(session.Path, func(data []byte) {
+ var entry line
+ if err := json.Unmarshal(data, &entry); err != nil {
+ return
+ }
+
+ role, content := roleAndContent(entry)
+ if role == "" {
+ return
+ }
+ if len(transcript.Messages) >= maxMessages {
+ transcript.Truncated = true
+ return
+ }
+
+ transcript.Messages = append(transcript.Messages, Message{
+ Role: role,
+ Time: strings.TrimSpace(entry.Timestamp),
+ Blocks: blocks(content),
+ })
+ })
+ if err != nil {
+ return Transcript{}, err
+ }
+
+ if skipped > 0 {
+ transcript.Truncated = true
+ }
+ return transcript, nil
+}
+
+// contentPart is the union of the parts both formats put in a message body.
+type contentPart struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Thinking string `json:"thinking"`
+ Name string `json:"name"`
+ Input json.RawMessage `json:"input"`
+ Content json.RawMessage `json:"content"`
+ IsError bool `json:"is_error"`
+}
+
+// blocks turns the body of a message into the parts a reader sees. Both
formats
+// allow a plain string or a list of typed parts.
+func blocks(content json.RawMessage) []Block {
+ if len(content) == 0 {
+ return []Block{}
+ }
+
+ var text string
+ if err := json.Unmarshal(content, &text); err == nil {
+ return []Block{{Kind: blockText, Text: clip(text,
maxTextRunes)}}
+ }
+
+ var parts []contentPart
+ if err := json.Unmarshal(content, &parts); err != nil {
+ return []Block{{Kind: blockText, Text: clip(string(content),
maxToolRunes)}}
+ }
+
+ result := []Block{}
+ for _, part := range parts {
+ if block, ok := blockOf(part); ok {
+ result = append(result, block)
+ }
+ }
+ return result
+}
+
+func blockOf(part contentPart) (Block, bool) {
+ switch part.Type {
+ case "text", "input_text", "output_text":
+ // An empty part carries nothing to read. Claude Code writes
one for
+ // thinking it did not keep, and rendering those would be 200
headings
+ // with nothing under them.
+ if strings.TrimSpace(part.Text) == "" {
+ return Block{}, false
+ }
+ return Block{Kind: blockText, Text: clip(part.Text,
maxTextRunes)}, true
+ case "thinking", "reasoning":
+ // Claude Code keeps the signature of a thinking block and not
the words,
+ // so this is usually empty. The block still stands for a turn
that
+ // happened, which is why it is not dropped.
+ return Block{Kind: blockThinking, Text:
clip(firstNonEmpty(part.Thinking, part.Text), maxTextRunes)}, true
+ case "tool_use", "function_call":
+ return Block{Kind: blockToolUse, Tool: part.Name, Text:
clip(string(part.Input), maxToolRunes)}, true
+ case "tool_result", "function_call_output":
+ return Block{Kind: blockToolResult, Text:
clip(plainText(part.Content), maxToolRunes), IsError: part.IsError}, true
+ case "image", "input_image":
+ return Block{Kind: blockImage}, true
+ }
+ return Block{}, false
+}
+
+// plainText reads the text out of a tool result, which either is one string or
+// carries its text in a list of parts.
+func plainText(content json.RawMessage) string {
+ if len(content) == 0 {
+ return ""
+ }
+
+ var text string
+ if err := json.Unmarshal(content, &text); err == nil {
+ return text
+ }
+
+ var parts []contentPart
+ if err := json.Unmarshal(content, &parts); err != nil {
+ return string(content)
+ }
+
+ pieces := []string{}
+ for _, part := range parts {
+ if strings.TrimSpace(part.Text) != "" {
+ pieces = append(pieces, part.Text)
+ }
+ }
+ return strings.Join(pieces, "\n")
+}
+
+// clip keeps one block to something a page can render, marking what it
dropped.
+func clip(text string, limit int) string {
+ runes := []rune(text)
+ if len(runes) <= limit {
+ return text
+ }
+ return string(runes[:limit]) + "\n..."
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index d72110b..165383d 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -225,6 +225,39 @@ func (c *ApiController) GetAgentSessions() {
c.ResponseOk(sessions)
}
+// GetAgentSession reads one transcript in full, so that a session listed off
+// disk can be opened and read instead of only counted. The session is looked
up
+// by key among the ones a scan found, which is what keeps a request from
naming
+// a file of its own.
+func (c *ApiController) GetAgentSession() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ agentId := c.Input().Get("agent")
+ sessionKey := c.Input().Get("session")
+ if sessionKey == "" {
+ c.ResponseError("session is required")
+ return
+ }
+
+ for _, session := range historicalSessions(agentId) {
+ if session.SessionKey != sessionKey {
+ continue
+ }
+
+ transcript, err := agenthistory.ReadTranscript(session)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(transcript)
+ return
+ }
+
+ c.ResponseError("no transcript on disk for this session")
+}
+
// 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 {
diff --git a/routers/router.go b/routers/router.go
index 6f4bc06..925c395 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -75,6 +75,7 @@ func initAPI() {
beego.Router("/api/stop-agent", &controllers.ApiController{},
"POST:StopAgent")
beego.Router("/api/get-agent-records", &controllers.ApiController{},
"GET:GetAgentRecords")
beego.Router("/api/get-agent-sessions", &controllers.ApiController{},
"GET:GetAgentSessions")
+ beego.Router("/api/get-agent-session", &controllers.ApiController{},
"GET:GetAgentSession")
beego.Router("/api/add-agent-record", &controllers.ApiController{},
"POST:AddAgentRecord")
beego.Router("/api/get-agent-configs", &controllers.ApiController{},
"GET:GetAgentConfigs")
beego.Router("/api/get-agent-config-item",
&controllers.ApiController{}, "GET:GetAgentConfigItem")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 88c5039..514e489 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -32,6 +32,7 @@ import AgentConfigsPage from "@/pages/AgentConfigsPage";
import AgentDashboardPage from "@/pages/AgentDashboardPage";
import AgentDetailPage from "@/pages/AgentDetailPage";
import AgentRecordsPage from "@/pages/AgentRecordsPage";
+import AgentSessionPage from "@/pages/AgentSessionPage";
import AgentSessionsPage from "@/pages/AgentSessionsPage";
import AgentsPage from "@/pages/AgentsPage";
import AuthCallback from "@/pages/AuthCallback";
@@ -297,6 +298,10 @@ export default function App() {
path="/agent-sessions"
element={requireSignin(user => <AgentSessionsPage
account={user} />)}
/>
+ <Route
+ path="/agent-sessions/:sessionKey"
+ element={requireSignin(user => <AgentSessionPage
account={user} />)}
+ />
<Route path="/nodes" element={requireSignin(user =>
<NodeListPage account={user} />)} />
<Route path="/nodes/:owner/:nodeName"
element={requireSignin(() => <NodeEditPage />)} />
<Route path="/settings" element={requireSignin(user =>
<SettingPage account={user} />)} />
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 0a509d5..d7b7cf4 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -20,6 +20,7 @@ import type {
AgentRecord,
AgentRuntime,
AgentSession,
+ AgentTranscript,
} from "@/types";
export interface PatchTarget {
@@ -91,3 +92,8 @@ export function getAgentRecords(agent = "", eventType = "",
outcome = "", sessio
export function getAgentSessions(agent = "") {
return request<AgentSession[]>(`/api/get-agent-sessions${query({agent:
agent})}`);
}
+
+/** The conversation inside one session, read from the agent's own transcript.
*/
+export function getAgentSession(agent: string, session: string) {
+ return request<AgentTranscript>(`/api/get-agent-session${query({agent:
agent, session: session})}`);
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index b79b752..360cf96 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -51,9 +51,11 @@
"Direct": "Direct",
"Direct mode hint": "The provider's own base URL and key are written into
the agent's configuration. Switching to another provider rewrites the file, and
the agent has to be restarted to pick it up.",
"Duration": "Duration",
+ "Empty message": "Empty message",
"Event": "Event",
"Failed to get agent records": "Failed to get agent records",
"Failed to get agent sessions": "Failed to get agent sessions",
+ "Failed to read this transcript": "Failed to read this transcript",
"Failed to scan agents": "Failed to scan agents",
"Failed to start the agent": "Failed to start the agent",
"Failed to stop the agent": "Failed to stop the agent",
@@ -66,6 +68,7 @@
"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.",
+ "Image": "Image",
"Install Method": "Install Method",
"Install an AI agent on this machine, then scan again": "Install an AI
agent on this machine, then scan again",
"Installation": "Installation",
@@ -86,6 +89,7 @@
"No supported agents found": "No supported agents found",
"No usage yet": "No requests have been relayed for this agent yet",
"Not patched": "Not monitored",
+ "Not recorded": "not recorded",
"Not supported": "Not supported yet",
"Not written": "Not written",
"Patch": "Turn on monitoring",
@@ -125,10 +129,16 @@
"Stopped": "Stopped",
"Switch takes effect at once": "Picking another provider above rewrites
this file at once, so switching takes one click.",
"Target / Model": "Target / Model",
+ "Thinking": "Thinking",
+ "This transcript holds no conversation": "This transcript holds no
conversation",
"Time": "Time",
"Token hint": "Gateway authenticates upstream with the provider's own key.
This value is Gateway's own relay token, which a request from another machine
has to carry.",
"Tool": "Tool",
+ "Tool call": "Tool call",
+ "Tool error": "Tool error",
+ "Tool result": "Tool result",
"Tool use ID": "Tool use ID",
+ "Transcript truncated": "Some of this session was too large to show, so
what follows is not all of it",
"Turn on monitoring to collect activity": "Turn on monitoring to collect
activity",
"Unknown": "Unknown",
"Unpatch": "Turn off monitoring",
@@ -143,6 +153,7 @@
"Write confirm hint": "Gateway writes the selected provider into this
agent's own configuration file, replacing the file in one step. Restart any
session that is already running.",
"Write the configuration of {agent}?": "Write the configuration of
{agent}?",
"Written by Gateway": "Written by Gateway",
+ "{count} messages": "{count} messages",
"{count} sessions": "{count} sessions"
},
"agentConfig": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 01a2899..88f9a94 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -51,9 +51,11 @@
"Direct": "直连",
"Direct mode hint": "直接把该供应商的 Base URL 和密钥写入 Agent 配置文件。切换供应商会重写文件,且需要重启
Agent 才能生效。",
"Duration": "耗时",
+ "Empty message": "空消息",
"Event": "事件",
"Failed to get agent records": "获取 Agent 记录失败",
"Failed to get agent sessions": "获取 Agent 会话失败",
+ "Failed to read this transcript": "读取该会话记录失败",
"Failed to scan agents": "探测 Agent 失败",
"Failed to start the agent": "启动 Agent 失败",
"Failed to stop the agent": "停止 Agent 失败",
@@ -66,6 +68,7 @@
"From the transcript": "来自会话记录",
"Gateway": "网关",
"Gateway mode hint": "把 Agent 指向本地代理,切换供应商在下一次请求即生效,无需重启 Agent。",
+ "Image": "图片",
"Install Method": "安装方式",
"Install an AI agent on this machine, then scan again": "在本机安装一个 AI
智能体后,再扫描一次",
"Installation": "安装信息",
@@ -86,6 +89,7 @@
"No supported agents found": "没有发现本机安装的智能体",
"No usage yet": "该 Agent 还没有经过网关的请求",
"Not patched": "未开启",
+ "Not recorded": "未保留",
"Not supported": "暂不支持",
"Not written": "未写入",
"Patch": "开启监控",
@@ -125,10 +129,16 @@
"Stopped": "未运行",
"Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。",
"Target / Model": "目标 / 模型",
+ "Thinking": "思考",
+ "This transcript holds no conversation": "该会话记录里没有对话内容",
"Time": "时间",
"Token hint": "Gateway 是用供应商自己的密钥去认证上游的。这里的值是 Gateway
自己的中继令牌,来自其他机器的请求必须带上它。",
"Tool": "工具",
+ "Tool call": "工具调用",
+ "Tool error": "工具报错",
+ "Tool result": "工具返回",
"Tool use ID": "工具调用 ID",
+ "Transcript truncated": "这个会话有部分内容太大,没能全部展示出来",
"Turn on monitoring to collect activity": "开启监控后才会收集活动",
"Unknown": "未知",
"Unpatch": "关闭监控",
@@ -143,6 +153,7 @@
"Write confirm hint": "Gateway 会把所选供应商以该 Agent
自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
"Write the configuration of {agent}?": "写入 {agent} 的配置?",
"Written by Gateway": "已由 Gateway 写入",
+ "{count} messages": "{count} 条消息",
"{count} sessions": "{count} 个会话"
},
"agentConfig": {
diff --git a/web/src/pages/AgentSessionPage.tsx
b/web/src/pages/AgentSessionPage.tsx
new file mode 100644
index 0000000..924f4bd
--- /dev/null
+++ b/web/src/pages/AgentSessionPage.tsx
@@ -0,0 +1,206 @@
+// 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.
+
+import * as React from "react";
+import {Link, useParams, useSearchParams} from "react-router-dom";
+import {ArrowLeft, Bot, Image as ImageIcon, RefreshCw, Wrench} from
"lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {CodeBlock, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent} from "@/components/ui/card";
+import type {Account, AgentMessage, AgentMessageBlock, AgentTranscript} from
"@/types";
+
+const ROLE_VARIANTS: Record<string, BadgeVariant> = {
+ user: "info",
+ assistant: "success",
+ system: "warning",
+ developer: "warning",
+ tool: "muted",
+};
+
+function BlockView({block}: {block: AgentMessageBlock}) {
+ switch (block.kind) {
+ case "thinking":
+ return (
+ <div className="flex flex-wrap items-start gap-2">
+ <Badge variant="muted">{i18next.t("agent:Thinking")}</Badge>
+ {block.text ? (
+ <pre className="text-muted-foreground scrollbar-thin min-w-0 flex-1
overflow-x-auto text-xs leading-relaxed whitespace-pre-wrap italic">
+ {block.text}
+ </pre>
+ ) : (
+ <span className="text-muted-foreground
text-xs">{i18next.t("agent:Not recorded")}</span>
+ )}
+ </div>
+ );
+ case "toolUse":
+ return (
+ <div className="grid gap-1">
+ <Badge variant="warning">
+ <Wrench />
+ {block.tool || i18next.t("agent:Tool call")}
+ </Badge>
+ {block.text ? <CodeBlock maxHeight="16rem">{block.text}</CodeBlock> :
null}
+ </div>
+ );
+ case "toolResult":
+ return (
+ <div className="grid gap-1">
+ <Badge variant={block.isError ? "danger" : "muted"}>
+ {block.isError ? i18next.t("agent:Tool error") :
i18next.t("agent:Tool result")}
+ </Badge>
+ {block.text ? <CodeBlock maxHeight="16rem">{block.text}</CodeBlock> :
null}
+ </div>
+ );
+ case "image":
+ return (
+ <Badge variant="muted">
+ <ImageIcon />
+ {i18next.t("agent:Image")}
+ </Badge>
+ );
+ default:
+ return (
+ <pre className="scrollbar-thin overflow-x-auto text-xs leading-relaxed
whitespace-pre-wrap">{block.text}</pre>
+ );
+ }
+}
+
+function MessageView({message, index}: {message: AgentMessage; index: number})
{
+ return (
+ <div className="rounded-lg border">
+ <div className="bg-muted/40 flex flex-wrap items-center gap-2 border-b
px-3 py-1.5">
+ <Badge variant={ROLE_VARIANTS[message.role] ??
"secondary"}>{message.role}</Badge>
+ <span className="text-muted-foreground text-xs">#{index + 1}</span>
+ {message.time ? (
+ <span className="text-muted-foreground text-xs">{new
Date(message.time).toLocaleString()}</span>
+ ) : null}
+ </div>
+ <div className="grid gap-3 px-3 py-2">
+ {message.blocks.length === 0 ? (
+ <span className="text-muted-foreground
text-xs">{i18next.t("agent:Empty message")}</span>
+ ) : (
+ message.blocks.map((block, position) => <BlockView key={position}
block={block} />)
+ )}
+ </div>
+ </div>
+ );
+}
+
+/**
+ * One session, read out of the transcript the agent itself wrote. Monitoring
+ * only ever sees what happened after it was turned on, so on the first day
this
+ * is the only way to open anything on the Sessions page.
+ */
+export default function AgentSessionPage({account}: {account: Account}) {
+ const {sessionKey = ""} = useParams();
+ const [searchParams] = useSearchParams();
+ const agentId = searchParams.get("agent") ?? "";
+
+ const [transcript, setTranscript] = React.useState<AgentTranscript |
null>(null);
+ const [error, setError] = React.useState("");
+ const [loading, setLoading] = React.useState(false);
+ const isAdmin = Setting.isAdminUser(account);
+
+ const load = React.useCallback(() => {
+ if (!isAdmin || sessionKey === "") {
+ return;
+ }
+
+ setLoading(true);
+ AgentBackend.getAgentSession(agentId, sessionKey)
+ .then(res => {
+ if (res.status === "ok") {
+ setTranscript(res.data ?? null);
+ setError("");
+ } else {
+ setError(res.msg || i18next.t("agent:Failed to read this
transcript"));
+ }
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => setLoading(false));
+ }, [agentId, isAdmin, sessionKey]);
+
+ React.useEffect(() => {
+ load();
+ }, [load]);
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ const session = transcript?.session;
+ const messages = transcript?.messages ?? [];
+
+ return (
+ <PageContainer>
+ <PageHeader
+ title={session?.title || sessionKey}
+ description={session?.cwd || session?.path}
+ actions={
+ <>
+ <Button variant="outline" size="sm" asChild>
+ <Link to="/agent-sessions">
+ <ArrowLeft />
+ {i18next.t("agent:Agent Sessions")}
+ </Link>
+ </Button>
+ <Button variant="outline" size="sm" onClick={load}
loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
+ </Button>
+ </>
+ }
+ />
+
+ {error ? <MessageAlert title={error} /> : null}
+
+ {session ? (
+ <div className="flex flex-wrap items-center gap-2">
+ <Badge variant="info">
+ <AgentIcon agent={session.agent} fallback={<Bot className="size-3"
/>} size={12} />
+ {session.agent}
+ </Badge>
+ <Badge variant="muted">
+ {i18next.t("agent:{count} messages").replace("{count}",
String(messages.length))}
+ </Badge>
+ <span className="text-muted-foreground text-xs">{new
Date(session.lastTime).toLocaleString()}</span>
+ </div>
+ ) : null}
+
+ {transcript?.truncated ? <MessageAlert
title={i18next.t("agent:Transcript truncated")} /> : null}
+
+ {transcript !== null && messages.length === 0 ? (
+ <Card>
+ <CardContent className="text-muted-foreground py-8 text-center
text-sm">
+ {i18next.t("agent:This transcript holds no conversation")}
+ </CardContent>
+ </Card>
+ ) : (
+ <div className="grid gap-3">
+ {messages.map((message, index) => (
+ <MessageView key={index} message={message} index={index} />
+ ))}
+ </div>
+ )}
+ </PageContainer>
+ );
+}
diff --git a/web/src/pages/AgentSessionsPage.tsx
b/web/src/pages/AgentSessionsPage.tsx
index 2cbcad8..f457a80 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -74,18 +74,18 @@ export default function AgentSessionsPage({account}:
{account: Account}) {
key: "session",
render: (_value, session) => (
<div className="flex min-w-0 flex-col">
- {/* 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>
- )}
+ {/* A session read off disk has no monitoring records behind it, so
it
+ opens its own transcript rather than the Activity page. */}
+ <Link
+ to={
+ session.historical
+ ?
`/agent-sessions/${encodeURIComponent(session.sessionKey)}?agent=${encodeURIComponent(session.agent)}`
+ :
`/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>
diff --git a/web/src/types.ts b/web/src/types.ts
index af74e06..a42fee5 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -305,6 +305,28 @@ export interface AgentSession {
historical?: boolean;
}
+/** One part of a transcript message: prose, thinking, a tool call or its
result. */
+export interface AgentMessageBlock {
+ kind: "text" | "thinking" | "toolUse" | "toolResult" | "image";
+ text?: string;
+ tool?: string;
+ isError?: boolean;
+}
+
+export interface AgentMessage {
+ role: string;
+ time?: string;
+ blocks: AgentMessageBlock[];
+}
+
+/** One session read in full out of the agent's own transcript file. */
+export interface AgentTranscript {
+ session: AgentSession;
+ messages: AgentMessage[];
+ /** True when the file holds more than what was read. */
+ truncated?: boolean;
+}
+
export interface LlmRecord {
id: number;
createdTime: string;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]