feat(chat): add conversation pinning and rename controls

This commit is contained in:
pphua committed 2026-08-24 19:29:38 +08:00
1 parent 27dc6d8bad
commit 3bedbdc669
4 files changed
+418 -117

No files matched your search

+93 -16
View File
@@ -50,43 +50,98 @@ GROUP BY c.id`)
// Conversation is one ChatGPT-style chat thread bound to an agent key. It lives // Conversation is one ChatGPT-style chat thread bound to an agent key. It lives
// independent of the pentest exploration graph — see schema.sql §I. // independent of the pentest exploration graph — see schema.sql §I.
type Conversation struct { type Conversation struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AgentKey string `json:"agent_key"` AgentKey string `json:"agent_key"`
Title string `json:"title"` Title string `json:"title"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
CreatedAt time.Time `json:"created_at"` Pinned bool `json:"pinned"`
UpdatedAt time.Time `json:"updated_at"` PinnedAt *time.Time `json:"pinned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
const convCols = `id, agent_key, title, llm_profile_id, created_at, updated_at` // ConversationPatch updates only the fields whose pointers are non-nil.
type ConversationPatch struct {
Title *string
Pinned *bool
}
const convCols = `id, agent_key, title, llm_profile_id, pinned_at, created_at, updated_at`
func scanConv(row interface{ Scan(...any) error }) (Conversation, error) { func scanConv(row interface{ Scan(...any) error }) (Conversation, error) {
var c Conversation var c Conversation
err := row.Scan(&c.ID, &c.AgentKey, &c.Title, &c.LLMProfileID, &c.CreatedAt, &c.UpdatedAt) var pinnedAt sql.NullTime
err := row.Scan(&c.ID, &c.AgentKey, &c.Title, &c.LLMProfileID, &pinnedAt, &c.CreatedAt, &c.UpdatedAt)
if pinnedAt.Valid {
c.Pinned = true
c.PinnedAt = &pinnedAt.Time
}
return c, err return c, err
} }
// CreateConversation opens a new chat thread for agentKey with an initial title. // CreateConversation opens a new chat thread for agentKey with an initial title.
// llmProfileID may be nil to use the globally active profile. // llmProfileID may be nil to use the globally active profile.
func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) { func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) {
c, err := scanConv(d.QueryRow(` tx, err := d.Begin()
INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, $3)
RETURNING `+convCols, agentKey, title, llmProfileID))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback()
// Insert the child without a profile first. The new row is exclusively owned
// by this transaction before it takes a profile lock, matching DeleteProfile's
// child-row -> profile-row protocol.
c, err := scanConv(tx.QueryRow(`
INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, NULL)
RETURNING `+convCols, agentKey, title))
if err != nil {
return nil, err
}
if err := lockLLMProfileForReference(tx, llmProfileID); err != nil {
return nil, err
}
if llmProfileID != nil {
c, err = scanConv(tx.QueryRow(`UPDATE conversations SET llm_profile_id=$2
WHERE id=$1 RETURNING `+convCols, c.ID, llmProfileID))
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &c, nil return &c, nil
} }
// UpdateConversationProfile sets (or clears) the LLM profile override for a conversation. // UpdateConversationProfile sets (or clears) the LLM profile override for a conversation.
func (d *DB) UpdateConversationProfile(id int64, llmProfileID *int64) error { func (d *DB) UpdateConversationProfile(id int64, llmProfileID *int64) error {
_, err := d.Exec(`UPDATE conversations SET llm_profile_id=$2 WHERE id=$1`, id, llmProfileID) tx, err := d.Begin()
return err if err != nil {
return err
}
defer tx.Rollback()
var lockedID int64
if err := tx.QueryRow(`SELECT id FROM conversations WHERE id=$1 FOR UPDATE`, id).Scan(&lockedID); err != nil {
if err == sql.ErrNoRows {
// Preserve the previous UPDATE semantics: an unknown id is a no-op.
return tx.Commit()
}
return err
}
if err := lockLLMProfileForReference(tx, llmProfileID); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE conversations SET llm_profile_id=$2 WHERE id=$1`, id, llmProfileID); err != nil {
return err
}
return tx.Commit()
} }
// ListConversations returns all threads, most-recently-updated first. // ListConversations returns all threads, most-recently-updated first.
func (d *DB) ListConversations() ([]*Conversation, error) { func (d *DB) ListConversations() ([]*Conversation, error) {
rows, err := d.Query(`SELECT ` + convCols + ` FROM conversations ORDER BY updated_at DESC`) rows, err := d.Query(`SELECT ` + convCols + ` FROM conversations
ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, updated_at DESC, id DESC`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -114,9 +169,31 @@ func (d *DB) GetConversation(id int64) (*Conversation, error) {
return &c, nil return &c, nil
} }
// RenameConversation sets a thread's title. // UpdateConversation applies a partial title/pin mutation and returns the updated
// row. Pinning an already-pinned conversation preserves its original pin order.
func (d *DB) UpdateConversation(id int64, patch ConversationPatch) (*Conversation, error) {
c, err := scanConv(d.QueryRow(`UPDATE conversations SET
title = CASE WHEN $2::boolean THEN $3 ELSE title END,
pinned_at = CASE
WHEN $4::boolean IS NULL THEN pinned_at
WHEN $4::boolean THEN COALESCE(pinned_at, now())
ELSE NULL
END
WHERE id=$1
RETURNING `+convCols, id, patch.Title != nil, patch.Title, patch.Pinned))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// RenameConversation sets a thread's title. Kept for automatic first-message
// titles and compatibility with existing callers.
func (d *DB) RenameConversation(id int64, title string) error { func (d *DB) RenameConversation(id int64, title string) error {
_, err := d.Exec(`UPDATE conversations SET title=$2 WHERE id=$1`, id, title) _, err := d.UpdateConversation(id, ConversationPatch{Title: &title})
return err return err
} }
+69
View File
@@ -0,0 +1,69 @@
package db
import (
"fmt"
"testing"
"time"
)
func TestConversationPinOrderingAndPatch(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
suffix := time.Now().UnixNano()
first, err := d.CreateConversation("mainagent", fmt.Sprintf("pin-first-%d", suffix), nil)
if err != nil {
t.Fatal(err)
}
second, err := d.CreateConversation("mainagent", fmt.Sprintf("pin-second-%d", suffix), nil)
if err != nil {
_ = d.DeleteConversation(first.ID)
t.Fatal(err)
}
t.Cleanup(func() {
_ = d.DeleteConversation(first.ID)
_ = d.DeleteConversation(second.ID)
})
pinned := true
first, err = d.UpdateConversation(first.ID, ConversationPatch{Pinned: &pinned})
if err != nil || first == nil || !first.Pinned || first.PinnedAt == nil {
t.Fatalf("pin first = %+v, %v", first, err)
}
firstPinnedAt := *first.PinnedAt
time.Sleep(5 * time.Millisecond)
second, err = d.UpdateConversation(second.ID, ConversationPatch{Pinned: &pinned})
if err != nil || second == nil || !second.Pinned {
t.Fatalf("pin second = %+v, %v", second, err)
}
newTitle := "renamed while pinned"
first, err = d.UpdateConversation(first.ID, ConversationPatch{Title: &newTitle, Pinned: &pinned})
if err != nil || first == nil {
t.Fatal(err)
}
if first.Title != newTitle || first.PinnedAt == nil || !first.PinnedAt.Equal(firstPinnedAt) {
t.Fatalf("repeat pin should preserve pin time: %+v (want %v)", first, firstPinnedAt)
}
conversations, err := d.ListConversations()
if err != nil {
t.Fatal(err)
}
positions := map[int64]int{}
for index, conversation := range conversations {
positions[conversation.ID] = index
}
if positions[second.ID] >= positions[first.ID] {
t.Fatalf("newer pin must sort first: second=%d first=%d", positions[second.ID], positions[first.ID])
}
pinned = false
first, err = d.UpdateConversation(first.ID, ConversationPatch{Pinned: &pinned})
if err != nil || first == nil || first.Pinned || first.PinnedAt != nil {
t.Fatalf("unpin first = %+v, %v", first, err)
}
}
+62 -15
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"math" "math"
@@ -10,11 +11,33 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept" "github.com/Autumn-27/artex/intercept"
) )
const (
maxConversationRequestBytes = 64 << 10
maxConversationAgentKeyRunes = 120
maxConversationTitleRunes = 200
)
func decodeConversationRequest(w http.ResponseWriter, r *http.Request, value any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxConversationRequestBytes)
if err := decode(r, value); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
} else {
writeErr(w, http.StatusBadRequest, err.Error())
}
return false
}
return true
}
// ---------- conversations (chat page) ---------- // ---------- conversations (chat page) ----------
// //
// A conversation is a ChatGPT-style thread bound to an agent key, independent of // A conversation is a ChatGPT-style thread bound to an agent key, independent of
@@ -45,8 +68,7 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
Title string `json:"title"` Title string `json:"title"`
LLMProfileID *int64 `json:"llm_profile_id"` LLMProfileID *int64 `json:"llm_profile_id"`
} }
if err := decode(r, &req); err != nil { if !decodeConversationRequest(w, r, &req) {
writeErr(w, 400, err.Error())
return return
} }
req.AgentKey = strings.TrimSpace(req.AgentKey) req.AgentKey = strings.TrimSpace(req.AgentKey)
@@ -54,6 +76,10 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "agent_key 不能为空") writeErr(w, 400, "agent_key 不能为空")
return return
} }
if utf8.RuneCountInString(req.AgentKey) > maxConversationAgentKeyRunes {
writeErr(w, 400, fmt.Sprintf("agent_key 最多 %d 个字符", maxConversationAgentKeyRunes))
return
}
a, err := pg.GetAgentByKey(req.AgentKey) a, err := pg.GetAgentByKey(req.AgentKey)
if err != nil { if err != nil {
writeErr(w, 500, err.Error()) writeErr(w, 500, err.Error())
@@ -73,6 +99,10 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
if title == "" { if title == "" {
title = "新对话" title = "新对话"
} }
if utf8.RuneCountInString(title) > maxConversationTitleRunes {
writeErr(w, 400, fmt.Sprintf("标题最多 %d 个字符", maxConversationTitleRunes))
return
}
c, err := pg.CreateConversation(req.AgentKey, title, req.LLMProfileID) c, err := pg.CreateConversation(req.AgentKey, title, req.LLMProfileID)
if err != nil { if err != nil {
writeErr(w, 500, err.Error()) writeErr(w, 500, err.Error())
@@ -89,8 +119,7 @@ func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
LLMProfileID *int64 `json:"llm_profile_id"` // null clears the override LLMProfileID *int64 `json:"llm_profile_id"` // null clears the override
} }
if err := decode(r, &req); err != nil { if !decodeConversationRequest(w, r, &req) {
writeErr(w, 400, err.Error())
return return
} }
if req.LLMProfileID != nil { if req.LLMProfileID != nil {
@@ -134,21 +163,39 @@ func (s *Server) pgRenameConversation(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
var req struct{ Title string } var req struct {
if err := decode(r, &req); err != nil { Title *string `json:"title"`
writeErr(w, 400, err.Error()) Pinned *bool `json:"pinned"`
}
if !decodeConversationRequest(w, r, &req) {
return return
} }
title := strings.TrimSpace(req.Title) if req.Title == nil && req.Pinned == nil {
if title == "" { writeErr(w, 400, "至少需要提供 title 或 pinned")
writeErr(w, 400, "标题不能为空")
return return
} }
if err := pg.RenameConversation(c.ID, title); err != nil { if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
writeErr(w, 400, "标题不能为空")
return
}
if utf8.RuneCountInString(title) > maxConversationTitleRunes {
writeErr(w, 400, fmt.Sprintf("标题最多 %d 个字符", maxConversationTitleRunes))
return
}
req.Title = &title
}
updated, err := pg.UpdateConversation(c.ID, db.ConversationPatch{Title: req.Title, Pinned: req.Pinned})
if err != nil {
writeErr(w, 500, err.Error()) writeErr(w, 500, err.Error())
return return
} }
writeJSON(w, 200, map[string]any{"ok": true}) if updated == nil {
writeErr(w, 404, "conversation not found")
return
}
writeJSON(w, 200, updated)
} }
func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) { func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) {
@@ -243,7 +290,7 @@ func (s *Server) pgStopConversation(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"status": "idle"}) // nothing running writeJSON(w, 200, map[string]any{"status": "idle"}) // nothing running
return return
} }
cancel() cancel(agent.AbortChatStoppedByUser)
writeJSON(w, 200, map[string]any{"status": "stopping"}) writeJSON(w, 200, map[string]any{"status": "stopping"})
} }
@@ -331,12 +378,12 @@ func (s *Server) runConversation(c *db.Conversation, msg, busyKey string) {
func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) { func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
// Per-run cancellable context so a manual stop (pgStopConversation) can abort // Per-run cancellable context so a manual stop (pgStopConversation) can abort
// just this session. Registered under chatMu so the stop handler can find it. // just this session. Registered under chatMu so the stop handler can find it.
ctx, cancel := context.WithCancel(intercept.WithConvID(s.ctx, c.ID)) ctx, cancel := context.WithCancelCause(intercept.WithConvID(s.ctx, c.ID))
s.chatMu.Lock() s.chatMu.Lock()
s.chatCancel[busyKey] = cancel s.chatCancel[busyKey] = cancel
s.chatMu.Unlock() s.chatMu.Unlock()
defer func() { defer func() {
cancel() cancel(agent.AbortChatTurnFinished)
s.chatMu.Lock() s.chatMu.Lock()
delete(s.chatBusy, busyKey) delete(s.chatBusy, busyKey)
delete(s.chatCancel, busyKey) delete(s.chatCancel, busyKey)
+194 -86
View File
@@ -1,20 +1,27 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import { Bot, ChevronDownIcon, Loader2Icon, PaperclipIcon, PlusIcon, SendIcon, Square, Trash2Icon, XIcon, ZapIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Select, ArrowUpIcon,
SelectContent, Bot,
SelectItem, ChevronDownIcon,
SelectTrigger, Loader2Icon,
SelectValue, MoreHorizontalIcon,
} from "@/components/ui/select"; PaperclipIcon,
PencilIcon,
PinIcon,
PinOffIcon,
PlusIcon,
Square,
Trash2Icon,
XIcon,
ZapIcon,
} from "lucide-react";
import { toast } from "sonner";
import { TodoPopover } from "@/components/todo-popover";
import { Transcript } from "@/components/transcript";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -24,15 +31,21 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Transcript } from "@/components/transcript"; import { Badge } from "@/components/ui/badge";
import { TodoPopover } from "@/components/todo-popover"; import { Button } from "@/components/ui/button";
import { import {
Popover, DropdownMenu,
PopoverContent, DropdownMenuContent,
PopoverTrigger, DropdownMenuGroup,
} from "@/components/ui/popover"; DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode"; import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types"; import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
@@ -47,8 +60,8 @@ function fmtBytes(n: number): string {
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string { function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k"; if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
return String(n); return String(n);
} }
@@ -67,6 +80,10 @@ function fmtDuration(ms: number): string {
// thread stays snappy (only ~a page of rows is in the DOM until you scroll up). // thread stays snappy (only ~a page of rows is in the DOM until you scroll up).
const HISTORY_PAGE = 200; const HISTORY_PAGE = 200;
function conversationIsPinned(conversation: Conversation): boolean {
return conversation.pinned ?? Boolean(conversation.pinned_at);
}
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent // LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
// console — shown while a turn is streaming. // console — shown while a turn is streaming.
function LiveBadge() { function LiveBadge() {
@@ -115,16 +132,9 @@ function Composer({
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。 // 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode(); const sendMode = useChatSendMode();
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
<<<<<<< Updated upstream
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
=======
if (!shouldSubmitOnKey(e, sendMode)) return; if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault(); e.preventDefault();
onSend(); onSend();
>>>>>>> Stashed changes
} }
return ( return (
<div className="border-t p-3"> <div className="border-t p-3">
@@ -153,8 +163,8 @@ function Composer({
))} ))}
</div> </div>
)} )}
<div className="flex items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
{leftSlot} {leftSlot ? <div className="w-full sm:w-auto">{leftSlot}</div> : null}
{onPickFiles && ( {onPickFiles && (
<> <>
<input <input
@@ -179,7 +189,7 @@ function Composer({
</> </>
)} )}
<Textarea <Textarea
className="max-h-40 min-h-10 flex-1 resize-none" className="max-h-40 min-h-10 min-w-0 flex-1 resize-none"
rows={1} rows={1}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
@@ -194,8 +204,14 @@ function Composer({
<Square className="size-3.5 fill-current" /> <Square className="size-3.5 fill-current" />
</Button> </Button>
) : ( ) : (
<Button size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)}> <Button
<SendIcon className="size-4" /> size="icon"
onClick={onSend}
disabled={disabled || (!value.trim() && atts.length === 0)}
title="发送消息"
aria-label="发送消息"
>
<ArrowUpIcon />
</Button> </Button>
)} )}
</div> </div>
@@ -243,7 +259,10 @@ function LLMProfileRow({
{/* default option */} {/* default option */}
<button <button
type="button" type="button"
onClick={() => { onChange(null); setOpen(false); }} onClick={() => {
onChange(null);
setOpen(false);
}}
className={cn( className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent", "flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected == null && "bg-accent", selected == null && "bg-accent",
@@ -251,21 +270,28 @@ function LLMProfileRow({
> >
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span> <span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
{activeDefault && ( {activeDefault && (
<span className="text-muted-foreground text-[11px]">{activeDefault.format} · {activeDefault.model}</span> <span className="text-muted-foreground text-[11px]">
{activeDefault.format} · {activeDefault.model}
</span>
)} )}
</button> </button>
{profiles.map((p) => ( {profiles.map((p) => (
<button <button
key={p.id} key={p.id}
type="button" type="button"
onClick={() => { onChange(Number(p.id)); setOpen(false); }} onClick={() => {
onChange(Number(p.id));
setOpen(false);
}}
className={cn( className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent", "flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected === Number(p.id) && "bg-accent", selected === Number(p.id) && "bg-accent",
)} )}
> >
<span className="text-sm">{p.name}</span> <span className="text-sm">{p.name}</span>
<span className="text-muted-foreground text-[11px]">{p.format} · {p.model}</span> <span className="text-muted-foreground text-[11px]">
{p.format} · {p.model}
</span>
</button> </button>
))} ))}
</PopoverContent> </PopoverContent>
@@ -335,7 +361,7 @@ function DraftChat({
const agentPicker = ( const agentPicker = (
<Select value={agentKey} onValueChange={setAgentKey}> <Select value={agentKey} onValueChange={setAgentKey}>
<SelectTrigger size="sm" className="w-40 shrink-0"> <SelectTrigger size="sm" className="w-full sm:w-40">
<SelectValue placeholder="选择 Agent…" /> <SelectValue placeholder="选择 Agent…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -345,7 +371,9 @@ function DraftChat({
<Bot className="size-3.5" /> <Bot className="size-3.5" />
{a.name} {a.name}
{!a.builtin && ( {!a.builtin && (
<Badge variant="outline" className="px-1 py-0 text-[9px]">自定义</Badge> <Badge variant="outline" className="px-1 py-0 text-[9px]">
自定义
</Badge>
)} )}
</span> </span>
</SelectItem> </SelectItem>
@@ -362,9 +390,7 @@ function DraftChat({
<Bot className="text-primary size-6" /> <Bot className="text-primary size-6" />
</div> </div>
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div> <div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
{agent?.description && ( {agent?.description && <p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>}
<p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>
)}
</div> </div>
<Composer <Composer
@@ -433,10 +459,7 @@ function ChatView({
} }
// conversation-scoped detail fetcher for the reused Transcript renderer. // conversation-scoped detail fetcher for the reused Transcript renderer.
const fetchDetail = React.useCallback( const fetchDetail = React.useCallback((seq: number) => api.conversationMsgDetail(conv.id, seq), [conv.id]);
(seq: number) => api.conversationMsgDetail(conv.id, seq),
[conv.id],
);
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none. // seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
const latestTodoSeq = React.useMemo(() => { const latestTodoSeq = React.useMemo(() => {
@@ -471,7 +494,9 @@ function ChatView({
setHasMore(r.hasMore); setHasMore(r.hasMore);
setRunning(r.running); setRunning(r.running);
}) })
.catch(() => {}); .catch(() => {
/* The empty state remains usable when history loading fails. */
});
return () => { return () => {
live = false; live = false;
}; };
@@ -506,9 +531,7 @@ function ChatView({
const contentRef = React.useRef<HTMLDivElement | null>(null); const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true); const atBottomRef = React.useRef(true);
const viewport = React.useCallback( const viewport = React.useCallback(
() => () => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
null,
[], [],
); );
// Scroll-up loads one older page and prepends it, preserving the visual position // Scroll-up loads one older page and prepends it, preserving the visual position
@@ -552,6 +575,7 @@ function ChatView({
return () => vp.removeEventListener("scroll", onScroll); return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, loadEarlier]); }, [viewport, loadEarlier]);
// open/switch a conversation → jump to the latest (bottom) // open/switch a conversation → jump to the latest (bottom)
// biome-ignore lint/correctness/useExhaustiveDependencies: changing conversations intentionally retriggers the scroll reset.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const vp = viewport(); const vp = viewport();
if (vp) { if (vp) {
@@ -560,6 +584,7 @@ function ChatView({
} }
}, [conv.id, viewport]); }, [conv.id, viewport]);
// new activity → stick to bottom only if the user is already pinned there // new activity → stick to bottom only if the user is already pinned there
// biome-ignore lint/correctness/useExhaustiveDependencies: message and running changes intentionally retrigger bottom anchoring.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!atBottomRef.current) return; if (!atBottomRef.current) return;
const vp = viewport(); const vp = viewport();
@@ -569,8 +594,12 @@ function ChatView({
// Per-conversation token total, live — same accounting as the main-agent // Per-conversation token total, live — same accounting as the main-agent
// console: completed runs' `result` sum + the in-progress run's latest `usage`. // console: completed runs' `result` sum + the in-progress run's latest `usage`.
const tokenTotal = React.useMemo(() => { const tokenTotal = React.useMemo(() => {
let i = 0, o = 0, cr = 0; let i = 0,
let li = 0, lo = 0, lcr = 0; o = 0,
cr = 0;
let li = 0,
lo = 0,
lcr = 0;
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage') let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
for (const a of messages) { for (const a of messages) {
if (a.kind === "result") { if (a.kind === "result") {
@@ -585,7 +614,9 @@ function ChatView({
lcr = a.cache_read_tokens ?? 0; lcr = a.cache_read_tokens ?? 0;
} }
} }
const I = i + li, O = o + lo, CR = cr + lcr; const I = i + li,
O = o + lo,
CR = cr + lcr;
return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 }; return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 };
}, [messages]); }, [messages]);
@@ -645,10 +676,10 @@ function ChatView({
return ( return (
<> <>
{/* header: which agent + live + token meta */} {/* header: which agent + live + token meta */}
<div className="flex items-center gap-2 border-b px-4 py-2.5"> <div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-4 py-2.5">
<Bot className="text-muted-foreground size-4 shrink-0" /> <Bot className="text-muted-foreground size-4 shrink-0" />
<span className="shrink-0 text-sm font-medium">{agent?.name ?? conv.agent_key}</span> <span className="min-w-0 max-w-48 truncate text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
<span className="text-muted-foreground shrink-0 font-mono text-xs">{conv.agent_key}</span> <span className="text-muted-foreground hidden shrink-0 font-mono text-xs sm:inline">{conv.agent_key}</span>
{agent && !agent.builtin && ( {agent && !agent.builtin && (
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]"> <Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]">
自定义 自定义
@@ -658,14 +689,14 @@ function ChatView({
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span> <span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
)} )}
{running && <LiveBadge />} {running && <LiveBadge />}
<div className="text-muted-foreground ml-auto flex shrink-0 items-center gap-3 text-xs"> <div className="text-muted-foreground ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs max-sm:w-full max-sm:flex-wrap">
{tokenTotal.turns > 0 && ( {tokenTotal.turns > 0 && (
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums"> <span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
{tokenTotal.turns} 轮 {tokenTotal.turns} 轮
</span> </span>
)} )}
{tokenTotal.any && ( {tokenTotal.any && (
<span title="input / cache(read) / output tokens" className="tabular-nums"> <span title="input / cache(read) / output tokens" className="min-w-0 truncate tabular-nums">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)} input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span> </span>
)} )}
@@ -673,10 +704,7 @@ function ChatView({
</div> </div>
{/* messages */} {/* messages */}
<ScrollArea <ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
type="auto"
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
>
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}> <div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
{messages.length === 0 && !running ? ( {messages.length === 0 && !running ? (
<div className="text-muted-foreground py-10 text-center text-sm"> <div className="text-muted-foreground py-10 text-center text-sm">
@@ -685,9 +713,7 @@ function ChatView({
) : ( ) : (
<> <>
{hasMore && ( {hasMore && (
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]"> <div className="text-muted-foreground/70 pb-2 text-center text-[11px]">向上滚动加载更早的消息…</div>
向上滚动加载更早的消息…
</div>
)} )}
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} /> <Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
</> </>
@@ -720,8 +746,8 @@ function ChatView({
); );
} }
// ConversationItem is one row in the left rail — status-ish icon, title + agent // ConversationItem is one row in the left rail: title, agent subtitle, inline
// subtitle, inline rename, hover delete. Modeled on the console's SessionItem. // rename, pin marker, and a compact action menu.
function ConversationItem({ function ConversationItem({
conv, conv,
agent, agent,
@@ -732,6 +758,8 @@ function ConversationItem({
onStartRename, onStartRename,
onRenameText, onRenameText,
onCommitRename, onCommitRename,
onCancelRename,
onTogglePinned,
onDelete, onDelete,
}: { }: {
conv: Conversation; conv: Conversation;
@@ -743,8 +771,22 @@ function ConversationItem({
onStartRename: () => void; onStartRename: () => void;
onRenameText: (v: string) => void; onRenameText: (v: string) => void;
onCommitRename: () => void; onCommitRename: () => void;
onCancelRename: () => void;
onTogglePinned: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const [deleteOpen, setDeleteOpen] = React.useState(false);
const renameInputRef = React.useRef<HTMLInputElement>(null);
const cancelRenameRef = React.useRef(false);
const pinned = conversationIsPinned(conv);
React.useEffect(() => {
if (!renaming) return;
cancelRenameRef.current = false;
renameInputRef.current?.focus();
renameInputRef.current?.select();
}, [renaming]);
return ( return (
<div <div
className={cn( className={cn(
@@ -754,13 +796,19 @@ function ConversationItem({
> >
{renaming ? ( {renaming ? (
<input <input
autoFocus ref={renameInputRef}
value={renameText} value={renameText}
onChange={(e) => onRenameText(e.target.value)} onChange={(e) => onRenameText(e.target.value)}
onBlur={onCommitRename} onBlur={() => {
if (!cancelRenameRef.current) onCommitRename();
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename(); if (e.key === "Enter") onCommitRename();
if (e.key === "Escape") onCommitRename(); if (e.key === "Escape") {
e.preventDefault();
cancelRenameRef.current = true;
onCancelRename();
}
}} }}
className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm" className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
/> />
@@ -772,26 +820,58 @@ function ConversationItem({
title="双击重命名" title="双击重命名"
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left" className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
> >
<div className="truncate text-sm">{conv.title || "新对话"}</div> <div className="flex min-w-0 items-center gap-1.5">
{pinned && <PinIcon className="text-primary size-3 shrink-0" aria-label="已置顶" />}
<div className="truncate text-sm">{conv.title || "新对话"}</div>
</div>
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]"> <div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
<Bot className="size-3 shrink-0" /> <Bot className="size-3 shrink-0" />
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span> <span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
<span className="shrink-0">·</span> <span className="shrink-0">·</span>
<span className="shrink-0">{new Date(conv.created_at).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span> <span className="shrink-0">
{new Date(conv.created_at).toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className="shrink-0 opacity-60">#{conv.id}</span> <span className="shrink-0 opacity-60">#{conv.id}</span>
</div> </div>
</button> </button>
)} )}
<AlertDialog> <DropdownMenu>
<AlertDialogTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100" className="text-muted-foreground shrink-0"
aria-label={`管理对话「${conv.title || "新对话"}」`}
> >
<Trash2Icon className="size-3.5" /> <MoreHorizontalIcon />
</Button> </Button>
</AlertDialogTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onStartRename}>
<PencilIcon />
重命名
</DropdownMenuItem>
<DropdownMenuItem onSelect={onTogglePinned}>
{pinned ? <PinOffIcon /> : <PinIcon />}
{pinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={() => setDeleteOpen(true)}>
<Trash2Icon />
删除
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle> <AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
@@ -822,11 +902,24 @@ export default function ChatPage() {
>({}); >({});
const reloadConvs = React.useCallback(() => { const reloadConvs = React.useCallback(() => {
api.conversations().then(setConvs).catch(() => setConvs([])); api
.conversations()
.then(setConvs)
.catch(() => setConvs([]));
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
api.agents().then(setAgents).catch(() => {}); api
api.llmProfiles().then(setProfiles).catch(() => {}); .agents()
.then(setAgents)
.catch(() => {
/* Agent metadata is optional for rendering an existing conversation. */
});
api
.llmProfiles()
.then(setProfiles)
.catch(() => {
/* The conversation remains usable without profile labels. */
});
reloadConvs(); reloadConvs();
}, [reloadConvs]); }, [reloadConvs]);
@@ -865,6 +958,16 @@ export default function ChatPage() {
} }
} }
async function togglePinned(conversation: Conversation) {
const pinned = conversationIsPinned(conversation);
try {
await api.pinConversation(conversation.id, !pinned);
reloadConvs();
} catch (e) {
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
}
}
function startRename(c: Conversation) { function startRename(c: Conversation) {
setRenamingId(c.id); setRenamingId(c.id);
setRenameText(c.title || ""); setRenameText(c.title || "");
@@ -885,9 +988,9 @@ export default function ChatPage() {
return ( return (
<div <div
data-content-padding="false" data-content-padding="false"
className="flex h-[calc(100svh-3rem)] flex-col overflow-hidden p-4 md:h-[calc(100svh-4rem)] md:p-6" className="flex h-[calc(100svh-3rem)] min-w-0 flex-col overflow-hidden p-3 sm:p-4 md:h-[calc(100svh-4rem)] md:p-6"
> >
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] grid-rows-[minmax(0,1fr)] gap-4"> <div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-[minmax(10rem,15rem)_minmax(0,1fr)] gap-3 md:grid-cols-[18rem_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)] md:gap-4">
{/* left: conversation list */} {/* left: conversation list */}
<div className="bg-card flex flex-col overflow-hidden rounded-lg border"> <div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div className="border-b p-2"> <div className="border-b p-2">
@@ -897,9 +1000,7 @@ export default function ChatPage() {
</div> </div>
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"> <ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex min-w-0 flex-col gap-0.5 p-2"> <div className="flex min-w-0 flex-col gap-0.5 p-2">
{convs.length === 0 && ( {convs.length === 0 && <p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>}
<p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>
)}
{convs.map((c) => ( {convs.map((c) => (
<ConversationItem <ConversationItem
key={c.id} key={c.id}
@@ -912,6 +1013,8 @@ export default function ChatPage() {
onStartRename={() => startRename(c)} onStartRename={() => startRename(c)}
onRenameText={setRenameText} onRenameText={setRenameText}
onCommitRename={commitRename} onCommitRename={commitRename}
onCancelRename={() => setRenamingId(null)}
onTogglePinned={() => void togglePinned(c)}
onDelete={() => del(c.id)} onDelete={() => del(c.id)}
/> />
))} ))}
@@ -940,7 +1043,12 @@ export default function ChatPage() {
// it on this render (switching to ChatView right away, before the // it on this render (switching to ChatView right away, before the
// async reloadConvs lands); reloadConvs then reconciles titles etc. // async reloadConvs lands); reloadConvs then reconciles titles etc.
if (pending) setPendingByConv((p) => ({ ...p, [c.id]: pending })); if (pending) setPendingByConv((p) => ({ ...p, [c.id]: pending }));
setConvs((prev) => (prev.some((x) => x.id === c.id) ? prev : [c, ...prev])); setConvs((prev) => {
if (prev.some((item) => item.id === c.id)) return prev;
const firstUnpinned = prev.findIndex((item) => !conversationIsPinned(item));
const insertAt = firstUnpinned < 0 ? prev.length : firstUnpinned;
return [...prev.slice(0, insertAt), c, ...prev.slice(insertAt)];
});
setSelectedId(c.id); setSelectedId(c.id);
reloadConvs(); reloadConvs();
}} }}