Why: Masks must not be the only copy of a key, and personal instructions, lore hits, and candidate replies have to reach the model without rewriting the system prefix or deleting history. Evidence: go test ./internal/store ./internal/ctxasm ./internal/settings ./internal/runtime ./internal/server. Migration v16 upgrades and rolls back. Startup snapshots settings before moving raw keys. Impact: One labeled key is active per service. Excluding a message hides its whole run from the model. Regenerating appends another candidate and rewinds files to the user turn. User slash commands expand to prompt text and do not fire on their own. Refs: docs/records/2026-10-04-sillytavern-review.md
288 lines
7.7 KiB
Go
288 lines
7.7 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
agentcommand "agent-command"
|
|
"agent-system-backend/internal/store"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
var userCommandSlug = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
|
|
func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.st.ListSecrets(s.owner(r))
|
|
writeJSONOr(w, list, err)
|
|
}
|
|
|
|
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Service string `json:"service"`
|
|
Label string `json:"label"`
|
|
Value string `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, sec)
|
|
}
|
|
|
|
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
|
|
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
|
|
}
|
|
|
|
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Label string `json:"label"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeOKOr(w, s.st.RenameSecret(s.owner(r), r.PathValue("id"), req.Label))
|
|
}
|
|
|
|
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
|
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
|
|
}
|
|
|
|
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId"))
|
|
writeJSONOr(w, list, err)
|
|
}
|
|
|
|
func (s *Server) saveContextEntry(w http.ResponseWriter, r *http.Request) {
|
|
var e store.ContextEntry
|
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
saved, err := s.st.SaveContextEntry(s.owner(r), e)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, saved)
|
|
}
|
|
|
|
func (s *Server) deleteContextEntry(w http.ResponseWriter, r *http.Request) {
|
|
writeOKOr(w, s.st.DeleteContextEntry(s.owner(r), r.PathValue("id")))
|
|
}
|
|
|
|
func (s *Server) listUserPromptCommands(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.st.ListUserCommands(s.owner(r))
|
|
writeJSONOr(w, list, err)
|
|
}
|
|
|
|
func (s *Server) saveUserPromptCommand(w http.ResponseWriter, r *http.Request) {
|
|
var c store.UserCommand
|
|
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
saved, err := s.st.SaveUserCommand(s.owner(r), c)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.registerUserPrompt(saved)
|
|
writeJSON(w, http.StatusOK, saved)
|
|
}
|
|
|
|
func (s *Server) deleteUserPromptCommand(w http.ResponseWriter, r *http.Request) {
|
|
owner := s.owner(r)
|
|
id := r.PathValue("id")
|
|
list, _ := s.st.ListUserCommands(owner)
|
|
var name string
|
|
for _, c := range list {
|
|
if c.ID == id {
|
|
name = c.Name
|
|
}
|
|
}
|
|
if err := s.st.DeleteUserCommand(owner, id); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if s.Bus != nil && name != "" {
|
|
s.Bus.UnregisterUser(userCommandID(name), name)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
|
}
|
|
|
|
func (s *Server) excludeMessage(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Excluded *bool `json:"excluded"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
excluded := true
|
|
if req.Excluded != nil {
|
|
excluded = *req.Excluded
|
|
}
|
|
msg, err := s.st.MessageByID(r.PathValue("id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if _, err := s.st.GetConversation(s.owner(r), msg.ConversationID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeOKOr(w, s.st.ExcludeRunGroup(msg.ID, excluded))
|
|
}
|
|
|
|
func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) {
|
|
msg, err := s.st.MessageByID(r.PathValue("id"))
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if _, err := s.st.GetConversation(s.owner(r), msg.ConversationID); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeOKOr(w, s.st.SelectVariant(msg.ID))
|
|
}
|
|
|
|
func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
|
owner := s.owner(r)
|
|
convID := r.PathValue("id")
|
|
conv, err := s.st.GetConversation(owner, convID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
var req struct {
|
|
MessageID string `json:"messageId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
|
|
http.Error(w, "messageId required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
target, err := s.st.MessageByID(req.MessageID)
|
|
if err != nil || target.ConversationID != convID || target.Role != core.RoleAssistant {
|
|
http.Error(w, "assistant message required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
hist, err := s.st.ListMessages(convID)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
var user core.Message
|
|
found := false
|
|
maxIndex := target.VariantIndex
|
|
for _, m := range hist {
|
|
if m.VariantGroup != "" && m.VariantGroup == target.VariantGroup && m.VariantIndex > maxIndex {
|
|
maxIndex = m.VariantIndex
|
|
}
|
|
if m.ID == target.ID {
|
|
found = true
|
|
}
|
|
if !found && m.Role == core.RoleUser {
|
|
user = m
|
|
}
|
|
}
|
|
if user.ID == "" {
|
|
http.Error(w, "no user turn to regenerate", http.StatusBadRequest)
|
|
return
|
|
}
|
|
group := target.VariantGroup
|
|
if group == "" {
|
|
group = "var_" + target.ID
|
|
}
|
|
if err := s.st.SetMessageFlags(target.ID, true, group, target.VariantIndex); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if err := s.st.ExcludeRunGroup(target.ID, true); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if err := s.st.SetRegenPending(convID, group, maxIndex+1); err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
if _, err := s.mgr.RewindFiles(owner, convID, user.ID); err != nil {
|
|
// 没有文件检查点时仍然重新生成回复。
|
|
}
|
|
run, err := s.mgr.StartConversationRun(conv, user, nil, nil)
|
|
if err != nil {
|
|
_, _, _ = s.st.TakeRegenPending(convID)
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"run": run, "variantGroup": group})
|
|
}
|
|
|
|
func (s *Server) expandUserCommand(owner, text string) (string, bool) {
|
|
fields := strings.Fields(text)
|
|
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
|
|
return "", false
|
|
}
|
|
list, err := s.st.ListUserCommands(owner)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
name := strings.ToLower(fields[0])
|
|
rest := strings.TrimSpace(strings.TrimPrefix(text, fields[0]))
|
|
for _, c := range list {
|
|
if strings.ToLower(c.Name) != name {
|
|
continue
|
|
}
|
|
body := c.Template
|
|
if strings.Contains(body, "{{input}}") {
|
|
body = strings.ReplaceAll(body, "{{input}}", rest)
|
|
} else if rest != "" {
|
|
body = strings.TrimSpace(body + "\n" + rest)
|
|
}
|
|
return body, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func userCommandID(name string) string {
|
|
slug := strings.TrimPrefix(strings.ToLower(name), "/")
|
|
return "user." + slug
|
|
}
|
|
|
|
func (s *Server) registerUserPrompt(c store.UserCommand) {
|
|
if s.Bus == nil {
|
|
return
|
|
}
|
|
slug := strings.TrimPrefix(strings.ToLower(c.Name), "/")
|
|
if !userCommandSlug.MatchString(slug) {
|
|
return
|
|
}
|
|
_ = s.Bus.RegisterUser(agentcommand.CommandSpec{
|
|
ID: userCommandID(c.Name), Title: c.Title, Description: "展开为提示词,不自动执行",
|
|
}, c.Name)
|
|
}
|
|
|
|
func (s *Server) appendUserPromptCommands(owner string, out []map[string]any) []map[string]any {
|
|
list, err := s.st.ListUserCommands(owner)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, c := range list {
|
|
s.registerUserPrompt(c)
|
|
out = append(out, map[string]any{
|
|
"id": userCommandID(c.Name), "name": c.Name, "title": c.Title,
|
|
"description": "展开为提示词", "source": "user", "enabled": true,
|
|
})
|
|
}
|
|
return out
|
|
}
|