5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
378 lines
13 KiB
Go
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
|
|
}
|