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:
pphua committed 2026-08-25 14:56:15 +08:00
1 parent 48e85b33d6
commit 86eb67ea0a
15 files changed
+957 -20

No files matched your search

+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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)
+66
View File
@@ -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)))
}
+113
View File
@@ -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))
}
}