1、将排序做到border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted里,无需单独做个button 2、指定重命名使用ICO,无需使用菜单
This commit is contained in:
1 parent
48e85b33d6
commit
86eb67ea0a
15 files changed
+957
-20
No files matched your search
@@ -209,6 +209,28 @@ func (d *DB) DeleteConversation(id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteConversations removes existing threads in one statement and returns the
|
||||
// ids that were actually present. Child activities and trigger runs cascade.
|
||||
func (d *DB) DeleteConversations(ids []int64) ([]int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return []int64{}, nil
|
||||
}
|
||||
rows, err := d.Query(`DELETE FROM conversations WHERE id=ANY($1::bigint[]) RETURNING id`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
deleted := make([]int64, 0, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
return deleted, rows.Err()
|
||||
}
|
||||
|
||||
// AppendConvActivity records one step of a conversation (human message or an agent
|
||||
// execution step) and returns its id. Mirrors ExplorationStore.AppendActivity but
|
||||
// keyed by conversation_id. Reuses the Activity struct (NodeID is ignored here).
|
||||
|
||||
@@ -385,6 +385,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
coverage_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
pinned_at TIMESTAMPTZ,
|
||||
first_run_at TIMESTAMPTZ,
|
||||
deadline_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
@@ -413,10 +414,13 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT '';
|
||||
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL AND category_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC)
|
||||
WHERE deleted_at IS NULL AND pinned_at IS NOT NULL;
|
||||
|
||||
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
|
||||
-- identity used to reject visually equivalent duplicate names.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskPinOrderingAndRename(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.CreateTask(fmt.Sprintf("task-pin-first-%d", suffix), "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := d.CreateTask(fmt.Sprintf("task-pin-second-%d", suffix), "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
_ = d.DeleteTask(first.ID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = d.DeleteTask(first.ID)
|
||||
_ = d.DeleteTask(second.ID)
|
||||
}()
|
||||
|
||||
pinned := true
|
||||
first, err = d.UpdateTask(first.ID, TaskPatch{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.UpdateTask(second.ID, TaskPatch{Pinned: &pinned})
|
||||
if err != nil || second == nil || !second.Pinned || second.PinnedAt == nil {
|
||||
t.Fatalf("pin second = %+v, %v", second, err)
|
||||
}
|
||||
|
||||
name := "renamed pinned task"
|
||||
first, err = d.UpdateTask(first.ID, TaskPatch{Name: &name, Pinned: &pinned})
|
||||
if err != nil || first == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Name != name || first.PinnedAt == nil || !first.PinnedAt.Equal(firstPinnedAt) {
|
||||
t.Fatalf("repeat pin should preserve pin time: %+v (want %v)", first, firstPinnedAt)
|
||||
}
|
||||
|
||||
tasks, err := d.ListTasks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
positions := map[int64]int{}
|
||||
for index, task := range tasks {
|
||||
positions[task.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.UpdateTask(first.ID, TaskPatch{Pinned: &pinned})
|
||||
if err != nil || first == nil || first.Pinned || first.PinnedAt != nil {
|
||||
t.Fatalf("unpin first = %+v, %v", first, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteConversationsReturnsExistingIDs(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
first, err := d.CreateConversation("mainagent", "batch-delete-first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := d.CreateConversation("mainagent", "batch-delete-second", nil)
|
||||
if err != nil {
|
||||
_ = d.DeleteConversation(first.ID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = d.DeleteConversation(first.ID)
|
||||
_ = d.DeleteConversation(second.ID)
|
||||
}()
|
||||
|
||||
missing := int64(1<<62 - 1)
|
||||
deleted, err := d.DeleteConversations([]int64{first.ID, missing, second.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := make(map[int64]bool, len(deleted))
|
||||
for _, id := range deleted {
|
||||
seen[id] = true
|
||||
}
|
||||
if !seen[first.ID] || !seen[second.ID] || seen[missing] || len(deleted) != 2 {
|
||||
t.Fatalf("deleted=%v, want existing ids only", deleted)
|
||||
}
|
||||
}
|
||||
+36
-6
@@ -15,6 +15,8 @@ type Task struct {
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PinnedAt *time.Time `json:"pinned_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
ExplorationID int64 `json:"exploration_id"`
|
||||
@@ -304,13 +306,14 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
||||
|
||||
const taskCols = `id, COALESCE(name,''), category_id,
|
||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
|
||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||
var t Task
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Pinned = t.PinnedAt != nil
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
@@ -320,11 +323,11 @@ func (d *DB) SetParentRef(id int64, parentRef string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ListTasks returns alive tasks, newest first.
|
||||
// ListTasks returns alive tasks with pinned tasks first, then newest ids.
|
||||
func (d *DB) ListTasks() ([]*Task, error) {
|
||||
// 按 id 降序:id 是 BIGSERIAL,单调递增即插入顺序,最新的在最前。不用 created_at
|
||||
// 是因为同一时刻创建的多个任务时间戳相同,排序不稳定,列表会频繁换位(见 List)。
|
||||
rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL ORDER BY id DESC`)
|
||||
// id 是 BIGSERIAL,同一时刻创建的任务也有稳定且唯一的顺序。
|
||||
rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL
|
||||
ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -349,6 +352,33 @@ func (d *DB) ListTasks() ([]*Task, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TaskPatch updates task list metadata without changing execution state.
|
||||
type TaskPatch struct {
|
||||
Name *string
|
||||
Pinned *bool
|
||||
}
|
||||
|
||||
// UpdateTask applies a partial task name/pin mutation and returns the updated row.
|
||||
// Re-pinning an already pinned task preserves its original position.
|
||||
func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
||||
task, err := scanTask(d.QueryRow(`UPDATE tasks SET
|
||||
name = CASE WHEN $2::boolean THEN $3 ELSE name 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 AND deleted_at IS NULL
|
||||
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetTask returns one alive task (nil if not found/deleted).
|
||||
func (d *DB) GetTask(id int64) (*Task, error) {
|
||||
t, err := scanTask(d.QueryRow(`SELECT `+taskCols+` FROM tasks WHERE id=$1 AND deleted_at IS NULL`, id))
|
||||
|
||||
@@ -203,6 +203,7 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.cancelConversation(c.ID)
|
||||
if err := pg.DeleteConversation(c.ID); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -210,6 +211,79 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"deleted": c.ID})
|
||||
}
|
||||
|
||||
const maxConversationDeleteBatch = 100
|
||||
|
||||
type conversationDeleteItem struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) cancelConversation(id int64) {
|
||||
busyKey := s.convBusyKey(id)
|
||||
s.chatMu.Lock()
|
||||
cancel := s.chatCancel[busyKey]
|
||||
s.chatMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel(agent.AbortChatStoppedByUser)
|
||||
}
|
||||
}
|
||||
|
||||
// pgDeleteConversationsBatch deletes up to 100 selected conversations in one
|
||||
// database statement. Missing ids are reported per item so a stale list does not
|
||||
// hide what was actually removed.
|
||||
func (s *Server) pgDeleteConversationsBatch(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
if !decodeConversationRequest(w, r, &request) {
|
||||
return
|
||||
}
|
||||
ids := make([]int64, 0, len(request.IDs))
|
||||
seen := make(map[int64]struct{}, len(request.IDs))
|
||||
for _, id := range request.IDs {
|
||||
if id <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "对话 id 无效")
|
||||
return
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 || len(ids) > maxConversationDeleteBatch {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Sprintf("ids 数量必须为 1-%d", maxConversationDeleteBatch))
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
s.cancelConversation(id)
|
||||
}
|
||||
deleted, err := pg.DeleteConversations(ids)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
deletedSet := make(map[int64]struct{}, len(deleted))
|
||||
for _, id := range deleted {
|
||||
deletedSet[id] = struct{}{}
|
||||
}
|
||||
items := make([]conversationDeleteItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
_, ok := deletedSet[id]
|
||||
item := conversationDeleteItem{ID: id, OK: ok}
|
||||
if !ok {
|
||||
item.Error = "conversation not found"
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) convBusyKey(id int64) string { return "conv-" + strconv.FormatInt(id, 10) }
|
||||
|
||||
// pgConversationMessages returns steps after ?since=cursor plus whether a turn is
|
||||
|
||||
+5
-1
@@ -35,6 +35,8 @@ type TaskDTO struct {
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PinnedAt string `json:"pinned_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
Status string `json:"status"` // created | running | paused | done | failed
|
||||
@@ -86,9 +88,11 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
return TaskDTO{
|
||||
ID: t.ID,
|
||||
ExplorationID: t.ExpID,
|
||||
Name: t.Name,
|
||||
Name: lifecycle.Name,
|
||||
CategoryID: lifecycle.CategoryID,
|
||||
CategoryName: lifecycle.CategoryName,
|
||||
Pinned: lifecycle.PinnedAt > 0,
|
||||
PinnedAt: completedRFC(lifecycle.PinnedAt),
|
||||
Description: t.Description,
|
||||
Goal: t.Goal,
|
||||
Status: status,
|
||||
|
||||
+44
-3
@@ -32,6 +32,7 @@ type Task struct {
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
PinnedAt int64 `json:"pinned_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
@@ -77,6 +78,8 @@ type Task struct {
|
||||
// updateLifecycle instead of reading or writing the corresponding Task fields
|
||||
// directly after the task has been published by Manager.
|
||||
type taskLifecycleState struct {
|
||||
Name string
|
||||
PinnedAt int64
|
||||
Status string
|
||||
Paused bool
|
||||
Queued bool
|
||||
@@ -102,6 +105,8 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
|
||||
|
||||
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
||||
return taskLifecycleState{
|
||||
Name: t.Name,
|
||||
PinnedAt: t.PinnedAt,
|
||||
Status: t.Status,
|
||||
Paused: t.Paused,
|
||||
Queued: t.Queued,
|
||||
@@ -124,6 +129,8 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
|
||||
t.lifecycleMu.Lock()
|
||||
state := t.lifecycleSnapshotLocked()
|
||||
update(&state)
|
||||
t.Name = state.Name
|
||||
t.PinnedAt = state.PinnedAt
|
||||
t.Status = state.Status
|
||||
t.Paused = state.Paused
|
||||
t.Queued = state.Queued
|
||||
@@ -698,6 +705,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
|
||||
Name: pt.Name,
|
||||
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
|
||||
PinnedAt: unixOrZero(pt.PinnedAt),
|
||||
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
|
||||
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
|
||||
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
|
||||
@@ -714,6 +722,32 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTaskMetadata changes list-only task metadata without interrupting any
|
||||
// planner, main-agent, or worker call.
|
||||
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
id, err := strconv.ParseInt(taskID, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
updated, err := m.pg.UpdateTask(id, patch)
|
||||
if err != nil || updated == nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.RLock()
|
||||
task := m.tasks[taskID]
|
||||
m.mu.RUnlock()
|
||||
if task == nil {
|
||||
return nil, nil
|
||||
}
|
||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||
state.Name = updated.Name
|
||||
state.PinnedAt = unixOrZero(updated.PinnedAt)
|
||||
})
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CreateTask creates a task + its exploration and makes it active.
|
||||
// timeoutSeconds is the task-level wall-clock budget (0 = 不限时).
|
||||
func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
|
||||
@@ -1558,10 +1592,17 @@ func (m *Manager) List() []*Task {
|
||||
for _, t := range m.tasks {
|
||||
out = append(out, t)
|
||||
}
|
||||
// 按 id 降序(最新插入在最前)。m.tasks 是 map,遍历顺序随机,每次轮询都要重排;
|
||||
// 若按 CreatedAt 排,同一秒创建的任务时间戳相同,排序不稳定,会在列表里频繁换
|
||||
// 位置。id 由 BIGSERIAL 单调递增分配,唯一且稳定,顺序恒定。
|
||||
// 置顶任务优先,组内按置顶时间倒序;普通任务按 id 倒序。m.tasks 是 map,
|
||||
// 每次轮询都必须重排,id 则为同刻创建任务提供稳定且唯一的兜底顺序。
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
iState := out[i].lifecycleSnapshot()
|
||||
jState := out[j].lifecycleSnapshot()
|
||||
if (iState.PinnedAt > 0) != (jState.PinnedAt > 0) {
|
||||
return iState.PinnedAt > 0
|
||||
}
|
||||
if iState.PinnedAt != jState.PinnedAt {
|
||||
return iState.PinnedAt > jState.PinnedAt
|
||||
}
|
||||
ai, _ := strconv.ParseInt(out[i].ID, 10, 64)
|
||||
aj, _ := strconv.ParseInt(out[j].ID, 10, 64)
|
||||
return ai > aj
|
||||
|
||||
@@ -687,6 +687,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
||||
mux.HandleFunc("DELETE /api/task-templates/{id}", s.pgDeleteTaskTemplate)
|
||||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}", s.updateTaskMetadata)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory)
|
||||
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||||
@@ -791,6 +792,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// conversations (chat page)
|
||||
mux.HandleFunc("GET /api/conversations", s.pgListConversations)
|
||||
mux.HandleFunc("POST /api/conversations", s.pgCreateConversation)
|
||||
mux.HandleFunc("POST /api/conversations/delete/batch", s.pgDeleteConversationsBatch)
|
||||
mux.HandleFunc("PATCH /api/conversations/{id}", s.pgRenameConversation)
|
||||
mux.HandleFunc("PATCH /api/conversations/{id}/profile", s.pgUpdateConversation)
|
||||
mux.HandleFunc("DELETE /api/conversations/{id}", s.pgDeleteConversation)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTaskNameRunes = 200
|
||||
maxTaskMetadataRequestSize = 16 << 10
|
||||
)
|
||||
|
||||
// updateTaskMetadata changes list-only task metadata. It intentionally does not
|
||||
// touch lifecycle state, scheduling, or the task's immutable description/goal.
|
||||
func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if _, ok := s.m.Task(taskID); !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
|
||||
var request struct {
|
||||
Name *string `json:"name"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if request.Name == nil && request.Pinned == nil {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name 或 pinned")
|
||||
return
|
||||
}
|
||||
if request.Name != nil {
|
||||
name := strings.TrimSpace(*request.Name)
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, "任务名称不能为空")
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxTaskNameRunes {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Sprintf("任务名称最多 %d 个字符", maxTaskNameRunes))
|
||||
return
|
||||
}
|
||||
request.Name = &name
|
||||
}
|
||||
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTask("metadata patch", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"name": " renamed task ", "pinned": true})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var updated struct {
|
||||
Name string `json:"name"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PinnedAt string `json:"pinned_at"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Name != "renamed task" || !updated.Pinned || updated.PinnedAt == "" {
|
||||
t.Fatalf("unexpected task patch response: %+v", updated)
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{"name": strings.Repeat("任", maxTaskNameRunes+1)})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong task name status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationBatchDeleteReportsMissing(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
first, err := m.pg.CreateConversation("mainagent", "batch-http-first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := m.pg.CreateConversation("mainagent", "batch-http-second", nil)
|
||||
if err != nil {
|
||||
_ = m.pg.DeleteConversation(first.ID)
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = m.pg.DeleteConversation(first.ID)
|
||||
_ = m.pg.DeleteConversation(second.ID)
|
||||
}()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missing := int64(1<<62 - 1)
|
||||
body, _ := json.Marshal(map[string]any{"ids": []int64{first.ID, missing, second.ID}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/conversations/delete/batch", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("batch delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Items []conversationDeleteItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Items) != 3 || !response.Items[0].OK || response.Items[1].OK || !response.Items[2].OK {
|
||||
t.Fatalf("batch delete response=%+v", response.Items)
|
||||
}
|
||||
for _, id := range []int64{first.ID, second.ID} {
|
||||
if conversation, err := m.pg.GetConversation(id); err != nil || conversation != nil {
|
||||
t.Fatalf("conversation %d still exists: %+v err=%v", id, conversation, err)
|
||||
}
|
||||
}
|
||||
if response.Items[1].Error == "" {
|
||||
t.Fatalf("missing conversation %s must include an error", fmt.Sprint(missing))
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -761,6 +762,8 @@ function ConversationItem({
|
||||
onCancelRename,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
selectedForDelete,
|
||||
onSelectedForDeleteChange,
|
||||
}: {
|
||||
conv: Conversation;
|
||||
agent?: Agent;
|
||||
@@ -774,6 +777,8 @@ function ConversationItem({
|
||||
onCancelRename: () => void;
|
||||
onTogglePinned: () => void;
|
||||
onDelete: () => void;
|
||||
selectedForDelete: boolean;
|
||||
onSelectedForDeleteChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const renameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -794,6 +799,12 @@ function ConversationItem({
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedForDelete}
|
||||
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
|
||||
aria-label={`选择对话「${conv.title || "新对话"}」`}
|
||||
className="ml-1 shrink-0"
|
||||
/>
|
||||
{renaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
@@ -894,6 +905,9 @@ export default function ChatPage() {
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||
const [bulkDeleting, setBulkDeleting] = React.useState(false);
|
||||
// Pending text + uploaded attachments handed off from a draft that created a
|
||||
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
||||
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
||||
@@ -923,6 +937,15 @@ export default function ChatPage() {
|
||||
reloadConvs();
|
||||
}, [reloadConvs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedConversationIds((current) => {
|
||||
if (current.size === 0) return current;
|
||||
const live = new Set(convs.map((conversation) => conversation.id));
|
||||
const next = new Set([...current].filter((id) => live.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [convs]);
|
||||
|
||||
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
|
||||
// returns to the same thread instead of the empty draft view. Runs after
|
||||
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
||||
@@ -948,16 +971,80 @@ export default function ChatPage() {
|
||||
// are task-specific and stay hidden from the chat page.
|
||||
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
|
||||
|
||||
const selectedConversationCount = selectedConversationIds.size;
|
||||
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
|
||||
const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected;
|
||||
let conversationHeaderChecked: boolean | "indeterminate" = false;
|
||||
if (allConversationsSelected) conversationHeaderChecked = true;
|
||||
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
|
||||
|
||||
function toggleConversationSelected(id: number, checked: boolean) {
|
||||
setSelectedConversationIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllConversations(checked: boolean) {
|
||||
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
try {
|
||||
await api.deleteConversation(id);
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
setSelectedConversationIds((current) => {
|
||||
if (!current.has(id)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedConversations() {
|
||||
const ids = [...selectedConversationIds];
|
||||
if (ids.length === 0 || bulkDeleting) return;
|
||||
setBulkDeleting(true);
|
||||
const deleted = new Set<number>();
|
||||
const failed: { id: number; error: string }[] = [];
|
||||
try {
|
||||
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||
const result = await api.deleteConversations(ids.slice(offset, offset + 100));
|
||||
for (const item of result.items) {
|
||||
if (item.ok) deleted.add(item.id);
|
||||
else failed.push({ id: item.id, error: item.error ?? "对话不存在" });
|
||||
}
|
||||
}
|
||||
if (deleted.has(selectedId ?? -1)) setSelectedId(null);
|
||||
setSelectedConversationIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of deleted) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
if (deleted.size > 0) toast.success(`已删除 ${deleted.size} 个对话`);
|
||||
if (failed.length > 0) {
|
||||
const details = failed
|
||||
.slice(0, 3)
|
||||
.map((item) => `#${item.id}(${item.error})`)
|
||||
.join(";");
|
||||
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
|
||||
}
|
||||
setBulkDeleteOpen(false);
|
||||
reloadConvs();
|
||||
} catch (error) {
|
||||
toast.error(`批量删除失败:${(error as Error).message}`);
|
||||
reloadConvs();
|
||||
} finally {
|
||||
setBulkDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePinned(conversation: Conversation) {
|
||||
const pinned = conversationIsPinned(conversation);
|
||||
try {
|
||||
@@ -993,10 +1080,33 @@ export default function ChatPage() {
|
||||
<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">
|
||||
<div className="flex flex-col gap-2 border-b p-2">
|
||||
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
||||
<PlusIcon /> 新建对话
|
||||
</Button>
|
||||
{convs.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Checkbox
|
||||
checked={conversationHeaderChecked}
|
||||
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
|
||||
aria-label="选择全部对话"
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
|
||||
</span>
|
||||
{selectedConversationCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={bulkDeleting}
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -1016,6 +1126,8 @@ export default function ChatPage() {
|
||||
onCancelRename={() => setRenamingId(null)}
|
||||
onTogglePinned={() => void togglePinned(c)}
|
||||
onDelete={() => del(c.id)}
|
||||
selectedForDelete={selectedConversationIds.has(c.id)}
|
||||
onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1056,6 +1168,28 @@ export default function ChatPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除选中的 {selectedConversationCount} 个对话?</AlertDialogTitle>
|
||||
<AlertDialogDescription>对话消息和执行记录将一并删除,此操作不可撤销。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkDeleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={bulkDeleting || selectedConversationCount === 0}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void deleteSelectedConversations();
|
||||
}}
|
||||
>
|
||||
{bulkDeleting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
{bulkDeleting ? "删除中" : "确认删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronRightIcon,
|
||||
EyeIcon,
|
||||
FolderInputIcon,
|
||||
@@ -24,6 +27,9 @@ import {
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PauseIcon,
|
||||
PencilIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
@@ -235,12 +241,59 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type TaskSortField = "id" | "created" | "duration" | "status";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const TASK_STATUS_RANK = new Map(STATUS_OPTIONS.map((option, index) => [option.value, index]));
|
||||
|
||||
function taskIsPinned(task: Task): boolean {
|
||||
return task.pinned ?? Boolean(task.pinned_at);
|
||||
}
|
||||
|
||||
function compareTaskIDs(left: string, right: string): number {
|
||||
return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function taskCreatedUnix(task: Task): number {
|
||||
if (task.created_unix) return task.created_unix;
|
||||
const parsed = Date.parse(task.created_at);
|
||||
return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
|
||||
}
|
||||
|
||||
function compareTasks(left: Task, right: Task, field: TaskSortField, direction: SortDirection, nowSec: number): number {
|
||||
const leftPinned = taskIsPinned(left);
|
||||
const rightPinned = taskIsPinned(right);
|
||||
if (leftPinned !== rightPinned) return leftPinned ? -1 : 1;
|
||||
if (leftPinned && left.pinned_at !== right.pinned_at) {
|
||||
return (right.pinned_at ?? "").localeCompare(left.pinned_at ?? "");
|
||||
}
|
||||
|
||||
let compared = 0;
|
||||
switch (field) {
|
||||
case "created":
|
||||
compared = taskCreatedUnix(left) - taskCreatedUnix(right);
|
||||
break;
|
||||
case "duration":
|
||||
compared = taskDuration(left, nowSec) - taskDuration(right, nowSec);
|
||||
break;
|
||||
case "status":
|
||||
compared = (TASK_STATUS_RANK.get(left.status) ?? 0) - (TASK_STATUS_RANK.get(right.status) ?? 0);
|
||||
break;
|
||||
default:
|
||||
compared = compareTaskIDs(left.id, right.id);
|
||||
}
|
||||
if (compared !== 0) return direction === "asc" ? compared : -compared;
|
||||
return -compareTaskIDs(left.id, right.id);
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
|
||||
const [categoryFilter, setCategoryFilter] = React.useState("all");
|
||||
const [sortField, setSortField] = React.useState<TaskSortField>("id");
|
||||
const [sortDirection, setSortDirection] = React.useState<SortDirection>("desc");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
|
||||
@@ -269,15 +322,32 @@ export default function TasksPage() {
|
||||
});
|
||||
}, [tasks, query, statusFilter, categoryFilter]);
|
||||
|
||||
// reset to page 1 whenever filters change
|
||||
const sortNowSec = sortField === "duration" ? nowSec : 0;
|
||||
const ordered = React.useMemo(() => {
|
||||
return [...filtered].sort((left, right) => compareTasks(left, right, sortField, sortDirection, sortNowSec));
|
||||
}, [filtered, sortDirection, sortField, sortNowSec]);
|
||||
|
||||
const sortTasksBy = React.useCallback(
|
||||
(field: TaskSortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSortField(field);
|
||||
setSortDirection("desc");
|
||||
},
|
||||
[sortField],
|
||||
);
|
||||
|
||||
// reset to page 1 whenever filters or ordering change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query, statusFilter, categoryFilter]);
|
||||
}, [query, statusFilter, categoryFilter, sortField, sortDirection]);
|
||||
|
||||
const paginated = React.useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
() => ordered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[ordered, page, pageSize],
|
||||
);
|
||||
|
||||
// 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。
|
||||
@@ -439,6 +509,37 @@ export default function TasksPage() {
|
||||
[load],
|
||||
);
|
||||
|
||||
const renameTask = React.useCallback(
|
||||
async (task: Task, name: string) => {
|
||||
try {
|
||||
await api.renameTask(task.id, name);
|
||||
toast.success(`任务 #${task.id} 已重命名`);
|
||||
lastRef.current = "";
|
||||
load();
|
||||
} catch (error) {
|
||||
toast.error(`重命名失败:${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const toggleTaskPinned = React.useCallback(
|
||||
async (task: Task) => {
|
||||
const pinned = taskIsPinned(task);
|
||||
try {
|
||||
await api.pinTask(task.id, !pinned);
|
||||
toast.success(pinned ? `任务 #${task.id} 已取消置顶` : `任务 #${task.id} 已置顶`);
|
||||
lastRef.current = "";
|
||||
load();
|
||||
} catch (error) {
|
||||
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
|
||||
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
|
||||
const deleteTasks = React.useCallback(
|
||||
@@ -717,14 +818,41 @@ export default function TasksPage() {
|
||||
aria-label="选择本页全部任务"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="font-mono">ID</TableHead>
|
||||
<SortableTaskHead
|
||||
field="id"
|
||||
label="ID"
|
||||
activeField={sortField}
|
||||
direction={sortDirection}
|
||||
className="font-mono"
|
||||
onSort={sortTasksBy}
|
||||
/>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<SortableTaskHead
|
||||
field="status"
|
||||
label="状态"
|
||||
activeField={sortField}
|
||||
direction={sortDirection}
|
||||
onSort={sortTasksBy}
|
||||
/>
|
||||
<TableHead className="text-center">目标进度</TableHead>
|
||||
<TableHead className="text-right">创建时间</TableHead>
|
||||
<TableHead className="text-right">运行时长</TableHead>
|
||||
<SortableTaskHead
|
||||
field="created"
|
||||
label="创建时间"
|
||||
activeField={sortField}
|
||||
direction={sortDirection}
|
||||
align="right"
|
||||
onSort={sortTasksBy}
|
||||
/>
|
||||
<SortableTaskHead
|
||||
field="duration"
|
||||
label="运行时长"
|
||||
activeField={sortField}
|
||||
direction={sortDirection}
|
||||
align="right"
|
||||
onSort={sortTasksBy}
|
||||
/>
|
||||
<TableHead className="text-right">Token</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">
|
||||
操作
|
||||
@@ -741,6 +869,8 @@ export default function TasksPage() {
|
||||
nowSec={task.status === "running" ? nowSec : 0}
|
||||
onDelete={deleteTask}
|
||||
onControl={controlTask}
|
||||
onRename={renameTask}
|
||||
onTogglePinned={toggleTaskPinned}
|
||||
selected={selectedIds.has(task.id)}
|
||||
onSelectedChange={toggleSelected}
|
||||
/>
|
||||
@@ -763,6 +893,56 @@ export default function TasksPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function taskSortIcon(active: boolean, direction: SortDirection) {
|
||||
if (!active) {
|
||||
return <ArrowUpDownIcon className="size-3.5 opacity-40 transition-opacity group-hover/sort:opacity-100" />;
|
||||
}
|
||||
if (direction === "asc") return <ArrowUpIcon className="size-3.5" />;
|
||||
return <ArrowDownIcon className="size-3.5" />;
|
||||
}
|
||||
|
||||
function SortableTaskHead({
|
||||
field,
|
||||
label,
|
||||
activeField,
|
||||
direction,
|
||||
align = "left",
|
||||
className,
|
||||
onSort,
|
||||
}: {
|
||||
field: TaskSortField;
|
||||
label: string;
|
||||
activeField: TaskSortField;
|
||||
direction: SortDirection;
|
||||
align?: "left" | "right";
|
||||
className?: string;
|
||||
onSort: (field: TaskSortField) => void;
|
||||
}) {
|
||||
const active = activeField === field;
|
||||
let ariaSort: React.AriaAttributes["aria-sort"] = "none";
|
||||
if (active) ariaSort = direction === "asc" ? "ascending" : "descending";
|
||||
|
||||
let actionLabel = `按${label}倒序排序`;
|
||||
if (active) actionLabel = `${label}当前${direction === "asc" ? "正序" : "倒序"},点击切换排序方向`;
|
||||
|
||||
return (
|
||||
<TableHead className={className} aria-sort={ariaSort}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/sort inline-flex h-full w-full items-center gap-1 outline-none focus-visible:underline",
|
||||
align === "right" && "justify-end",
|
||||
)}
|
||||
aria-label={actionLabel}
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{taskSortIcon(active, direction)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
function ConcurrencySettingsDialog() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
@@ -858,6 +1038,8 @@ const TaskRow = React.memo(function TaskRow({
|
||||
nowSec,
|
||||
onDelete,
|
||||
onControl,
|
||||
onRename,
|
||||
onTogglePinned,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
}: {
|
||||
@@ -865,6 +1047,8 @@ const TaskRow = React.memo(function TaskRow({
|
||||
nowSec: number;
|
||||
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
|
||||
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
|
||||
onRename: (task: Task, name: string) => Promise<void>;
|
||||
onTogglePinned: (task: Task) => Promise<void>;
|
||||
selected: boolean;
|
||||
onSelectedChange: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
@@ -890,6 +1074,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
{task.name?.trim() ? task.name : <span className="text-muted-foreground">未命名</span>}
|
||||
</Link>
|
||||
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
|
||||
{taskIsPinned(task) && <PinIcon className="text-primary size-4 shrink-0" aria-label="已置顶" />}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-xs">
|
||||
@@ -954,6 +1139,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
</Link>
|
||||
</Button>
|
||||
<TaskControlButton task={task} onControl={onControl} />
|
||||
<TaskMetadataActions task={task} onRename={onRename} onTogglePinned={onTogglePinned} />
|
||||
<DeleteTaskDialog task={task} onDelete={onDelete} />
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -961,6 +1147,120 @@ const TaskRow = React.memo(function TaskRow({
|
||||
);
|
||||
});
|
||||
|
||||
function taskPinIcon(pinning: boolean, pinned: boolean) {
|
||||
if (pinning) return <Spinner />;
|
||||
if (pinned) return <PinOffIcon />;
|
||||
return <PinIcon />;
|
||||
}
|
||||
|
||||
function TaskMetadataActions({
|
||||
task,
|
||||
onRename,
|
||||
onTogglePinned,
|
||||
}: {
|
||||
task: Task;
|
||||
onRename: (task: Task, name: string) => Promise<void>;
|
||||
onTogglePinned: (task: Task) => Promise<void>;
|
||||
}) {
|
||||
const [renameOpen, setRenameOpen] = React.useState(false);
|
||||
const [name, setName] = React.useState(task.name ?? "");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [pinning, setPinning] = React.useState(false);
|
||||
const pinned = taskIsPinned(task);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (renameOpen) setName(task.name ?? "");
|
||||
}, [renameOpen, task.name]);
|
||||
|
||||
async function saveName() {
|
||||
const next = name.trim();
|
||||
if (!next) return;
|
||||
if (next === task.name?.trim()) {
|
||||
setRenameOpen(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onRename(task, next);
|
||||
setRenameOpen(false);
|
||||
} catch {
|
||||
// The parent already reports the API error; keep the dialog open for retry.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`重命名任务 #${task.id}`}
|
||||
title="重命名"
|
||||
onClick={() => setRenameOpen(true)}
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={pinning}
|
||||
aria-label={pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`}
|
||||
title={pinned ? "取消置顶" : "置顶"}
|
||||
onClick={async () => {
|
||||
setPinning(true);
|
||||
try {
|
||||
await onTogglePinned(task);
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{taskPinIcon(pinning, pinned)}
|
||||
</Button>
|
||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名任务 #{task.id}</DialogTitle>
|
||||
<DialogDescription>新名称只用于列表和详情展示,不会改变任务描述、目标或执行状态。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`task-rename-${task.id}`}>任务名称</FieldLabel>
|
||||
<Input
|
||||
id={`task-rename-${task.id}`}
|
||||
value={name}
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void saveName();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={saving || !name.trim()} onClick={() => void saveName()}>
|
||||
{saving && <Spinner data-icon="inline-start" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。
|
||||
function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) {
|
||||
if (pending) return <Loader2Icon className="animate-spin" />;
|
||||
|
||||
@@ -215,6 +215,9 @@ export const api = {
|
||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||
}),
|
||||
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
||||
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
|
||||
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
||||
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
|
||||
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
|
||||
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
|
||||
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
||||
@@ -704,6 +707,8 @@ export const api = {
|
||||
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
||||
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
||||
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
|
||||
deleteConversations: (ids: number[]) =>
|
||||
post<{ items: { id: number; ok: boolean; error?: string }[] }>("/conversations/delete/batch", { ids }),
|
||||
// Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch.
|
||||
conversationMessages: (id: number, since = 0) =>
|
||||
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
|
||||
|
||||
@@ -233,6 +233,16 @@ function sortMockConversations() {
|
||||
});
|
||||
}
|
||||
|
||||
function sortMockTasks() {
|
||||
mockTasks.sort((a, b) => {
|
||||
const aPinned = a.pinned_at ? 1 : 0;
|
||||
const bPinned = b.pinned_at ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
if (a.pinned_at !== b.pinned_at) return (b.pinned_at ?? "").localeCompare(a.pinned_at ?? "");
|
||||
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedTemplateName(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
@@ -268,7 +278,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/auth/change-password") return { ok: true };
|
||||
|
||||
// ── tasks ──
|
||||
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
|
||||
if (path === "/tasks" && m === "GET") {
|
||||
sortMockTasks();
|
||||
return { tasks: mockTasks, active: mockActiveTask };
|
||||
}
|
||||
if (path === "/tasks" && m === "POST") {
|
||||
let suffix = 1;
|
||||
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
|
||||
@@ -436,6 +449,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
||||
return { deleted: id };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
if (typeof b.name === "string") task.name = b.name.trim();
|
||||
if (typeof b.pinned === "boolean") {
|
||||
task.pinned = b.pinned;
|
||||
task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
|
||||
}
|
||||
sortMockTasks();
|
||||
return structuredClone(task);
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
|
||||
const id = seg[1];
|
||||
const index = mockTasks.findIndex((item) => item.id === id);
|
||||
@@ -1187,6 +1211,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (index >= 0) mockConversations.splice(index, 1);
|
||||
return { deleted: id };
|
||||
}
|
||||
if (path === "/conversations/delete/batch" && m === "POST") {
|
||||
const ids = Array.isArray(b.ids)
|
||||
? [...new Set(b.ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||
: [];
|
||||
const items = ids.map((id) => {
|
||||
const index = mockConversations.findIndex((item) => item.id === id);
|
||||
if (index < 0) return { id, ok: false, error: "conversation not found" };
|
||||
mockConversations.splice(index, 1);
|
||||
return { id, ok: true };
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Task {
|
||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||
category_id?: number;
|
||||
category_name?: string;
|
||||
pinned?: boolean;
|
||||
pinned_at?: string | null;
|
||||
description: string;
|
||||
goal: string;
|
||||
status: TaskStatus;
|
||||
|
||||
Reference in new issue
Block a user