Task list APIs with pin/archive flags and /api/me (R1) #15

Merged
weichen merged 3 commits from feature/task-sidebar-r1 into main 2026-09-30 10:45:22 +00:00
7 changed files with 240 additions and 14 deletions
+58
View File
@@ -744,3 +744,61 @@ func TestModelRoutingOverrideChain(t *testing.T) {
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
}
}
// TestTaskListAndFlags 方案 A R1:/api/me 身份、跨项目任务列表与置顶/归档 PATCH。
func TestTaskListAndFlags(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
p1 := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "P1"})
p2 := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "P2"})
c1 := mustCode(t, h, "POST", "/api/projects/"+p1["id"].(string)+"/conversations", map[string]any{"title": "t1"})
c2 := mustCode(t, h, "POST", "/api/projects/"+p2["id"].(string)+"/conversations", map[string]any{"title": "t2"})
// 身份:嵌入式档(无认证中间件)→ 默认租户,auth=false。
code, me := doJSON(t, h, "GET", "/api/me", nil)
if code != 200 || me["owner"] != store.OwnerDefault || me["auth"] != false {
t.Fatalf("me: code=%d owner=%v auth=%v", code, me["owner"], me["auth"])
}
if s, _ := me["name"].(string); s == "" {
t.Fatalf("me.name must be non-empty, got %v", me["name"])
}
// 跨项目任务列表:两个项目的会话都在。
code, list := doAny(t, h, "GET", "/api/conversations", nil)
if code != 200 {
t.Fatalf("listTasks code=%d", code)
}
arr, _ := list.([]any)
if len(arr) != 2 {
t.Fatalf("task list must span projects, got %d", len(arr))
}
// 置顶 t2 → 列表首;归档 t1 → 默认隐藏、?archived=1 可见。
mustCode(t, h, "PATCH", "/api/conversations/"+c2["id"].(string), map[string]any{"pinned": true})
mustCode(t, h, "PATCH", "/api/conversations/"+c1["id"].(string), map[string]any{"archived": true})
_, list = doAny(t, h, "GET", "/api/conversations", nil)
arr, _ = list.([]any)
if len(arr) != 1 || arr[0].(map[string]any)["id"] != c2["id"] || arr[0].(map[string]any)["pinned"] != true {
t.Fatalf("pinned-first and archived-hidden expected, got %+v", list)
}
_, list = doAny(t, h, "GET", "/api/conversations?archived=1", nil)
arr, _ = list.([]any)
if len(arr) != 2 {
t.Fatalf("archived=1 must include archived, got %d", len(arr))
}
// 解除归档恢复可见。
mustCode(t, h, "PATCH", "/api/conversations/"+c1["id"].(string), map[string]any{"archived": false})
_, list = doAny(t, h, "GET", "/api/conversations", nil)
arr, _ = list.([]any)
if len(arr) != 2 {
t.Fatalf("unarchive must restore visibility, got %d", len(arr))
}
// 详情读取带标记(对账权威路径)。
det := mustCode(t, h, "GET", "/api/conversations/"+c2["id"].(string), nil)
if det["conversation"].(map[string]any)["pinned"] != true {
t.Fatalf("detail must carry pinned flag, got %+v", det["conversation"])
}
}
+26 -2
View File
@@ -134,6 +134,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/projects/{id}", s.deleteProject)
mux.HandleFunc("GET /api/projects/{id}/conversations", s.listConversations)
mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation)
mux.HandleFunc("GET /api/conversations", s.listTasks)
mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail)
mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation)
mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation)
@@ -190,6 +191,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge)
mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)
mux.HandleFunc("GET /api/me", s.me)
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": time.Now().UTC()})
})
@@ -376,6 +378,23 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
writeJSONOr(w, cs, err)
}
// listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("archived")
cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true")
writeJSONOr(w, cs, err)
}
// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
name := owner
if u, err := s.st.GetUser(owner); err == nil && u.Name != "" {
name = u.Name
}
writeJSON(w, http.StatusOK, map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled})
}
func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
@@ -413,9 +432,15 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
ProfileID string `json:"profileId"`
Pinned *bool `json:"pinned"`
Archived *bool `json:"archived"`
}
json.NewDecoder(r.Body).Decode(&req)
writeOKOr(w, s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID))
if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived))
}
func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) {
@@ -1082,7 +1107,6 @@ func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) {
}
writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id")))
}
func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, ingest.ParsersDescription())
}
+25
View File
@@ -237,6 +237,31 @@ func init() {
`CREATE TABLE IF NOT EXISTS prompt_modules (id TEXT NOT NULL, owner TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(id, owner))`,
}},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS prompt_modules`}, "postgres": {`DROP TABLE IF EXISTS prompt_modules`}}},
migration{version: 9, name: "conversation_flags",
// 会话置顶/归档(提案 2026-09-30 方案 A R1):默认 0 兼容既有行;
// owner+updated_at 索引服务跨项目任务列表(GET /api/conversations)。
up: map[string][]string{"sqlite": {
`ALTER TABLE conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`,
`CREATE INDEX IF NOT EXISTS idx_conversations_owner ON conversations(owner, updated_at)`,
}, "postgres": {
`ALTER TABLE conversations ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT FALSE`,
`ALTER TABLE conversations ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE`,
`CREATE INDEX IF NOT EXISTS idx_conversations_owner ON conversations(owner, updated_at)`,
}},
down: map[string][]string{"sqlite": {
// SQLite 兼容路径不假设支持 DROP COLUMN:重建去列;
// 回滚丢弃置顶/归档偏好(本端组织态),会话本体保留。
`DROP INDEX IF EXISTS idx_conversations_owner`,
`CREATE TABLE conversations_old (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`INSERT INTO conversations_old(id, project_id, title, profile_id, owner, created_at, updated_at) SELECT id, project_id, title, profile_id, owner, created_at, updated_at FROM conversations`,
`DROP TABLE conversations`,
`ALTER TABLE conversations_old RENAME TO conversations`,
}, "postgres": {
`DROP INDEX IF EXISTS idx_conversations_owner`,
`ALTER TABLE conversations DROP COLUMN archived`,
`ALTER TABLE conversations DROP COLUMN pinned`,
}}},
)
}
+71 -7
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"errors"
"strings"
"time"
core "agent-system-core"
@@ -136,7 +137,7 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
}
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
if err != nil {
return nil, err
}
@@ -144,23 +145,58 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation,
out := []core.Conversation{}
for rows.Next() {
var c core.Conversation
var ca, ua string
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua); err != nil {
if err := scanConversation(rows, &c); err != nil {
return nil, err
}
c.Owner = owner
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
out = append(out, c)
}
return out, rows.Err()
}
// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。
func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) {
q := `SELECT id,project_id,title,profile_id,pinned,archived,created_at,updated_at FROM conversations WHERE owner=?`
if !includeArchived {
q += ` AND archived=0`
}
q += ` ORDER BY pinned DESC, updated_at DESC`
rows, err := s.db.Query(s.q(q), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Conversation{}
for rows.Next() {
var c core.Conversation
if err := scanConversation(rows, &c); err != nil {
return nil, err
}
c.Owner = owner
out = append(out, c)
}
return out, rows.Err()
}
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
var ca, ua string
var pinned, archived int
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &ca, &ua); err != nil {
return err
}
c.Pinned = pinned == 1
c.Archived = archived == 1
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
return nil
}
func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
var c core.Conversation
var ca, ua string
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua)
var pinned, archived int
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return c, ErrNotFound
}
@@ -168,6 +204,8 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
return c, err
}
c.Owner = owner
c.Pinned = pinned == 1
c.Archived = archived == 1
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
return c, nil
@@ -198,6 +236,32 @@ func (s *Store) UpdateConversation(owner, id, title, profileID string) error {
return nil
}
// SetConversationFlags 更新会话置顶/归档标记(nil = 该字段不动;方案 A R1)。
func (s *Store) SetConversationFlags(owner, id string, pinned, archived *bool) error {
var sets []string
var args []any
if pinned != nil {
sets = append(sets, "pinned=?")
args = append(args, boolInt(*pinned))
}
if archived != nil {
sets = append(sets, "archived=?")
args = append(args, boolInt(*archived))
}
if len(sets) == 0 {
return nil
}
args = append(args, nowISO(), id, owner)
res, err := s.db.Exec(s.q(`UPDATE conversations SET `+strings.Join(sets, ", ")+`, updated_at=? WHERE id=? AND owner=?`), args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteConversation(owner, id string) error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
}
+2 -2
View File
@@ -32,8 +32,8 @@ const OwnerDefault = "local"
type Store struct {
db *sql.DB
dialect dialect
dataDir string // 工件对象目录(备份路径等诊断用)
blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储)
dataDir string // 工件对象目录(备份路径等诊断用)
blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储)
}
// OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。
+57 -2
View File
@@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(got) != 8 {
t.Fatalf("expected versions [1..8], got %v", got)
if len(got) != 9 {
t.Fatalf("expected versions [1..9], got %v", got)
}
// 老库形态(v2 前):events 全局自增、settings 单列主键。
if err := st.MigrateDown(1); err != nil {
@@ -396,3 +396,58 @@ func TestProfileTenantScope(t *testing.T) {
t.Fatalf("cross-tenant profile: %v", err)
}
}
// TestConversationFlagsAndTaskList 方案 A R1:置顶/归档标记与跨项目任务列表(租户隔离负例)。
func TestConversationFlagsAndTaskList(t *testing.T) {
st := openTestStore(t)
a, b := "alice", "bob"
pa, err := st.CreateProject(a, "甲")
if err != nil {
t.Fatal(err)
}
pb, err := st.CreateProject(a, "乙")
if err != nil {
t.Fatal(err)
}
ca, err := st.CreateConversation(a, pa.ID, "t1", "")
if err != nil {
t.Fatal(err)
}
cb, err := st.CreateConversation(a, pb.ID, "t2", "")
if err != nil {
t.Fatal(err)
}
yes := true
// 置顶跨项目列表排首;归档默认隐藏。
if err := st.SetConversationFlags(a, cb.ID, &yes, nil); err != nil {
t.Fatal(err)
}
tasks, err := st.ListTasks(a, false)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 || tasks[0].ID != cb.ID || !tasks[0].Pinned {
t.Fatalf("pinned task must sort first, got %+v", tasks)
}
if err := st.SetConversationFlags(a, ca.ID, nil, &yes); err != nil {
t.Fatal(err)
}
tasks, _ = st.ListTasks(a, false)
if len(tasks) != 1 || tasks[0].ID != cb.ID {
t.Fatalf("archived must be hidden by default, got %+v", tasks)
}
tasks, _ = st.ListTasks(a, true)
if len(tasks) != 2 {
t.Fatalf("includeArchived must return all, got %d", len(tasks))
}
// 跨租户标记写入一律 NotFound。
no := false
if err := st.SetConversationFlags(b, ca.ID, &no, nil); err != ErrNotFound {
t.Fatalf("cross-tenant flag write must be NotFound, got %v", err)
}
if got, _ := st.ListTasks(b, true); len(got) != 0 {
t.Fatalf("task list must be tenant-scoped, got %d", len(got))
}
}