feat(chat): add conversation pinning and rename controls
This commit is contained in:
1 parent
27dc6d8bad
commit
3bedbdc669
4 files changed
+418
-117
No files matched your search
+93
-16
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in new issue
Block a user