Files
backend/internal/server/agentkit.go

123 lines
4.0 KiB
Go

package server
import (
"errors"
"fmt"
"gorm.io/gorm"
"code.littlelan.cn/CarrotAssistant/backend/internal/agent"
"code.littlelan.cn/CarrotAssistant/backend/internal/llm"
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
)
// resolveLLMConfig builds the llm.Config for an App: looks up the App's
// ModelConfig (or errors), decrypts the API key, and returns a ready client.
func (d Deps) resolveLLMConfig(app *store.App) (llm.Config, error) {
if app.ModelConfigID == nil {
return llm.Config{}, errors.New("应用未配置模型")
}
var mc store.ModelConfig
if err := d.DB.First(&mc, "id = ?", *app.ModelConfigID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return llm.Config{}, errors.New("应用的模型配置不存在")
}
return llm.Config{}, err
}
apiKey, err := d.Crypto.Decrypt(mc.APIKeyEncrypted)
if err != nil {
return llm.Config{}, fmt.Errorf("解密 api key: %w", err)
}
return llm.Config{
BaseURL: mc.BaseURL,
APIKey: apiKey,
Model: mc.DefaultModel,
MaxTokens: mc.MaxTokens,
}, nil
}
// loadPublishedSkill returns the currently-published skill for an app plus its
// parsed content. Returns an error if none is published (the agent cannot run
// without one).
func (d Deps) loadPublishedSkill(app *store.App) (*store.Skill, *skill.Skill, error) {
var row store.Skill
err := d.DB.Where("app_id = ? AND status = ?", app.ID, "published").
Order("id DESC").First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, errors.New("应用尚未发布任何 skill")
}
return nil, nil, err
}
content, err := skill.NewManager(d.SkillsDir).Load(&row)
if err != nil {
return nil, nil, fmt.Errorf("读取 skill 文件: %w", err)
}
return &row, content, nil
}
// sessionHistory loads a session's messages and converts them to llm.Message
// form, prefixed with the skill's system prompt. The returned slice is ready
// to hand to agent.NewRunner.
func (d Deps) sessionHistory(sessionID string, sk *skill.Skill) ([]llm.Message, error) {
var msgs []store.Message
if err := d.DB.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs).Error; err != nil {
return nil, err
}
out := make([]llm.Message, 0, len(msgs)+1)
// System prompt first; never replay it from storage.
out = append(out, llm.Message{Role: llm.RoleSystem, Content: sk.SystemPrompt})
for _, m := range msgs {
out = append(out, toLLMMessage(m))
}
return out, nil
}
// toLLMMessage converts a stored message back into llm.Message, rehydrating
// tool calls from the JSON column.
func toLLMMessage(m store.Message) llm.Message {
msg := llm.Message{
Role: llm.Role(m.Role),
Content: m.Content,
ToolCallID: m.ToolCallID,
}
if len(m.ToolCalls) > 0 {
var calls []llm.ToolCall
// Best-effort decode; a corrupt column should never break the chat.
_ = jsonUnmarshal(m.ToolCalls, &calls)
msg.ToolCalls = calls
}
return msg
}
// persistMessage stores one message row. Used after each assistant turn /
// tool result so the conversation survives across requests.
func (d Deps) persistMessage(sessionID string, m llm.Message) error {
row := store.Message{
SessionID: sessionID,
Role: string(m.Role),
Content: m.Content,
ToolCallID: m.ToolCallID,
}
if len(m.ToolCalls) > 0 {
// Store the calls array directly; toLLMMessage decodes []ToolCall.
row.ToolCalls = jsonRaw(m.ToolCalls)
}
if err := d.DB.Create(&row).Error; err != nil {
return err
}
return d.DB.Model(&store.Session{}).Where("id = ?", sessionID).
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error
}
// newRunner assembles an agent.Runner for an app + session.
func (d Deps) newRunner(app *store.App, sk *skill.Skill, history []llm.Message, onEvent func(agent.Event)) (*agent.Runner, error) {
cfg, err := d.resolveLLMConfig(app)
if err != nil {
return nil, err
}
client := llm.New(cfg)
return agent.NewRunner(client, sk, history, onEvent), nil
}