mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
feat(ui): editable session title, detail page spacing, and session list enhancements
This commit is contained in:
1 parent
faf66cf453
commit
2a61562503
8 files changed
+169
-11
No files matched your search
@@ -42,7 +42,7 @@ func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client,
|
||||
params.Limit = 20
|
||||
}
|
||||
|
||||
result := m.sessions.ListPaged(store.SessionListOpts{
|
||||
result := m.sessions.ListPagedRich(store.SessionListOpts{
|
||||
AgentID: params.AgentID,
|
||||
Limit: params.Limit,
|
||||
Offset: params.Offset,
|
||||
|
||||
@@ -134,6 +134,101 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList
|
||||
return store.SessionListResult{Sessions: result, Total: total}
|
||||
}
|
||||
|
||||
// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name).
|
||||
func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.SessionListRichResult {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
offset := opts.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var where string
|
||||
var whereArgs []interface{}
|
||||
|
||||
if opts.AgentID != "" {
|
||||
where = " WHERE s.session_key LIKE $1"
|
||||
whereArgs = append(whereArgs, "agent:"+opts.AgentID+":%")
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int
|
||||
countQ := "SELECT COUNT(*) FROM sessions s" + where
|
||||
if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil {
|
||||
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0}
|
||||
}
|
||||
|
||||
// Fetch page with agent name via LEFT JOIN
|
||||
var selectQ string
|
||||
var selectArgs []interface{}
|
||||
|
||||
const richCols = `s.session_key, jsonb_array_length(s.messages), s.created_at, s.updated_at,
|
||||
s.label, s.channel, s.user_id, COALESCE(s.metadata, '{}'),
|
||||
s.model, s.provider, s.input_tokens, s.output_tokens,
|
||||
COALESCE(a.display_name, '')`
|
||||
|
||||
if opts.AgentID != "" {
|
||||
selectQ = `SELECT ` + richCols + `
|
||||
FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
|
||||
WHERE s.session_key LIKE $1 ORDER BY s.updated_at DESC LIMIT $2 OFFSET $3`
|
||||
selectArgs = []interface{}{whereArgs[0], limit, offset}
|
||||
} else {
|
||||
selectQ = `SELECT ` + richCols + `
|
||||
FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
|
||||
ORDER BY s.updated_at DESC LIMIT $1 OFFSET $2`
|
||||
selectArgs = []interface{}{limit, offset}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(selectQ, selectArgs...)
|
||||
if err != nil {
|
||||
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.SessionInfoRich
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var msgCount int
|
||||
var createdAt, updatedAt time.Time
|
||||
var label, channel, userID *string
|
||||
var metaJSON []byte
|
||||
var model, provider *string
|
||||
var inputTokens, outputTokens int64
|
||||
var agentName string
|
||||
if err := rows.Scan(&key, &msgCount, &createdAt, &updatedAt, &label, &channel, &userID, &metaJSON,
|
||||
&model, &provider, &inputTokens, &outputTokens, &agentName); err != nil {
|
||||
continue
|
||||
}
|
||||
var meta map[string]string
|
||||
if len(metaJSON) > 0 {
|
||||
json.Unmarshal(metaJSON, &meta)
|
||||
}
|
||||
result = append(result, store.SessionInfoRich{
|
||||
SessionInfo: store.SessionInfo{
|
||||
Key: key,
|
||||
MessageCount: msgCount,
|
||||
Created: createdAt,
|
||||
Updated: updatedAt,
|
||||
Label: derefStr(label),
|
||||
Channel: derefStr(channel),
|
||||
UserID: derefStr(userID),
|
||||
Metadata: meta,
|
||||
},
|
||||
Model: derefStr(model),
|
||||
Provider: derefStr(provider),
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
AgentName: agentName,
|
||||
})
|
||||
}
|
||||
if result == nil {
|
||||
result = []store.SessionInfoRich{}
|
||||
}
|
||||
return store.SessionListRichResult{Sessions: result, Total: total}
|
||||
}
|
||||
|
||||
func (s *PGSessionStore) Save(key string) error {
|
||||
s.mu.RLock()
|
||||
data, ok := s.cache[key]
|
||||
|
||||
@@ -62,6 +62,22 @@ type SessionListResult struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SessionInfoRich is an enriched session info for API responses (includes model, tokens, agent name).
|
||||
type SessionInfoRich struct {
|
||||
SessionInfo
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
InputTokens int64 `json:"inputTokens,omitempty"`
|
||||
OutputTokens int64 `json:"outputTokens,omitempty"`
|
||||
AgentName string `json:"agentName,omitempty"`
|
||||
}
|
||||
|
||||
// SessionListRichResult is the paginated result of ListPagedRich.
|
||||
type SessionListRichResult struct {
|
||||
Sessions []SessionInfoRich `json:"sessions"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SessionStore manages conversation sessions.
|
||||
type SessionStore interface {
|
||||
GetOrCreate(key string) *SessionData
|
||||
@@ -88,6 +104,7 @@ type SessionStore interface {
|
||||
Delete(key string) error
|
||||
List(agentID string) []SessionInfo
|
||||
ListPaged(opts SessionListOpts) SessionListResult
|
||||
ListPagedRich(opts SessionListOpts) SessionListRichResult
|
||||
Save(key string) error
|
||||
LastUsedChannel(agentID string) (channel, chatID string)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function SessionSwitcher({
|
||||
{sessions.map((session) => {
|
||||
const parsed = parseSessionKey(session.key);
|
||||
const isActive = session.key === activeKey;
|
||||
const label = session.label || parsed.scope || session.key;
|
||||
const label = session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope || session.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useSessions(opts: UseSessionsOptions = {}) {
|
||||
);
|
||||
|
||||
const patchSession = useCallback(
|
||||
async (key: string, updates: { label?: string; model?: string }) => {
|
||||
async (key: string, updates: { label?: string; model?: string; metadata?: Record<string, string> }) => {
|
||||
if (!ws.isConnected) return;
|
||||
await ws.call(Methods.SESSIONS_PATCH, { key, ...updates });
|
||||
await invalidate();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, useLayoutEffect } from "react";
|
||||
import { ArrowLeft, Trash2, RotateCcw, Info, Eye } from "lucide-react";
|
||||
import { ArrowLeft, Trash2, RotateCcw, Info, Eye, Pencil, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MessageBubble } from "@/components/chat/message-bubble";
|
||||
@@ -33,6 +33,7 @@ interface SessionDetailPageProps {
|
||||
onPreview: (key: string) => Promise<SessionPreview | null>;
|
||||
onDelete: (key: string) => Promise<void>;
|
||||
onReset: (key: string) => Promise<void>;
|
||||
onPatch?: (key: string, updates: { label?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SessionDetailPage({
|
||||
@@ -41,12 +42,15 @@ export function SessionDetailPage({
|
||||
onPreview,
|
||||
onDelete,
|
||||
onReset,
|
||||
onPatch,
|
||||
}: SessionDetailPageProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [summary, setSummary] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
|
||||
const parsed = parseSessionKey(session.key);
|
||||
|
||||
@@ -99,11 +103,51 @@ export function SessionDetailPage({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
{session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{parsed.agentId}</Badge>
|
||||
{editingTitle ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
className="h-7 rounded border bg-background px-2 text-sm font-medium outline-none focus:ring-1 focus:ring-ring"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onPatch?.(session.key, { label: titleDraft });
|
||||
setEditingTitle(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
onPatch?.(session.key, { label: titleDraft });
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingTitle(false)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
className="group flex cursor-pointer items-center gap-1.5 font-medium hover:text-primary"
|
||||
onClick={() => {
|
||||
setTitleDraft(session.label || session.metadata?.chat_title || session.metadata?.display_name || "");
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
>
|
||||
{session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope}
|
||||
<Pencil className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50" />
|
||||
</h3>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{session.agentName || parsed.agentId}</Badge>
|
||||
{session.channel && session.channel !== "ws" && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SessionsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const { sessions, total, loading, preview, deleteSession, resetSession } = useSessions({
|
||||
const { sessions, total, loading, preview, deleteSession, resetSession, patchSession } = useSessions({
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export function SessionsPage() {
|
||||
navigate("/sessions");
|
||||
}}
|
||||
onReset={resetSession}
|
||||
onPatch={patchSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +147,7 @@ function SessionRow({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">{parsed.agentId}</Badge>
|
||||
<Badge variant="outline">{session.agentName || parsed.agentId}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{session.channel || "ws"}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SessionInfo {
|
||||
outputTokens?: number;
|
||||
userID?: string;
|
||||
metadata?: Record<string, string>;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface SessionPreview {
|
||||
|
||||
Reference in new issue
Block a user