From add1fbb045a3cc3cafee0efd0c252c96e6865d08 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 02:12:42 +0000 Subject: [PATCH] Add project-level surface aggregation and task-phase guidance (U6) Why: U6 extends pages from conversations into the task/project workspace - surfaces aggregate across conversations under the owner's project scope (project resolved authoritatively from the conversation row, never self-reported by the model), and the catalog guidance now organizes pages by task phase (requirements/execution/acceptance) while stating that task facts come only from real task records. Evidence: build/vet clean; surfacesvc suite green incl. the new project-aggregation test (owner isolation, cross-owner zero, history retained post-disable); genui/store suites green. Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves project from conversation; catalog text guidance. Existing endpoints unchanged. Refs: proposal r2 U6; Dev task feature/generative-ui-u6 --- internal/genui/genui.go | 1 + internal/server/platform_api.go | 14 ++++++ internal/server/server.go | 1 + internal/store/surfaces.go | 30 ++++++++++++ internal/surfacesvc/actions_test.go | 75 +++++++++++++++++++++++++++-- internal/surfacesvc/surfacesvc.go | 17 ++++++- 6 files changed, 133 insertions(+), 5 deletions(-) diff --git a/internal/genui/genui.go b/internal/genui/genui.go index 8504229..ebcf0a7 100644 --- a/internal/genui/genui.go +++ b/internal/genui/genui.go @@ -28,6 +28,7 @@ const ( // Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带, // 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。 const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。 +任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。 可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段): section{title?,children?};list{items:string[]};kv{kv:[[k,v]]}; diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go index 69e8cf2..ee5ae69 100644 --- a/internal/server/platform_api.go +++ b/internal/server/platform_api.go @@ -319,3 +319,17 @@ func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusInternalServerError) } } + +// listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。 +func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) { + if s.Surfaces == nil { + http.Error(w, "surface capability not enabled", http.StatusNotImplemented) + return + } + metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) + if err != nil { + s.writeSurfaceError(w, err) + return + } + writeJSON(w, http.StatusOK, orEmptySlice(metas)) +} diff --git a/internal/server/server.go b/internal/server/server.go index 340a795..b695a76 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -321,6 +321,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface) mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction) mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces) + mux.HandleFunc("GET /api/projects/{id}/surfaces", s.listProjectSurfaces) mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins) mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema) mux.HandleFunc("GET /api/registry", s.registry) diff --git a/internal/store/surfaces.go b/internal/store/surfaces.go index f77bd94..0ebf068 100644 --- a/internal/store/surfaces.go +++ b/internal/store/surfaces.go @@ -295,3 +295,33 @@ func (s *Store) ListSurfaceEventsPending(limit int) ([]SurfaceRevisionRow, error } return out, rows.Err() } + +// ListSurfacesByProject 项目级跨会话聚合(U6/R8):按 owner 授权作用域列出项目下 +// 全部页面(按 updated_at 新→旧);owner 列在 surfaces 表上,跨 owner 天然隔离。 +func (s *Store) ListSurfacesByProject(owner, projectID string, limit int) ([]core.SurfaceMeta, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.db.Query(s.q(`SELECT s.surface_id, s2.spec, s.plugin_id, s.latest_revision, s.conversation_id, s.updated_at + FROM surfaces s + JOIN surface_revisions s2 ON s2.surface_id=s.surface_id AND s2.revision=s.latest_revision + WHERE s.owner=? AND s.project_id=? ORDER BY s.updated_at DESC LIMIT ?`), + owner, projectID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []core.SurfaceMeta + for rows.Next() { + var m core.SurfaceMeta + var specJSON string + if err := rows.Scan(&m.SurfaceID, &specJSON, &m.PluginID, &m.Revision, &m.ConversationID, &m.UpdatedAt); err != nil { + return nil, err + } + var spec core.SurfaceSpec + _ = json.Unmarshal([]byte(specJSON), &spec) + m.Title = spec.Title + out = append(out, m) + } + return out, rows.Err() +} diff --git a/internal/surfacesvc/actions_test.go b/internal/surfacesvc/actions_test.go index 4d309ae..a96dcd1 100644 --- a/internal/surfacesvc/actions_test.go +++ b/internal/surfacesvc/actions_test.go @@ -16,10 +16,42 @@ import ( ) type actionFixture struct { - svc *Service - st *store.Store - surface core.SurfaceIdentity - rev int + svc *Service + st *store.Store + surface core.SurfaceIdentity + rev int + projectID string +} + +func newActionFixtureWithConversation(t *testing.T, projectID string) *actionFixture { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + svc := New(st, nil) + ctx := context.Background() + if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil { + t.Fatal(err) + } + pj, err := st.CreateProject("alice", "U6测试项目") + if err != nil { + t.Fatal(err) + } + projectID = string(pj.ID) + if _, err := st.DB().Exec(`INSERT INTO conversations(id,project_id,owner,title,created_at,updated_at) VALUES(?,?,?,'U6会话',datetime('now'),datetime('now'))`, + "c1", projectID, "alice"); err != nil { + t.Fatal(err) + } + snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{ + ConversationID: "c1", + Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`), + }) + if err != nil { + t.Fatal(err) + } + return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision, projectID: projectID} } func newActionFixture(t *testing.T) *actionFixture { @@ -152,3 +184,38 @@ func TestDangerHighGoesThroughConfirmCard(t *testing.T) { t.Fatalf("replay should return awaiting_confirm, got %+v err=%v", res2, err) } } + +func TestProjectAggregationOwnerIsolation(t *testing.T) { + // U6/R8 验收:项目级跨会话聚合按 owner 隔离(跨 owner 不可见); + // 禁用不阻断读取(历史页面保留)。 + f := newActionFixtureWithConversation(t, "prj_u6") + ctx := context.Background() + snap2, err := f.svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{ + ConversationID: "c1", + Spec: json.RawMessage(`{"title":"执行进度页","nodes":[{"type":"steps","id":"s1","steps":{"current":1,"steps":[{"label":"需求冻结","status":"done"},{"label":"实施","status":"active"}]}}]}`), + }) + if err != nil { + t.Fatal(err) + } + metas, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID)) + if err != nil { + t.Fatal(err) + } + if len(metas) != 2 { + t.Fatalf("alice should see her project surfaces, got %+v", metas) + } + // 跨 owner 隔离:bob 看不到 alice 的项目页面 + bobMetas, err := f.svc.ListByProject(ctx, "bob", core.ID(f.projectID)) + if err != nil || len(bobMetas) != 0 { + t.Fatalf("bob must see zero surfaces, got %+v err=%v", bobMetas, err) + } + // 禁用后读取保留 + if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil { + t.Fatal(err) + } + after, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID)) + if err != nil || len(after) != 2 { + t.Fatalf("post-disable read must retain history, got %+v err=%v", after, err) + } + _ = snap2 +} diff --git a/internal/surfacesvc/surfacesvc.go b/internal/surfacesvc/surfacesvc.go index 0b4f1f9..d305900 100644 --- a/internal/surfacesvc/surfacesvc.go +++ b/internal/surfacesvc/surfacesvc.go @@ -36,6 +36,7 @@ func (e *ErrInvalidSpec) Error() string { return "surface: invalid spec: " + fmt // ObservedEpoch>0 时校验与当前 epoch 一致,拦截「禁用后迟到的在途结果」。 type SubmitRequest struct { SurfaceID core.ID + ProjectID core.ID ConversationID core.ID SourceRunID core.ID BaseRevision int @@ -87,9 +88,16 @@ func (s *Service) Submit(ctx context.Context, owner, pluginID string, req Submit if surfaceID == "" { surfaceID = core.ID(store.NewID("surf")) } + projectID := req.ProjectID + if projectID == "" && req.ConversationID != "" { + // U6:项目归属从会话权威反查(模型不能自报 project;会话表是唯一来源)。 + if conv, err := s.st.GetConversation(owner, string(req.ConversationID)); err == nil { + projectID = conv.ProjectID + } + } snap := core.SurfaceSnapshot{ Identity: core.SurfaceIdentity{ - SurfaceID: surfaceID, Owner: owner, ConversationID: req.ConversationID, + SurfaceID: surfaceID, Owner: owner, ProjectID: projectID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID, PluginID: pluginID, }, Compat: core.SurfaceCompat{SchemaVersion: core.SurfaceSchemaVersion, CatalogVersion: core.SurfaceCatalogVersion}, @@ -335,3 +343,10 @@ func orEmptyJSON(s string) string { } return s } + +// ListByProject 项目级跨会话聚合(U6/R8):owner 授权作用域内列出项目下全部 +// 页面元数据。授权口径与项目工件/卡片一致——项目属 owner,跨 owner 隔离在 +// 存储层完成;禁用不阻断读取(历史页面保留,KTD7)。 +func (s *Service) ListByProject(ctx context.Context, owner string, projectID core.ID) ([]core.SurfaceMeta, error) { + return s.st.ListSurfacesByProject(owner, string(projectID), 50) +} -- 2.54.0