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
// independent of the pentest exploration graph — see schema.sql §I.
type Conversation struct {
ID int64 `json:"id"`
AgentKey string `json:"agent_key"`
Title string `json:"title"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
AgentKey string `json:"agent_key"`
Title string `json:"title"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
Pinned bool `json:"pinned"`
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) {
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
}
// CreateConversation opens a new chat thread for agentKey with an initial title.
// llmProfileID may be nil to use the globally active profile.
func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) {
c, err := scanConv(d.QueryRow(`
INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, $3)
RETURNING `+convCols, agentKey, title, llmProfileID))
tx, err := d.Begin()
if err != nil {
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
}
// UpdateConversationProfile sets (or clears) the LLM profile override for a conversation.
func (d *DB) UpdateConversationProfile(id int64, llmProfileID *int64) error {
_, err := d.Exec(`UPDATE conversations SET llm_profile_id=$2 WHERE id=$1`, id, llmProfileID)
return err
tx, err := d.Begin()
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.
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 {
return nil, err
}
@@ -114,9 +169,31 @@ func (d *DB) GetConversation(id int64) (*Conversation, error) {
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 {
_, err := d.Exec(`UPDATE conversations SET title=$2 WHERE id=$1`, id, title)
_, err := d.UpdateConversation(id, ConversationPatch{Title: &title})
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 (
"context"
"errors"
"fmt"
"log"
"math"
@@ -10,11 +11,33 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"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) ----------
//
// 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"`
LLMProfileID *int64 `json:"llm_profile_id"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
if !decodeConversationRequest(w, r, &req) {
return
}
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 不能为空")
return
}
if utf8.RuneCountInString(req.AgentKey) > maxConversationAgentKeyRunes {
writeErr(w, 400, fmt.Sprintf("agent_key 最多 %d 个字符", maxConversationAgentKeyRunes))
return
}
a, err := pg.GetAgentByKey(req.AgentKey)
if err != nil {
writeErr(w, 500, err.Error())
@@ -73,6 +99,10 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
if title == "" {
title = "新对话"
}
if utf8.RuneCountInString(title) > maxConversationTitleRunes {
writeErr(w, 400, fmt.Sprintf("标题最多 %d 个字符", maxConversationTitleRunes))
return
}
c, err := pg.CreateConversation(req.AgentKey, title, req.LLMProfileID)
if err != nil {
writeErr(w, 500, err.Error())
@@ -89,8 +119,7 @@ func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) {
var req struct {
LLMProfileID *int64 `json:"llm_profile_id"` // null clears the override
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
if !decodeConversationRequest(w, r, &req) {
return
}
if req.LLMProfileID != nil {
@@ -134,21 +163,39 @@ func (s *Server) pgRenameConversation(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
var req struct{ Title string }
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
var req struct {
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
}
if !decodeConversationRequest(w, r, &req) {
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
writeErr(w, 400, "标题不能为空")
if req.Title == nil && req.Pinned == nil {
writeErr(w, 400, "至少需要提供 title 或 pinned")
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())
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) {
@@ -243,7 +290,7 @@ func (s *Server) pgStopConversation(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"status": "idle"}) // nothing running
return
}
cancel()
cancel(agent.AbortChatStoppedByUser)
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) {
// Per-run cancellable context so a manual stop (pgStopConversation) can abort
// 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.chatCancel[busyKey] = cancel
s.chatMu.Unlock()
defer func() {
cancel()
cancel(agent.AbortChatTurnFinished)
s.chatMu.Lock()
delete(s.chatBusy, busyKey)
delete(s.chatCancel, busyKey)
+194 -86
View File
@@ -1,20 +1,27 @@
"use client";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
ArrowUpIcon,
Bot,
ChevronDownIcon,
Loader2Icon,
MoreHorizontalIcon,
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 {
AlertDialog,
AlertDialogAction,
@@ -24,15 +31,21 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
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 { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
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).
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 >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
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`;
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).
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
// console — shown while a turn is streaming.
function LiveBadge() {
@@ -115,16 +132,9 @@ function Composer({
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
<<<<<<< Updated upstream
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
=======
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
onSend();
>>>>>>> Stashed changes
}
return (
<div className="border-t p-3">
@@ -153,8 +163,8 @@ function Composer({
))}
</div>
)}
<div className="flex items-end gap-2">
{leftSlot}
<div className="flex flex-wrap items-end gap-2">
{leftSlot ? <div className="w-full sm:w-auto">{leftSlot}</div> : null}
{onPickFiles && (
<>
<input
@@ -179,7 +189,7 @@ function Composer({
</>
)}
<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}
placeholder={placeholder}
value={value}
@@ -194,8 +204,14 @@ function Composer({
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)}>
<SendIcon className="size-4" />
<Button
size="icon"
onClick={onSend}
disabled={disabled || (!value.trim() && atts.length === 0)}
title="发送消息"
aria-label="发送消息"
>
<ArrowUpIcon />
</Button>
)}
</div>
@@ -243,7 +259,10 @@ function LLMProfileRow({
{/* default option */}
<button
type="button"
onClick={() => { onChange(null); setOpen(false); }}
onClick={() => {
onChange(null);
setOpen(false);
}}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected == null && "bg-accent",
@@ -251,21 +270,28 @@ function LLMProfileRow({
>
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
{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>
{profiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { onChange(Number(p.id)); setOpen(false); }}
onClick={() => {
onChange(Number(p.id));
setOpen(false);
}}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected === Number(p.id) && "bg-accent",
)}
>
<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>
))}
</PopoverContent>
@@ -335,7 +361,7 @@ function DraftChat({
const agentPicker = (
<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…" />
</SelectTrigger>
<SelectContent>
@@ -345,7 +371,9 @@ function DraftChat({
<Bot className="size-3.5" />
{a.name}
{!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>
</SelectItem>
@@ -362,9 +390,7 @@ function DraftChat({
<Bot className="text-primary size-6" />
</div>
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
{agent?.description && (
<p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>
)}
{agent?.description && <p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>}
</div>
<Composer
@@ -433,10 +459,7 @@ function ChatView({
}
// conversation-scoped detail fetcher for the reused Transcript renderer.
const fetchDetail = React.useCallback(
(seq: number) => api.conversationMsgDetail(conv.id, seq),
[conv.id],
);
const fetchDetail = React.useCallback((seq: number) => api.conversationMsgDetail(conv.id, seq), [conv.id]);
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
const latestTodoSeq = React.useMemo(() => {
@@ -471,7 +494,9 @@ function ChatView({
setHasMore(r.hasMore);
setRunning(r.running);
})
.catch(() => {});
.catch(() => {
/* The empty state remains usable when history loading fails. */
});
return () => {
live = false;
};
@@ -506,9 +531,7 @@ function ChatView({
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
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
@@ -552,6 +575,7 @@ function ChatView({
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, loadEarlier]);
// open/switch a conversation → jump to the latest (bottom)
// biome-ignore lint/correctness/useExhaustiveDependencies: changing conversations intentionally retriggers the scroll reset.
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
@@ -560,6 +584,7 @@ function ChatView({
}
}, [conv.id, viewport]);
// 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(() => {
if (!atBottomRef.current) return;
const vp = viewport();
@@ -569,8 +594,12 @@ function ChatView({
// Per-conversation token total, live — same accounting as the main-agent
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
const tokenTotal = React.useMemo(() => {
let i = 0, o = 0, cr = 0;
let li = 0, lo = 0, lcr = 0;
let i = 0,
o = 0,
cr = 0;
let li = 0,
lo = 0,
lcr = 0;
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
for (const a of messages) {
if (a.kind === "result") {
@@ -585,7 +614,9 @@ function ChatView({
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 };
}, [messages]);
@@ -645,10 +676,10 @@ function ChatView({
return (
<>
{/* 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" />
<span className="shrink-0 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="min-w-0 max-w-48 truncate text-sm font-medium">{agent?.name ?? 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 && (
<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>
)}
{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 && (
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
{tokenTotal.turns} 轮
</span>
)}
{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)}
</span>
)}
@@ -673,10 +704,7 @@ function ChatView({
</div>
{/* messages */}
<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="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
{messages.length === 0 && !running ? (
<div className="text-muted-foreground py-10 text-center text-sm">
@@ -685,9 +713,7 @@ function ChatView({
) : (
<>
{hasMore && (
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">
向上滚动加载更早的消息…
</div>
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">向上滚动加载更早的消息…</div>
)}
<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
// subtitle, inline rename, hover delete. Modeled on the console's SessionItem.
// ConversationItem is one row in the left rail: title, agent subtitle, inline
// rename, pin marker, and a compact action menu.
function ConversationItem({
conv,
agent,
@@ -732,6 +758,8 @@ function ConversationItem({
onStartRename,
onRenameText,
onCommitRename,
onCancelRename,
onTogglePinned,
onDelete,
}: {
conv: Conversation;
@@ -743,8 +771,22 @@ function ConversationItem({
onStartRename: () => void;
onRenameText: (v: string) => void;
onCommitRename: () => void;
onCancelRename: () => void;
onTogglePinned: () => 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 (
<div
className={cn(
@@ -754,13 +796,19 @@ function ConversationItem({
>
{renaming ? (
<input
autoFocus
ref={renameInputRef}
value={renameText}
onChange={(e) => onRenameText(e.target.value)}
onBlur={onCommitRename}
onBlur={() => {
if (!cancelRenameRef.current) onCommitRename();
}}
onKeyDown={(e) => {
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"
/>
@@ -772,26 +820,58 @@ function ConversationItem({
title="双击重命名"
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]">
<Bot className="size-3 shrink-0" />
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</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>
</div>
</button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
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>
</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>
<AlertDialogHeader>
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
@@ -822,11 +902,24 @@ export default function ChatPage() {
>({});
const reloadConvs = React.useCallback(() => {
api.conversations().then(setConvs).catch(() => setConvs([]));
api
.conversations()
.then(setConvs)
.catch(() => setConvs([]));
}, []);
React.useEffect(() => {
api.agents().then(setAgents).catch(() => {});
api.llmProfiles().then(setProfiles).catch(() => {});
api
.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]);
@@ -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) {
setRenamingId(c.id);
setRenameText(c.title || "");
@@ -885,9 +988,9 @@ export default function ChatPage() {
return (
<div
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 */}
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div className="border-b p-2">
@@ -897,9 +1000,7 @@ export default function ChatPage() {
</div>
<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">
{convs.length === 0 && (
<p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>
)}
{convs.length === 0 && <p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>}
{convs.map((c) => (
<ConversationItem
key={c.id}
@@ -912,6 +1013,8 @@ export default function ChatPage() {
onStartRename={() => startRename(c)}
onRenameText={setRenameText}
onCommitRename={commitRename}
onCancelRename={() => setRenamingId(null)}
onTogglePinned={() => void togglePinned(c)}
onDelete={() => del(c.id)}
/>
))}
@@ -940,7 +1043,12 @@ export default function ChatPage() {
// it on this render (switching to ChatView right away, before the
// async reloadConvs lands); reloadConvs then reconciles titles etc.
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);
reloadConvs();
}}