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
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user