Merge pull request 'Implement EXT-13/15: shared retrieval inside runs and shared project listing/conversations' (#113) from feature/ext-13-15-shared-runs into main
ci / vet-and-test (push) Successful in 41s
ci / vet-and-test (push) Successful in 41s
This commit was merged in pull request #113.
This commit is contained in:
@@ -387,6 +387,12 @@ func Run(opt Options) error {
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
ok, err := authzSlot.Authorize(owner, projectID)
|
||||
return err == nil && ok
|
||||
})
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
applyCompiled(skillsRoot, bus, caps)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):进程内 Host.KnowledgeSearch
|
||||
// 的检索面在适配器放行后吃到他人项目的共享知识;未放行保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newExt13Host EXT-13 装配:真实 store + rag 管线 + authz 槽 + inprocHost(bob 视角)。
|
||||
func newExt13Host(t *testing.T) (interface {
|
||||
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
|
||||
}, *projectAuthz) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
authz := &projectAuthz{}
|
||||
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set, authz: authz}, pluginID: "rdm", owner: "bob"}
|
||||
// alice 已发布的项目知识(带 ProjectID;模拟发布状态机产物)。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: "p-1",
|
||||
Source: "plugin:rdm", Ref: "ext13-1", Chunk: "EXT13共享独占令牌陈述"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h, authz
|
||||
}
|
||||
|
||||
func TestInprocHostKnowledgeSearchShared(t *testing.T) {
|
||||
h, authz := newExt13Host(t)
|
||||
ctx := context.Background()
|
||||
q := core.RetrievalQuery{Text: "EXT13共享独占令牌", TopK: 5}
|
||||
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("before grant: hits=%d err=%v, want 0(未放行零泄露)", len(hits), err)
|
||||
}
|
||||
// 适配器放行 bob × p-1 → Host 检索面吃到 alice 的共享命中。
|
||||
// (槽为首个注册者生效——切换授权改闭包内变量,不重注册。)
|
||||
allowBob := true
|
||||
authz.set("rdm", func(owner string, projectID core.ID) (bool, error) {
|
||||
return allowBob && owner == "bob" && string(projectID) == "p-1", nil
|
||||
})
|
||||
hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("after grant: host search got 0 shared hits, want ≥1(EXT-13)")
|
||||
}
|
||||
// 指定他人 ProjectID 时同样经适配器(发布边界在下游状态机)。
|
||||
if hits, err := h.KnowledgeSearch(ctx, "p-1", q.Text, q.TopK); err != nil || len(hits) == 0 {
|
||||
t.Fatalf("scoped to shared project: hits=%d err=%v, want ≥1", len(hits), err)
|
||||
}
|
||||
// 撤销(同一注册内翻转授权)→ 共享命中即停、零泄露。
|
||||
allowBob = false
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("after revoke: hits=%d err=%v, want 0", len(hits), err)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,25 @@ func wireScopedHostFactory(caps hostcaps) {
|
||||
}
|
||||
|
||||
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
|
||||
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
|
||||
// 的具体类型断言散落)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
|
||||
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
|
||||
if c.authz != nil {
|
||||
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
|
||||
ok, err := c.authz.Authorize(owner, pid)
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), q)
|
||||
}
|
||||
|
||||
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
|
||||
|
||||
@@ -52,9 +52,12 @@ type Manager struct {
|
||||
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
|
||||
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
|
||||
OnUsage func(owner string, tokens int)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
|
||||
workspaceRoot string
|
||||
|
||||
@@ -153,6 +156,12 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
|
||||
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
|
||||
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
|
||||
|
||||
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
|
||||
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
|
||||
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
|
||||
m.sharedKnowledgeAllow = fn
|
||||
}
|
||||
|
||||
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
|
||||
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
|
||||
@@ -160,6 +169,11 @@ func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
|
||||
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||||
|
||||
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
// ---- runCtx 实现(runtime.RunCtx)----
|
||||
|
||||
type runCtx struct {
|
||||
@@ -731,6 +745,13 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
q.ProjectID = c.run.ProjectID
|
||||
}
|
||||
if c.mgr.retriever != nil {
|
||||
if c.mgr.sharedKnowledgeAllow != nil {
|
||||
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
|
||||
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
|
||||
}
|
||||
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package runmgr
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):回合内检索(knowledge_search
|
||||
// 工具与 @knowledge 注入的同一入口 runCtx.Retrieve)在适配器放行后吃到共享命中;
|
||||
// 未放行/未注册谓词时保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type sharedFakeRetriever struct {
|
||||
own []core.RetrievalHit
|
||||
shared []core.RetrievalHit
|
||||
gotAlt bool
|
||||
}
|
||||
|
||||
func (f *sharedFakeRetriever) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
return f.own, nil
|
||||
}
|
||||
|
||||
func (f *sharedFakeRetriever) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
|
||||
f.gotAlt = true
|
||||
out := append([]core.RetrievalHit{}, f.own...) // 对齐 rag.Pipeline:本人两路如常
|
||||
for _, h := range f.shared {
|
||||
if allow(h.Doc.Owner, h.Doc.ProjectID) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestRunContextRetrieveShared(t *testing.T) {
|
||||
m := New(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
fr := &sharedFakeRetriever{
|
||||
own: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "own-1", Chunk: "本人命中"}}},
|
||||
shared: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "sh-1", Chunk: "共享命中", Owner: "alice", ProjectID: "p-1"}}},
|
||||
}
|
||||
m.SetRetriever(fr)
|
||||
rc := &runCtx{mgr: m, run: core.Run{Owner: "bob"}}
|
||||
|
||||
// 未注册共享谓词:本人两路如常,不触碰共享通道。
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
|
||||
t.Fatalf("no predicate: hits=%+v err=%v", hits, err)
|
||||
}
|
||||
if fr.gotAlt {
|
||||
t.Fatal("no predicate: must not call RetrieveShared(未注册=现状)")
|
||||
}
|
||||
|
||||
// 注册谓词(放行 bob×p-1)→ 本人 + 共享。
|
||||
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
return owner == "bob" && string(projectID) == "p-1"
|
||||
})
|
||||
fr.gotAlt = false
|
||||
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 2 {
|
||||
t.Fatalf("with predicate: hits=%+v err=%v, want own+shared", hits, err)
|
||||
}
|
||||
if !fr.gotAlt {
|
||||
t.Fatal("with predicate: RetrieveShared not consulted(EXT-13)")
|
||||
}
|
||||
|
||||
// 谓词拒绝 → 只剩本人。
|
||||
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { return false })
|
||||
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
|
||||
t.Fatalf("denied: hits=%+v err=%v, want own only", hits, err)
|
||||
}
|
||||
}
|
||||
@@ -435,9 +435,41 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- handlers:项目与会话 ----
|
||||
|
||||
// projectView 项目 API 视图:core.Project 字段原样 + EXT-15 共享标注(本人自有
|
||||
// 项缺省 false/省略;适配器放行的他人项目为 true,供前端区分展示)。
|
||||
type projectView struct {
|
||||
core.Project
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
ps, err := s.st.ListProjects(s.owner(r))
|
||||
writeJSONOr(w, ps, err)
|
||||
owner := s.owner(r)
|
||||
ps, err := s.st.ListProjects(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]projectView, 0, len(ps))
|
||||
own := make(map[string]bool, len(ps))
|
||||
for _, p := range ps {
|
||||
own[string(p.ID)] = true
|
||||
out = append(out, projectView{Project: p})
|
||||
}
|
||||
// EXT-15:适配器已注册时,追加经放行的共享项目(他人 owner、去重)。
|
||||
if s.ProjectAuthz != nil {
|
||||
all, err := s.st.ListAllProjects()
|
||||
if err == nil {
|
||||
for _, p := range all {
|
||||
if own[string(p.ID)] || string(p.Owner) == owner {
|
||||
continue
|
||||
}
|
||||
if ok, aerr := s.ProjectAuthz(owner, p.ID); aerr == nil && ok {
|
||||
out = append(out, projectView{Project: p, Shared: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -524,6 +556,11 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
|
||||
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
|
||||
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
}
|
||||
writeJSONOr(w, c, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
// EXT-15 红测(提案 2026-10-09-ext-g1-b1-b3 §3):成员(经适配器放行)能在
|
||||
// GET /api/projects 里看到共享项目(shared 标注、去重、不含未授权),并能在
|
||||
// 共享项目下建会话;撤销后两者立即拒绝;未注册适配器=现状。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestSharedProjectListAndConversation(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pj, err := st.CreateProject("alice", "共享项目X")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid := pj.ID
|
||||
|
||||
type pv struct {
|
||||
ID string `json:"id"`
|
||||
Shared bool `json:"shared"`
|
||||
}
|
||||
call := func(key, method, path string) (*httptest.ResponseRecorder, []pv) {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var list []pv
|
||||
if rec.Body.Len() > 0 {
|
||||
readJSON(t, rec.Body.Bytes(), &list)
|
||||
}
|
||||
return rec, list
|
||||
}
|
||||
|
||||
// 未注册适配器:bob 看不到 alice 的项目(现状)。
|
||||
rec, list := call(bob, http.MethodGet, "/api/projects")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d", rec.Code)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("before adapter: bob sees %d projects, want 0", len(list))
|
||||
}
|
||||
|
||||
// 放行 bob×pid。
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
rec, list = call(bob, http.MethodGet, "/api/projects")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list after grant: %d", rec.Code)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != pid || !list[0].Shared {
|
||||
t.Fatalf("after grant: bob sees %+v, want 1 item id=%s shared=true", list, pid)
|
||||
}
|
||||
// alice 本人:仍只看到自己的(无 shared 标注)。
|
||||
_, alist := call(alice, http.MethodGet, "/api/projects")
|
||||
if len(alist) != 1 || alist[0].ID != pid || alist[0].Shared {
|
||||
t.Fatalf("alice own list = %+v, want own without shared", alist)
|
||||
}
|
||||
|
||||
// bob 在共享项目下建会话(EXT-15 第二入口)。
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "成员会话"})))
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("member create conversation: %d %s(须放行)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 撤销:列表回到 0,建会话 404(不泄露存在性)。
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return false, nil })
|
||||
_, list = call(bob, http.MethodGet, "/api/projects")
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("after revoke: bob sees %d, want 0", len(list))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "x"})))
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("after revoke: create conversation = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,31 @@ func (s *Store) ListProjects(owner string) ([]core.Project, error) {
|
||||
return scanProjects(rows)
|
||||
}
|
||||
|
||||
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
|
||||
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
|
||||
func (s *Store) ListAllProjects() ([]core.Project, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []core.Project{}
|
||||
for rows.Next() {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
if err := rows.Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
if settings != "" && settings != "{}" {
|
||||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(owner, id string) (core.Project, error) {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
@@ -137,6 +162,23 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
|
||||
return c, err
|
||||
}
|
||||
|
||||
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
|
||||
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
|
||||
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||||
var one int
|
||||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.Conversation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user