feat(ai): add local expert library

This commit is contained in:
2026-07-22 17:09:54 +08:00
parent b28a5d635c
commit 42dd0dce47
22 changed files with 3432 additions and 60 deletions

View File

@@ -0,0 +1,62 @@
package ai
import (
"errors"
"strings"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
// ExpertFilter 是本地专家库的查询条件。
type ExpertFilter struct {
Category string
Query string
}
func (s *SessionService) ListExperts(filter ExpertFilter) ([]models.SenlinAgentAIExpert, error) {
query := models.DBService.Model(&models.SenlinAgentAIExpert{}).Where("enabled = ?", true)
if category := strings.TrimSpace(filter.Category); category != "" {
query = query.Where("category = ?", category)
}
if keyword := strings.ToLower(strings.TrimSpace(filter.Query)); keyword != "" {
like := "%" + keyword + "%"
query = query.Where(
"LOWER(name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(category_name) LIKE ?",
like, like, like,
)
}
var experts []models.SenlinAgentAIExpert
if err := query.Order("category asc, id asc").Find(&experts).Error; err != nil {
return nil, err
}
if experts == nil {
experts = []models.SenlinAgentAIExpert{}
}
return experts, nil
}
func (s *SessionService) GetExpert(identity string) (*models.SenlinAgentAIExpert, error) {
var expert models.SenlinAgentAIExpert
if err := models.DBService.Where("identity = ? AND enabled = ?", strings.TrimSpace(identity), true).First(&expert).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrExpertNotFound
}
return nil, err
}
return &expert, nil
}
func findExpertByIdentity(tx *gorm.DB, identity string) (*models.SenlinAgentAIExpert, error) {
if strings.TrimSpace(identity) == "" {
return nil, nil
}
var expert models.SenlinAgentAIExpert
if err := tx.Where("identity = ? AND enabled = ?", identity, true).First(&expert).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrExpertNotFound
}
return nil, err
}
return &expert, nil
}

View File

@@ -15,18 +15,38 @@ import (
// SessionDTO 是普通项目 AI 会话的公开契约,不携带数据库主键或自动创建对象的 ID。
type SessionDTO struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Title string `json:"title"`
Context string `json:"context"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
ProjectID string `json:"projectId"`
Title string `json:"title"`
Context string `json:"context"`
Status string `json:"status"`
Expert *ExpertSummaryDTO `json:"expert"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type createSessionRequest struct {
Title string `json:"title"`
Context string `json:"context"`
Title string `json:"title"`
Context string `json:"context"`
ExpertID string `json:"expertId"`
}
type ExpertSummaryDTO struct {
ID string `json:"id"`
Slug string `json:"slug"`
Category string `json:"category"`
CategoryName string `json:"categoryName"`
Name string `json:"name"`
Description string `json:"description"`
Emoji string `json:"emoji"`
Color string `json:"color"`
}
type ExpertDetailDTO struct {
ExpertSummaryDTO
SystemPrompt string `json:"systemPrompt"`
Source string `json:"source"`
SourceLicense string `json:"sourceLicense"`
}
// Handler 注册受认证、受项目所有权保护的 AI 会话接口。
@@ -39,6 +59,8 @@ func NewHandler(service *SessionService) *Handler {
}
func (h *Handler) Register(router gin.IRouter) {
router.GET("/ai-experts", h.listExperts)
router.GET("/ai-experts/:id", h.getExpert)
router.GET("/projects/:id/ai-sessions", h.list)
router.POST("/projects/:id/ai-sessions", h.create)
}
@@ -70,7 +92,7 @@ func (h *Handler) create(c *gin.Context) {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
return
}
session, err := h.service.Create(userID, projectIdentity, input.Title, input.Context)
session, err := h.service.CreateWithExpert(userID, projectIdentity, input.Title, input.Context, input.ExpertID)
if err != nil {
writeAIError(c, err)
return
@@ -78,6 +100,35 @@ func (h *Handler) create(c *gin.Context) {
c.JSON(http.StatusCreated, sessionDTO(*session))
}
func (h *Handler) listExperts(c *gin.Context) {
experts, err := h.service.ListExperts(ExpertFilter{Category: c.Query("category"), Query: c.Query("q")})
if err != nil {
writeAIError(c, err)
return
}
items := make([]ExpertSummaryDTO, 0, len(experts))
for _, expert := range experts {
items = append(items, expertSummaryDTO(expert))
}
c.JSON(http.StatusOK, items)
}
func (h *Handler) getExpert(c *gin.Context) {
identity, ok := httpx.IdentityParam(c, "id")
if !ok {
return
}
expert, err := h.service.GetExpert(identity)
if err != nil {
writeAIError(c, err)
return
}
c.JSON(http.StatusOK, ExpertDetailDTO{
ExpertSummaryDTO: expertSummaryDTO(*expert),
SystemPrompt: expert.SystemPrompt, Source: expert.Source, SourceLicense: expert.SourceLicense,
})
}
func aiRequestContext(c *gin.Context) (uint, string, bool) {
userID, ok := auth.CurrentUserID(c)
if !ok {
@@ -92,7 +143,7 @@ func aiRequestContext(c *gin.Context) (uint, string, bool) {
}
func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
return SessionDTO{
dto := SessionDTO{
ID: session.Identity,
ProjectID: session.ProjectIdentity,
Title: session.Title,
@@ -101,10 +152,29 @@ func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
CreatedAt: session.CreatedAt.UTC(),
UpdatedAt: session.UpdatedAt.UTC(),
}
if session.Expert != nil {
dto.Expert = expertSummaryPointer(*session.Expert)
}
return dto
}
func expertSummaryPointer(expert models.SenlinAgentAIExpert) *ExpertSummaryDTO {
dto := expertSummaryDTO(expert)
return &dto
}
func expertSummaryDTO(expert models.SenlinAgentAIExpert) ExpertSummaryDTO {
return ExpertSummaryDTO{
ID: expert.Identity, Slug: expert.Slug, Category: expert.Category,
CategoryName: expert.CategoryName, Name: expert.Name, Description: expert.Description,
Emoji: expert.Emoji, Color: expert.Color,
}
}
func writeAIError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrExpertNotFound):
httpx.Error(c, http.StatusNotFound, "expert_not_found", "专家不存在或已停用")
case errors.Is(err, gorm.ErrRecordNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问")
case errors.Is(err, ErrInvalidSession):

View File

@@ -50,6 +50,48 @@ func TestAISessionHandlersRequireOwnedProject(t *testing.T) {
require.Empty(t, gateway.steps)
}
func TestAIExpertLibraryListsDetailsAndCreatesAssociatedSession(t *testing.T) {
database := newAIHandlerTestDB(t)
owner := createAIHandlerUser(t, database, "expert-owner@example.com")
project := createAIHandlerProject(t, database, owner.ID, "EXPERTS")
var expert models.SenlinAgentAIExpert
require.NoError(t, database.Order("id asc").First(&expert).Error)
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/ai-experts", nil))
require.Equal(t, http.StatusOK, listRecorder.Code)
var listPayload []map[string]any
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
require.Len(t, listPayload, 267)
require.Equal(t, expert.Identity, listPayload[0]["id"])
require.NotContains(t, listPayload[0], "systemPrompt")
detailRecorder := httptest.NewRecorder()
router.ServeHTTP(detailRecorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/ai-experts/"+expert.Identity, nil))
require.Equal(t, http.StatusOK, detailRecorder.Code)
var detailPayload map[string]any
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
require.Equal(t, expert.Name, detailPayload["name"])
require.NotEmpty(t, detailPayload["systemPrompt"])
require.Equal(t, "MIT", detailPayload["sourceLicense"])
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
"title": "专家会话", "context": "请给出下一步计划", "expertId": expert.Identity,
}))
require.Equal(t, http.StatusCreated, createRecorder.Code)
var createPayload map[string]any
require.NoError(t, json.Unmarshal(createRecorder.Body.Bytes(), &createPayload))
responseExpert := createPayload["expert"].(map[string]any)
require.Equal(t, expert.Identity, responseExpert["id"])
require.Equal(t, expert.Name, responseExpert["name"])
var session models.SenlinAgentAISession
require.NoError(t, database.First(&session).Error)
require.NotNil(t, session.ExpertID)
require.Equal(t, expert.ID, *session.ExpertID)
}
func TestCreateAISessionChecksRateLimitBeforeSelectingProvider(t *testing.T) {
database := newAIHandlerTestDB(t)
owner := createAIHandlerUser(t, database, "owner@example.com")
@@ -210,7 +252,8 @@ func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObject
require.Equal(t, http.StatusCreated, recorder.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload))
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "expert", "createdAt", "updatedAt"}, aiMapKeys(payload))
require.Nil(t, payload["expert"])
identity, err := uuid.Parse(payload["id"].(string))
require.NoError(t, err)
require.Equal(t, uuid.Version(7), identity.Version())
@@ -256,7 +299,8 @@ func TestListAISessionsReturnsOnlyOwnedProjectIdentityDTOs(t *testing.T) {
var payload []map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
require.Len(t, payload, 1)
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "expert", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
require.Nil(t, payload[0]["expert"])
require.Equal(t, project.Identity, payload[0]["projectId"])
require.Equal(t, "目标会话", payload[0]["title"])
require.Equal(t, "ready", payload[0]["status"])

View File

@@ -19,6 +19,7 @@ const (
var (
ErrInvalidSession = errors.New("invalid ai session")
ErrExpertNotFound = errors.New("ai expert not found")
)
type sessionGateway interface {
@@ -43,7 +44,7 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
return nil, err
}
var sessions []models.SenlinAgentAISession
if err := models.DBService.Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
if err := models.DBService.Preload("Expert").Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
return nil, err
}
if sessions == nil {
@@ -54,6 +55,11 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
// Create 先校验项目,再限流,最后才选择 provider/key会话创建不会生成任何正式业务对象。
func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) {
return s.CreateWithExpert(userID, projectIdentity, title, context, "")
}
// CreateWithExpert 创建带本地专家角色的项目会话。
func (s *SessionService) CreateWithExpert(userID uint, projectIdentity, title, context, expertIdentity string) (*models.SenlinAgentAISession, error) {
title = strings.TrimSpace(title)
context = strings.TrimSpace(context)
if title == "" {
@@ -66,6 +72,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
if s.gateway == nil {
return nil, errors.New("ai gateway is required")
}
expert, err := findExpertByIdentity(models.DBService, expertIdentity)
if err != nil {
return nil, err
}
if err := s.gateway.ReserveRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
if errors.Is(err, ErrAIRateLimited) {
@@ -100,6 +110,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
Context: context,
Status: defaultSessionStatus,
}
if expert != nil {
session.ExpertID = &expert.ID
session.ExpertIdentity = &expert.Identity
}
failureCode := "session_create_failed"
err = models.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&session).Error; err != nil {
@@ -119,6 +133,7 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
}
return nil, err
}
session.Expert = expert
return &session, nil
}

View File

@@ -0,0 +1,26 @@
package models
import "time"
// SenlinAgentAIExpert 是本地专家库中的可选 AI 角色。
type SenlinAgentAIExpert struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
Slug string `gorm:"size:160;uniqueIndex;not null"`
Category string `gorm:"size:80;index;not null"`
CategoryName string `gorm:"size:120;not null"`
Name string `gorm:"size:160;index;not null"`
Description string `gorm:"type:text;not null"`
Emoji string `gorm:"size:32"`
Color string `gorm:"size:32"`
SystemPrompt string `gorm:"type:text;not null"`
Source string `gorm:"size:255;not null"`
SourceLicense string `gorm:"size:32;not null"`
Enabled bool `gorm:"not null;default:true;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SenlinAgentAIExpert) TableName() string {
return "senlin_agent_ai_experts"
}

View File

@@ -3,15 +3,18 @@ package models
import "time"
type SenlinAgentAISession struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
Title string `gorm:"not null"`
Context string `gorm:"type:text"`
Status string `gorm:"not null;default:ready"`
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
ExpertID *uint `gorm:"index"`
ExpertIdentity *string `gorm:"type:char(36);index"`
Expert *SenlinAgentAIExpert `gorm:"foreignKey:ExpertID"`
Title string `gorm:"not null"`
Context string `gorm:"type:text"`
Status string `gorm:"not null;default:ready"`
CreatedAt time.Time
UpdatedAt time.Time
}

File diff suppressed because one or more lines are too long

View File

@@ -90,7 +90,14 @@ func (m *SenlinAgentAISession) BeforeCreate(tx *gorm.DB) error {
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
return err
}
return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity)
if err := resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SenlinAgentAIExpert{}, m.ExpertID, &m.ExpertIdentity)
}
func (m *SenlinAgentAIExpert) BeforeCreate(_ *gorm.DB) error {
return ensureIdentity(&m.Identity)
}
func (m *SenlinAgentTag) BeforeCreate(tx *gorm.DB) error {

View File

@@ -1,6 +1,7 @@
package models
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
@@ -31,6 +32,7 @@ func runVersionedMigrations(database *gorm.DB) error {
migrations := []versionedMigration{
{version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers},
{version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags},
{version: 3, name: "seed_ai_experts", run: seedAIExperts},
}
for _, migration := range migrations {
// Connection callbacks can provide an initialized Gorm session. Start each
@@ -63,6 +65,48 @@ func runVersionedMigrations(database *gorm.DB) error {
return nil
}
//go:embed experts_catalog.json
var expertCatalogJSON []byte
type embeddedExpertCatalog struct {
Source string `json:"source"`
License string `json:"license"`
Experts []embeddedExpertCatalogItem `json:"experts"`
}
type embeddedExpertCatalogItem struct {
Slug string `json:"slug"`
Category string `json:"category"`
CategoryName string `json:"categoryName"`
Name string `json:"name"`
Description string `json:"description"`
Emoji string `json:"emoji"`
Color string `json:"color"`
SystemPrompt string `json:"systemPrompt"`
}
func seedAIExperts(tx *gorm.DB) (map[string]int, error) {
var catalog embeddedExpertCatalog
if err := json.Unmarshal(expertCatalogJSON, &catalog); err != nil {
return nil, fmt.Errorf("decode embedded AI expert catalog: %w", err)
}
records := make([]SenlinAgentAIExpert, 0, len(catalog.Experts))
for _, expert := range catalog.Experts {
records = append(records, SenlinAgentAIExpert{
Slug: expert.Slug, Category: expert.Category, CategoryName: expert.CategoryName,
Name: expert.Name, Description: expert.Description, Emoji: expert.Emoji,
Color: expert.Color, SystemPrompt: expert.SystemPrompt, Source: catalog.Source,
SourceLicense: catalog.License, Enabled: true,
})
}
if len(records) > 0 {
if err := tx.CreateInBatches(&records, 25).Error; err != nil {
return nil, err
}
}
return map[string]int{"inserted": len(records)}, nil
}
func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) {
var projects []SenlinAgentProject
if err := tx.Model(&SenlinAgentProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil {

View File

@@ -57,14 +57,18 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te
var migrationCount int64
require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount)
require.Equal(t, int64(3), migrationCount)
var auditDetails []string
require.NoError(t, database.Table("senlin_agent_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error)
require.Contains(t, auditDetails[0], `"updated":3`)
require.Contains(t, auditDetails[1], `"deduplicated":1`)
require.Contains(t, auditDetails[2], `"inserted":267`)
var expertCount int64
require.NoError(t, database.Model(&SenlinAgentAIExpert{}).Count(&expertCount).Error)
require.Equal(t, int64(267), expertCount)
require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again")
require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount)
require.Equal(t, int64(3), migrationCount)
require.Error(t, database.Exec(`INSERT INTO senlin_agent_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error)
require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error)

View File

@@ -32,6 +32,7 @@ func AutoMigrate(database *gorm.DB) error {
&SenlinAgentTask{},
&SenlinAgentNote{},
&SenlinAgentSource{},
&SenlinAgentAIExpert{},
&SenlinAgentAISession{},
&SenlinAgentTag{},
&SenlinAgentProjectChannel{},

View File

@@ -0,0 +1,43 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const sourceRoot = process.argv[2]
const assetPath = process.argv[3]
if (!sourceRoot || !assetPath) {
throw new Error('usage: node backend/scripts/sync-ai-experts.mjs <agency-agents-zh-dir> <experts-asset.js>')
}
const asset = await readFile(assetPath, 'utf8')
const catalogMatch = asset.match(/^const e=JSON\.parse\(('(?:\\.|[^'])*')\)/s)
if (!catalogMatch) throw new Error('Chinese expert catalog was not found in the supplied asset')
const encodedCatalog = Function(`"use strict"; return ${catalogMatch[1]}`)()
const summaries = JSON.parse(encodedCatalog)
const experts = []
for (const summary of summaries) {
const promptPath = path.join(sourceRoot, summary.category, `${summary.id}.md`)
const systemPrompt = await readFile(promptPath, 'utf8')
experts.push({
slug: summary.id,
category: summary.category,
categoryName: summary.categoryName,
name: summary.name,
description: summary.description,
emoji: summary.emoji,
color: summary.color,
systemPrompt,
})
}
const outputPath = path.resolve('backend/internal/models/experts_catalog.json')
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify({
source: 'https://github.com/jnMetaCode/agency-agents-zh',
reference: 'https://ao.aiolaola.com/experts',
license: 'MIT',
experts,
}, null, 2)}\n`, 'utf8')
console.log(`wrote ${experts.length} experts to ${outputPath}`)