Changeset 0.17.0 (#75)
This commit is contained in:
@@ -314,6 +314,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
|
||||
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
||||
|
||||
// Paste-to-file threshold (admin-configurable via storage.paste_to_file_chars, default 2000)
|
||||
pasteChars := 2000
|
||||
if storageSettings, err := h.stores.GlobalConfig.Get(c.Request.Context(), "storage"); err == nil {
|
||||
if v, ok := storageSettings["paste_to_file_chars"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
pasteChars = int(n)
|
||||
case int:
|
||||
pasteChars = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only tell the user whether admin prompt exists — don't expose content
|
||||
hasAdminPrompt := false
|
||||
if content, ok := systemPrompt["content"].(string); ok && content != "" {
|
||||
@@ -321,10 +334,11 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
"has_admin_prompt": hasAdminPrompt,
|
||||
"storage_configured": storageConfigured,
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
"has_admin_prompt": hasAdminPrompt,
|
||||
"storage_configured": storageConfigured,
|
||||
"paste_to_file_chars": pasteChars,
|
||||
"policies": gin.H{
|
||||
"allow_registration": policies["allow_registration"],
|
||||
"allow_user_byok": policies["allow_user_byok"],
|
||||
|
||||
@@ -94,12 +94,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
|
||||
// ── Preset unwrap: preset overrides defaults, explicit request fields win ──
|
||||
var presetSystemPrompt string
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(req.PresetID, userID)
|
||||
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
|
||||
return
|
||||
}
|
||||
personaID = preset.ID
|
||||
// Preset provides defaults; explicit request fields take priority
|
||||
if req.Model == "" {
|
||||
req.Model = preset.BaseModelID
|
||||
@@ -138,7 +140,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt)
|
||||
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt, personaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -218,9 +220,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
if stream {
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID)
|
||||
} else {
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,9 +358,9 @@ func (h *CompletionHandler) streamCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model, configID, providerScope string,
|
||||
channelID, userID, model, configID, providerScope, personaID string,
|
||||
) {
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, h.hub)
|
||||
|
||||
// Persist assistant response
|
||||
if result.Content != "" {
|
||||
@@ -380,7 +382,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model, configID, providerScope string,
|
||||
channelID, userID, model, configID, providerScope, personaID string,
|
||||
) {
|
||||
var totalInput, totalOutput int
|
||||
var totalCacheCreation, totalCacheRead int
|
||||
@@ -446,6 +448,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
call := tools.ToolCall{
|
||||
@@ -747,7 +750,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
//
|
||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||
// messages before it are replaced by the summary content as a system message.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// ── Admin system prompt (always injected first, no opt out) ──
|
||||
@@ -781,7 +784,7 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
}
|
||||
|
||||
// ── KB hint (nudge LLM to use kb_search when KBs are active) ──
|
||||
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID); kbHint != "" {
|
||||
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: kbHint,
|
||||
|
||||
@@ -120,6 +120,8 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
presets := NewPersonaHandler(stores)
|
||||
protected.GET("/presets", presets.ListUserPersonas)
|
||||
protected.POST("/presets", presets.CreateUserPersona)
|
||||
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := NewNoteHandler()
|
||||
@@ -149,6 +151,8 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
|
||||
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
|
||||
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
|
||||
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
|
||||
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
|
||||
|
||||
// Attachments (nil storage = upload returns 503, but metadata works)
|
||||
attachH := NewAttachmentHandler(stores, nil, nil)
|
||||
@@ -185,6 +189,8 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.POST("/teams/:id/members", teams.AddMember)
|
||||
admin.GET("/presets", presets.ListAdminPersonas)
|
||||
admin.POST("/presets", presets.CreateAdminPersona)
|
||||
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Admin groups (v0.16.0)
|
||||
groupAdm := NewGroupHandler(stores)
|
||||
@@ -2785,45 +2791,6 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Diagnostic: verify grant row exists
|
||||
var grantCount int
|
||||
database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount)
|
||||
if grantCount == 0 {
|
||||
t.Fatal("DIAG: resource_grant row missing after SET")
|
||||
}
|
||||
|
||||
// Diagnostic: verify group membership
|
||||
var memberCount int
|
||||
database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount)
|
||||
if memberCount == 0 {
|
||||
t.Fatal("DIAG: group_members row missing")
|
||||
}
|
||||
|
||||
// Diagnostic: verify persona exists
|
||||
var personaExists bool
|
||||
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists)
|
||||
if !personaExists {
|
||||
t.Fatal("DIAG: persona not found or not active")
|
||||
}
|
||||
|
||||
// Diagnostic: run the subquery directly
|
||||
var subqCount int
|
||||
database.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM resource_grants rg
|
||||
WHERE rg.resource_type = 'persona'
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
WHERE gm.user_id = $1
|
||||
AND gm.group_id = ANY(rg.granted_groups)
|
||||
))
|
||||
)
|
||||
`, userID).Scan(&subqCount)
|
||||
t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s",
|
||||
grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID)
|
||||
|
||||
// ── User should NOW see team persona via group grant ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -2857,3 +2824,214 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Persona-KB Binding CRUD ──
|
||||
|
||||
func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// 1. Create a global persona via admin API
|
||||
w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||
"name": "KB Persona",
|
||||
"base_model_id": "test-model",
|
||||
"system_prompt": "You are a test persona.",
|
||||
})
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var personaResp map[string]interface{}
|
||||
decode(w, &personaResp)
|
||||
personaID := personaResp["id"].(string)
|
||||
|
||||
// 2. Create a global KB
|
||||
w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
|
||||
"name": "Test KB",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var kbResp map[string]interface{}
|
||||
decode(w, &kbResp)
|
||||
kbID := kbResp["id"].(string)
|
||||
|
||||
// 3. Bind KB to persona
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
adminToken, map[string]interface{}{
|
||||
"kb_ids": []string{kbID},
|
||||
"auto_search": map[string]bool{kbID: true},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 4. Read back bindings
|
||||
w = h.request("GET",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
decode(w, &listResp)
|
||||
data := listResp["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 binding, got %d", len(data))
|
||||
}
|
||||
binding := data[0].(map[string]interface{})
|
||||
if binding["kb_id"] != kbID {
|
||||
t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID)
|
||||
}
|
||||
if binding["auto_search"] != true {
|
||||
t.Fatal("auto_search should be true")
|
||||
}
|
||||
|
||||
// 5. Unbind — send empty list
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
adminToken, map[string]interface{}{
|
||||
"kb_ids": []string{},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 6. Verify empty
|
||||
w = h.request("GET",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var emptyResp map[string]interface{}
|
||||
decode(w, &emptyResp)
|
||||
emptyData := emptyResp["data"].([]interface{})
|
||||
if len(emptyData) != 0 {
|
||||
t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData))
|
||||
}
|
||||
}
|
||||
|
||||
// ── KB Create Auth (was TODO — now enforced) ──
|
||||
|
||||
func TestIntegration_KB_CreateAuth(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
_ = adminToken
|
||||
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("kbuser", "kb@test.com", "password123")
|
||||
|
||||
// Regular user should NOT be able to create global KBs
|
||||
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
|
||||
"name": "Sneaky Global KB",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Regular user should NOT be able to create team KBs without team admin role
|
||||
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
|
||||
"name": "Sneaky Team KB",
|
||||
"scope": "team",
|
||||
"team_id": "00000000-0000-0000-0000-000000000001",
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Regular user CAN create personal KBs
|
||||
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
|
||||
"name": "My Personal KB",
|
||||
"scope": "personal",
|
||||
})
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||
t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── KB Discoverable Flag ──
|
||||
|
||||
func TestIntegration_KB_Discoverable(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("discuser", "disc@test.com", "password123")
|
||||
|
||||
// 1. Admin creates a global KB (discoverable=true by default)
|
||||
w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
|
||||
"name": "Visible KB",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var kbResp map[string]interface{}
|
||||
decode(w, &kbResp)
|
||||
kbID := kbResp["id"].(string)
|
||||
|
||||
// 2. User can see it in discoverable list
|
||||
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var discovResp map[string]interface{}
|
||||
decode(w, &discovResp)
|
||||
kbs, _ := discovResp["data"].([]interface{})
|
||||
if kbs == nil {
|
||||
t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String())
|
||||
}
|
||||
found := false
|
||||
for _, kb := range kbs {
|
||||
if kb.(map[string]interface{})["id"] == kbID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String())
|
||||
}
|
||||
|
||||
// 3. Admin hides the KB
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID),
|
||||
adminToken, map[string]interface{}{"discoverable": false})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 4. User can no longer see it
|
||||
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var discovResp2 map[string]interface{}
|
||||
decode(w, &discovResp2)
|
||||
kbs2, _ := discovResp2["data"].([]interface{})
|
||||
for _, kb := range kbs2 {
|
||||
if kb.(map[string]interface{})["id"] == kbID {
|
||||
t.Fatal("hidden KB should not appear in discoverable list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform Policy: kb_direct_access seeded ──
|
||||
|
||||
func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Verify the migration seeded the policy by reading it through admin settings
|
||||
w := h.request("GET", "/api/v1/settings/public", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get public settings: want 200, got %d", w.Code)
|
||||
}
|
||||
// Policy existence is verified by the migration running without error
|
||||
// (if kb_direct_access INSERT fails, the migration fails and no tests run)
|
||||
_ = w
|
||||
}
|
||||
|
||||
@@ -147,9 +147,25 @@ func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"})
|
||||
return
|
||||
}
|
||||
// TODO: verify user is team admin
|
||||
// Verify user is team admin (or system admin)
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
var teamRole string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
|
||||
req.TeamID, userID).Scan(&teamRole)
|
||||
if err != nil || teamRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"})
|
||||
return
|
||||
}
|
||||
}
|
||||
case "global":
|
||||
// TODO: verify user is admin
|
||||
// Verify user is system admin
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required to create global knowledge bases"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"})
|
||||
return
|
||||
@@ -630,9 +646,7 @@ func (h *KnowledgeBaseHandler) getUserTeamIDs(c *gin.Context, userID string) []s
|
||||
|
||||
// updateDocStorageKey sets the storage_key on a document after upload.
|
||||
func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) {
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
`UPDATE kb_documents SET storage_key = $1 WHERE id = $2`,
|
||||
storageKey, docID)
|
||||
h.stores.KnowledgeBases.UpdateDocumentStorageKey(c.Request.Context(), docID, storageKey)
|
||||
}
|
||||
|
||||
// toKBResponse converts a model to the API response type.
|
||||
@@ -747,6 +761,46 @@ func (h *KnowledgeBaseHandler) SetChannelKBs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
||||
}
|
||||
|
||||
// ── Discoverable Management (v0.17.0) ────────────
|
||||
|
||||
// SetDiscoverable toggles KB visibility to users.
|
||||
func (h *KnowledgeBaseHandler) SetDiscoverable(c *gin.Context) {
|
||||
kbID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Discoverable bool `json:"discoverable"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.KnowledgeBases.SetDiscoverable(c.Request.Context(), kbID, req.Discoverable); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update discoverability"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// ListDiscoverableKBs returns KBs the user can see that are marked discoverable.
|
||||
// Used by the channel KB toggle popup when kb_direct_access is enabled.
|
||||
func (h *KnowledgeBaseHandler) ListDiscoverableKBs(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
teamIDs := h.getUserTeamIDs(c, userID)
|
||||
|
||||
kbs, err := h.stores.KnowledgeBases.ListDiscoverable(c.Request.Context(), userID, teamIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"})
|
||||
return
|
||||
}
|
||||
if kbs == nil {
|
||||
kbs = []models.KnowledgeBase{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
||||
}
|
||||
|
||||
// userCanAccess checks if a user can access a KB based on scope.
|
||||
func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
|
||||
switch kb.Scope {
|
||||
@@ -773,60 +827,57 @@ func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID st
|
||||
// BuildKBHint returns a system prompt fragment listing active KBs for a
|
||||
// channel, or empty string if none are active. Called by the completion
|
||||
// handler to nudge the LLM to use kb_search.
|
||||
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID string) string {
|
||||
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string {
|
||||
teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||
|
||||
kbIDs, err := stores.KnowledgeBases.GetActiveKBIDs(ctx, channelID, userID, teamIDs)
|
||||
if err != nil || len(kbIDs) == 0 {
|
||||
// Also check personal KBs — always available
|
||||
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
||||
if err != nil || len(personalKBs) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Only include personal KBs that have chunks
|
||||
hasContent := false
|
||||
for _, kb := range personalKBs {
|
||||
if kb.ChunkCount > 0 {
|
||||
hasContent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasContent {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Collect KB details for the hint
|
||||
type kbInfo struct {
|
||||
Name string
|
||||
DocCount int
|
||||
}
|
||||
var kbs []kbInfo
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Persona-bound KBs (v0.17.0)
|
||||
if personaID != "" {
|
||||
personaKBs, err := stores.Personas.GetKBs(ctx, personaID)
|
||||
if err == nil {
|
||||
for _, pkb := range personaKBs {
|
||||
if pkb.ChunkCount > 0 {
|
||||
kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount})
|
||||
seen[pkb.KBID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel-linked KBs
|
||||
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
||||
if err == nil {
|
||||
for _, ckb := range channelKBs {
|
||||
if ckb.Enabled && ckb.DocumentCount > 0 {
|
||||
if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] {
|
||||
kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
|
||||
seen[ckb.KBID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Personal KBs (not already linked to channel)
|
||||
// Personal KBs (not already counted)
|
||||
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
||||
if err == nil {
|
||||
linked := make(map[string]bool)
|
||||
for _, ckb := range channelKBs {
|
||||
linked[ckb.KBID] = true
|
||||
}
|
||||
for _, kb := range personalKBs {
|
||||
if !linked[kb.ID] && kb.ChunkCount > 0 {
|
||||
if !seen[kb.ID] && kb.ChunkCount > 0 {
|
||||
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
|
||||
seen[kb.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group-granted KBs are resolved via GetActiveKBIDs — only add names
|
||||
// for KBs found through channel link or persona. The kb_search tool
|
||||
// handles the full resolution at search time.
|
||||
_ = teamIDs // used by GetActiveKBIDsWithPersona at search time
|
||||
|
||||
if len(kbs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -366,17 +366,19 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
|
||||
|
||||
var presetSystemPrompt string
|
||||
var personaID string
|
||||
model := req.Model
|
||||
apiConfigID := req.APIConfigID
|
||||
temperature := req.Temperature
|
||||
maxTokens := req.MaxTokens
|
||||
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(req.PresetID, userID)
|
||||
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
||||
return
|
||||
}
|
||||
personaID = preset.ID
|
||||
if model == "" {
|
||||
model = preset.BaseModelID
|
||||
}
|
||||
@@ -461,7 +463,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
||||
|
||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
|
||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, h.hub)
|
||||
|
||||
// Persist as sibling (regen) with tool activity
|
||||
if result.Content != "" {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
@@ -256,31 +256,52 @@ func (r *personaRequest) toPersona() *models.Persona {
|
||||
// ResolvePreset loads a persona by ID and returns it if the user has access.
|
||||
// Returns nil if not found, inactive, or not accessible.
|
||||
// Used by completion.go and messages.go for preset unwrapping.
|
||||
func ResolvePreset(presetID, userID string) *models.Persona {
|
||||
var p models.Persona
|
||||
var providerConfigID *string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, name, base_model_id, provider_config_id, system_prompt,
|
||||
temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active
|
||||
FROM personas
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND created_by = $2)
|
||||
OR (scope = 'personal' AND is_shared = true)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $2
|
||||
))
|
||||
)
|
||||
`, presetID, userID).Scan(
|
||||
&p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt,
|
||||
&p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP,
|
||||
&p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive,
|
||||
)
|
||||
if err != nil {
|
||||
func ResolvePreset(stores store.Stores, presetID, userID string) *models.Persona {
|
||||
ctx := context.Background()
|
||||
|
||||
p, err := stores.Personas.GetByID(ctx, presetID)
|
||||
if err != nil || !p.IsActive {
|
||||
return nil
|
||||
}
|
||||
p.ProviderConfigID = providerConfigID
|
||||
return &p
|
||||
|
||||
ok, err := stores.Personas.UserCanAccess(ctx, userID, presetID)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// ── Persona-KB Binding Endpoints (v0.17.0) ──────
|
||||
|
||||
// GetPersonaKBs returns the knowledge bases bound to a persona.
|
||||
func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
||||
}
|
||||
|
||||
// SetPersonaKBs replaces the knowledge bases bound to a persona.
|
||||
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
KBIDs []string `json:"kb_ids"`
|
||||
AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func streamWithToolLoop(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req *providers.CompletionRequest,
|
||||
model, userID, channelID string,
|
||||
model, userID, channelID, personaID string,
|
||||
hub *events.Hub,
|
||||
) streamResult {
|
||||
// Set SSE headers
|
||||
@@ -168,6 +168,7 @@ func streamWithToolLoop(
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
|
||||
110
server/handlers/title.go
Normal file
110
server/handlers/title.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// TitleHandler generates chat titles using the utility role.
|
||||
type TitleHandler struct {
|
||||
stores store.Stores
|
||||
resolver *roles.Resolver
|
||||
}
|
||||
|
||||
// NewTitleHandler creates a title generation handler.
|
||||
func NewTitleHandler(s store.Stores, r *roles.Resolver) *TitleHandler {
|
||||
return &TitleHandler{stores: s, resolver: r}
|
||||
}
|
||||
|
||||
// GenerateTitle generates a title for a channel using the utility role.
|
||||
// POST /channels/:id/generate-title
|
||||
func (h *TitleHandler) GenerateTitle(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
teamID := c.GetString("team_id")
|
||||
|
||||
// Verify channel access
|
||||
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
|
||||
if err != nil || !owns {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load first few messages
|
||||
msgs, err := h.stores.Messages.ListForChannel(c.Request.Context(), channelID, store.ListOptions{
|
||||
Limit: 4, Order: "asc",
|
||||
})
|
||||
if err != nil || len(msgs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"title": ch.Title}) // keep existing
|
||||
return
|
||||
}
|
||||
|
||||
// Build a snippet from the first messages
|
||||
var snippet strings.Builder
|
||||
for _, m := range msgs {
|
||||
content := m.Content
|
||||
if len(content) > 200 {
|
||||
content = content[:200] + "…"
|
||||
}
|
||||
fmt.Fprintf(&snippet, "%s: %s\n", m.Role, content)
|
||||
}
|
||||
|
||||
// Call utility role
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tID *string
|
||||
if teamID != "" {
|
||||
tID = &teamID
|
||||
}
|
||||
|
||||
result, err := h.resolver.Complete(ctx, roles.RoleUtility, userID, tID, []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "You are a title generator. Given a conversation snippet, produce a concise title of 6 words or fewer. Respond with ONLY the title, no quotes, no punctuation at the end, no explanation.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Title this conversation:\n\n" + snippet.String(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// Fallback: truncate first user message
|
||||
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(result.Content)
|
||||
// Strip surrounding quotes if the model added them
|
||||
title = strings.Trim(title, "\"'`\u201c\u201d\u2018\u2019")
|
||||
if title == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
|
||||
return
|
||||
}
|
||||
// Cap at 100 chars
|
||||
if len(title) > 100 {
|
||||
title = title[:100]
|
||||
}
|
||||
|
||||
// Update channel
|
||||
_ = h.stores.Channels.Update(c.Request.Context(), channelID, map[string]interface{}{
|
||||
"title": title,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"title": title})
|
||||
}
|
||||
Reference in New Issue
Block a user