Add project-level surface aggregation and task-phase guidance (U6) #163

Merged
weichen merged 1 commits from feature/generative-ui-u6 into main 2026-10-12 02:12:59 +00:00
6 changed files with 133 additions and 5 deletions
+1
View File
@@ -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]]};
+14
View File
@@ -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))
}
+1
View File
@@ -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)
+30
View File
@@ -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()
}
+71 -4
View File
@@ -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
}
+16 -1
View File
@@ -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)
}