Files
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

378 lines
13 KiB
Go

package workbench
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type PromptCategory struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
TemplateCount int `json:"template_count"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Service) ListPromptCategories(ctx context.Context) ([]PromptCategory, error) {
rows, err := s.pool.Query(ctx, `SELECT c.id::text,c.name,c.description,count(t.id)::int,c.created_at FROM gateway.prompt_categories c LEFT JOIN gateway.prompt_templates t ON t.category_id=c.id GROUP BY c.id ORDER BY c.name`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PromptCategory{}
for rows.Next() {
var item PromptCategory
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.TemplateCount, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) CreatePromptCategory(ctx context.Context, name, description, actorID string) (PromptCategory, error) {
name, description = strings.TrimSpace(name), strings.TrimSpace(description)
if name == "" || len(name) > 64 || len(description) > 512 {
return PromptCategory{}, errors.New("分类名称或描述格式无效")
}
id, err := newUUID()
if err != nil {
return PromptCategory{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return PromptCategory{}, err
}
defer rollback(ctx, tx)
var item PromptCategory
err = tx.QueryRow(ctx, `INSERT INTO gateway.prompt_categories(id,name,description) VALUES($1,$2,$3) RETURNING id::text,name,description,created_at`, id, name, description).Scan(&item.ID, &item.Name, &item.Description, &item.CreatedAt)
if err != nil {
return PromptCategory{}, err
}
if err = emit(ctx, tx, "prompt_category.created", "prompt_category", id, actorID, nil); err != nil {
return PromptCategory{}, err
}
if err = tx.Commit(ctx); err != nil {
return PromptCategory{}, err
}
return item, nil
}
func (s *Service) DeletePromptCategory(ctx context.Context, id, actorID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer rollback(ctx, tx)
if _, err = tx.Exec(ctx, `UPDATE gateway.prompt_templates SET category_id=NULL,revision=revision+1,updated_at=clock_timestamp() WHERE category_id=$1`, id); err != nil {
return err
}
tag, err := tx.Exec(ctx, `DELETE FROM gateway.prompt_categories WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if err = emit(ctx, tx, "prompt_category.deleted", "prompt_category", id, actorID, nil); err != nil {
return err
}
return tx.Commit(ctx)
}
func validateVariables(values []Variable) ([]Variable, error) {
seen := map[string]struct{}{}
for i := range values {
values[i].Name = strings.TrimSpace(values[i].Name)
values[i].Label = strings.TrimSpace(values[i].Label)
if !regexpVariableName(values[i].Name) {
return nil, fmt.Errorf("变量名 %q 格式无效", values[i].Name)
}
if _, ok := seen[values[i].Name]; ok {
return nil, fmt.Errorf("变量 %q 重复", values[i].Name)
}
seen[values[i].Name] = struct{}{}
if len(values[i].Default) > 10000 || len(values[i].Label) > 128 {
return nil, errors.New("变量定义过长")
}
}
if len(values) > 100 {
return nil, errors.New("变量最多 100 个")
}
return values, nil
}
func regexpVariableName(value string) bool {
if value == "" {
return false
}
matched := variableRE.FindStringSubmatch("{{" + value + "}}")
return len(matched) == 2 && matched[1] == value
}
func validatePromptInput(input *PromptInput, create bool) error {
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
input.Content = strings.TrimSpace(input.Content)
input.ChangeNote = strings.TrimSpace(input.ChangeNote)
if input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 {
return errors.New("Prompt 名称或描述格式无效")
}
if create && (input.Content == "" || len(input.Content) > 100000) {
return errors.New("Prompt 正文不能为空且最多 100000 字符")
}
var err error
if input.Tags, err = normalizeStrings(input.Tags, 30); err != nil {
return err
}
if input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100); err != nil {
return err
}
if input.Variables, err = validateVariables(input.Variables); err != nil {
return err
}
return nil
}
const promptSelect = `SELECT t.id::text,t.name,t.description,t.category_id::text,coalesce(c.name,''),t.tags,t.department_ids::text[],t.enabled,t.current_version,t.revision,t.created_at,t.updated_at,
v.id::text,v.template_id::text,v.version,v.content,v.variables,v.change_note,v.created_at
FROM gateway.prompt_templates t LEFT JOIN gateway.prompt_categories c ON c.id=t.category_id LEFT JOIN gateway.prompt_versions v ON v.template_id=t.id AND v.version=t.current_version`
func scanPrompt(row pgx.Row) (PromptTemplate, error) {
var p PromptTemplate
var vID, vTemplate *string
var vVersion *int
var content, change *string
var variables []byte
var versionCreated *time.Time
err := row.Scan(&p.ID, &p.Name, &p.Description, &p.CategoryID, &p.CategoryName, &p.Tags, &p.DepartmentIDs, &p.Enabled, &p.CurrentVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &vID, &vTemplate, &vVersion, &content, &variables, &change, &versionCreated)
if err != nil {
return p, mapNotFound(err)
}
if vID != nil {
v := PromptVersion{ID: *vID, TemplateID: *vTemplate, Version: *vVersion, Content: *content, ChangeNote: *change, CreatedAt: *versionCreated}
_ = json.Unmarshal(variables, &v.Variables)
p.Current = &v
}
return p, nil
}
func (s *Service) ListPrompts(ctx context.Context) ([]PromptTemplate, error) {
rows, err := s.pool.Query(ctx, promptSelect+` ORDER BY t.updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PromptTemplate{}
for rows.Next() {
p, err := scanPrompt(rows)
if err != nil {
return nil, err
}
items = append(items, p)
}
return items, rows.Err()
}
func (s *Service) GetPrompt(ctx context.Context, id string) (PromptTemplate, error) {
return scanPrompt(s.pool.QueryRow(ctx, promptSelect+` WHERE t.id=$1`, id))
}
func (s *Service) CreatePrompt(ctx context.Context, input PromptInput, actorID string) (PromptTemplate, error) {
if err := validatePromptInput(&input, true); err != nil {
return PromptTemplate{}, err
}
if input.Variables == nil {
input.Variables = []Variable{}
}
id, err := newUUID()
if err != nil {
return PromptTemplate{}, err
}
versionID, err := newUUID()
if err != nil {
return PromptTemplate{}, err
}
vars, _ := json.Marshal(input.Variables)
tx, err := s.pool.Begin(ctx)
if err != nil {
return PromptTemplate{}, err
}
defer rollback(ctx, tx)
_, err = tx.Exec(ctx, `INSERT INTO gateway.prompt_templates(id,name,description,category_id,tags,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, id, input.Name, input.Description, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID)
if err != nil {
return PromptTemplate{}, err
}
_, err = tx.Exec(ctx, `INSERT INTO gateway.prompt_versions(id,template_id,version,content,variables,change_note,created_by) VALUES($1,$2,1,$3,$4,$5,$6)`, versionID, id, input.Content, vars, input.ChangeNote, actorID)
if err != nil {
return PromptTemplate{}, err
}
_, err = tx.Exec(ctx, `UPDATE gateway.prompt_templates SET current_version=1 WHERE id=$1`, id)
if err != nil {
return PromptTemplate{}, err
}
if err = emit(ctx, tx, "prompt.created", "prompt", id, actorID, map[string]any{"version": 1}); err != nil {
return PromptTemplate{}, err
}
if err = tx.Commit(ctx); err != nil {
return PromptTemplate{}, err
}
return s.GetPrompt(ctx, id)
}
func (s *Service) UpdatePrompt(ctx context.Context, id string, input PromptInput, actorID string) (PromptTemplate, error) {
if err := validatePromptInput(&input, false); err != nil {
return PromptTemplate{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return PromptTemplate{}, err
}
defer rollback(ctx, tx)
tag, err := tx.Exec(ctx, `UPDATE gateway.prompt_templates SET name=$2,description=$3,category_id=$4,tags=$5,department_ids=$6,enabled=$7,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Name, input.Description, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled)
if err != nil {
return PromptTemplate{}, err
}
if tag.RowsAffected() == 0 {
return PromptTemplate{}, ErrNotFound
}
if err = emit(ctx, tx, "prompt.updated", "prompt", id, actorID, nil); err != nil {
return PromptTemplate{}, err
}
if err = tx.Commit(ctx); err != nil {
return PromptTemplate{}, err
}
return s.GetPrompt(ctx, id)
}
func (s *Service) AddPromptVersion(ctx context.Context, id, content string, variables []Variable, changeNote, actorID string, activate bool) (PromptVersion, error) {
content = strings.TrimSpace(content)
changeNote = strings.TrimSpace(changeNote)
if content == "" || len(content) > 100000 {
return PromptVersion{}, errors.New("Prompt 正文不能为空且最多 100000 字符")
}
variables, err := validateVariables(variables)
if err != nil {
return PromptVersion{}, err
}
if variables == nil {
variables = []Variable{}
}
raw, _ := json.Marshal(variables)
versionID, err := newUUID()
if err != nil {
return PromptVersion{}, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return PromptVersion{}, err
}
defer rollback(ctx, tx)
var lockedID string
if err = tx.QueryRow(ctx, `SELECT id::text FROM gateway.prompt_templates WHERE id=$1 FOR UPDATE`, id).Scan(&lockedID); errors.Is(err, pgx.ErrNoRows) {
return PromptVersion{}, ErrNotFound
} else if err != nil {
return PromptVersion{}, err
}
var version int
err = tx.QueryRow(ctx, `SELECT coalesce(max(version),0)+1 FROM gateway.prompt_versions WHERE template_id=$1`, id).Scan(&version)
if err != nil {
return PromptVersion{}, err
}
var created time.Time
err = tx.QueryRow(ctx, `INSERT INTO gateway.prompt_versions(id,template_id,version,content,variables,change_note,created_by) SELECT $1,id,$3,$4,$5,$6,$7 FROM gateway.prompt_templates WHERE id=$2 RETURNING created_at`, versionID, id, version, content, raw, changeNote, actorID).Scan(&created)
if errors.Is(err, pgx.ErrNoRows) {
return PromptVersion{}, ErrNotFound
}
if err != nil {
return PromptVersion{}, err
}
if activate {
if _, err = tx.Exec(ctx, `UPDATE gateway.prompt_templates SET current_version=$2,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, version); err != nil {
return PromptVersion{}, err
}
}
if err = emit(ctx, tx, "prompt.version_created", "prompt", id, actorID, map[string]any{"version": version, "active": activate}); err != nil {
return PromptVersion{}, err
}
if err = tx.Commit(ctx); err != nil {
return PromptVersion{}, err
}
return PromptVersion{ID: versionID, TemplateID: id, Version: version, Content: content, Variables: variables, ChangeNote: changeNote, CreatedAt: created}, nil
}
func (s *Service) ListPromptVersions(ctx context.Context, id string) ([]PromptVersion, error) {
rows, err := s.pool.Query(ctx, `SELECT id::text,template_id::text,version,content,variables,change_note,created_at FROM gateway.prompt_versions WHERE template_id=$1 ORDER BY version DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PromptVersion{}
for rows.Next() {
var v PromptVersion
var raw []byte
if err = rows.Scan(&v.ID, &v.TemplateID, &v.Version, &v.Content, &raw, &v.ChangeNote, &v.CreatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal(raw, &v.Variables)
items = append(items, v)
}
return items, rows.Err()
}
func (s *Service) ActivatePromptVersion(ctx context.Context, id string, version int, actorID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer rollback(ctx, tx)
tag, err := tx.Exec(ctx, `UPDATE gateway.prompt_templates t SET current_version=$2,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND EXISTS(SELECT 1 FROM gateway.prompt_versions v WHERE v.template_id=t.id AND v.version=$2)`, id, version)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if err = emit(ctx, tx, "prompt.version_activated", "prompt", id, actorID, map[string]any{"version": version}); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Service) DeletePrompt(ctx context.Context, id, actorID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer rollback(ctx, tx)
tag, err := tx.Exec(ctx, `DELETE FROM gateway.prompt_templates WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if err = emit(ctx, tx, "prompt.deleted", "prompt", id, actorID, nil); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Service) RenderPromptByName(ctx context.Context, name string, provided map[string]any) (PromptVersion, string, error) {
var v PromptVersion
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT v.id::text,v.template_id::text,v.version,v.content,v.variables,v.change_note,v.created_at FROM gateway.prompt_templates t JOIN gateway.prompt_versions v ON v.template_id=t.id AND v.version=t.current_version WHERE t.name=$1 AND t.enabled`, name).Scan(&v.ID, &v.TemplateID, &v.Version, &v.Content, &raw, &v.ChangeNote, &v.CreatedAt)
if err != nil {
return v, "", mapNotFound(err)
}
_ = json.Unmarshal(raw, &v.Variables)
rendered, err := RenderPrompt(v.Content, v.Variables, provided)
return v, rendered, err
}