feat: add utility agents for one-shot AI tasks (#341)
This commit is contained in:
@@ -38,6 +38,8 @@ import (
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
usercontroller "github.com/kandev/kandev/internal/user/controller"
|
||||
userhandlers "github.com/kandev/kandev/internal/user/handlers"
|
||||
utilitycontroller "github.com/kandev/kandev/internal/utility/controller"
|
||||
utilityhandlers "github.com/kandev/kandev/internal/utility/handlers"
|
||||
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
|
||||
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -260,6 +262,7 @@ type routeParams struct {
|
||||
notificationCtrl *notificationcontroller.Controller
|
||||
editorCtrl *editorcontroller.Controller
|
||||
promptCtrl *promptcontroller.Controller
|
||||
utilityCtrl *utilitycontroller.Controller
|
||||
msgCreator *messageCreatorAdapter
|
||||
secretsSvc *secrets.Service
|
||||
secretStore secrets.SecretStore
|
||||
@@ -326,6 +329,9 @@ func registerSecondaryRoutes(
|
||||
prompthandlers.RegisterRoutes(p.router, p.promptCtrl, p.log)
|
||||
p.log.Debug("Registered Prompts handlers (HTTP)")
|
||||
|
||||
utilityhandlers.RegisterRoutes(p.router, p.utilityCtrl, p.lifecycleMgr, p.services.User, p.log)
|
||||
p.log.Debug("Registered Utility Agents handlers (HTTP)")
|
||||
|
||||
clarification.RegisterRoutes(p.router, clarificationStore, p.gateway.Hub, p.msgCreator, p.taskRepo, p.log)
|
||||
p.log.Debug("Registered Clarification handlers (HTTP)")
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
|
||||
promptcontroller "github.com/kandev/kandev/internal/prompts/controller"
|
||||
usercontroller "github.com/kandev/kandev/internal/user/controller"
|
||||
utilitycontroller "github.com/kandev/kandev/internal/utility/controller"
|
||||
|
||||
// Orchestrator
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
@@ -426,6 +427,7 @@ func buildHTTPServer(
|
||||
notificationCtrl: notificationCtrl,
|
||||
editorCtrl: editorcontroller.NewController(services.Editor),
|
||||
promptCtrl: promptcontroller.NewController(services.Prompts),
|
||||
utilityCtrl: utilitycontroller.NewController(services.Utility),
|
||||
msgCreator: msgCreator,
|
||||
secretsSvc: secrets.NewService(repos.Secrets, log),
|
||||
secretStore: repos.Secrets,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
utilityservice "github.com/kandev/kandev/internal/utility/service"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
@@ -36,6 +37,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
userSvc := userservice.NewService(repos.User, eventBus, log)
|
||||
editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc)
|
||||
promptSvc := promptservice.NewService(repos.Prompts)
|
||||
utilitySvc := utilityservice.NewService(repos.Utility)
|
||||
workflowSvc := workflowservice.NewService(repos.Workflow, log)
|
||||
taskSvc := taskservice.NewService(
|
||||
taskservice.Repos{
|
||||
@@ -84,6 +86,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
User: userSvc,
|
||||
Editor: editorSvc,
|
||||
Prompts: promptSvc,
|
||||
Utility: utilitySvc,
|
||||
Workflow: workflowSvc,
|
||||
GitHub: githubSvc,
|
||||
// Notification service is initialized after gateway is available.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
utilitystore "github.com/kandev/kandev/internal/utility/store"
|
||||
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
|
||||
|
||||
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
@@ -81,6 +82,12 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep
|
||||
}
|
||||
cleanups = append(cleanups, cleanup)
|
||||
|
||||
utilityRepo, cleanup, err := utilitystore.Provide(writer, reader)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
cleanups = append(cleanups, cleanup)
|
||||
|
||||
// Initialize master key and secrets store
|
||||
kandevDir := filepath.Join(os.Getenv("HOME"), ".kandev")
|
||||
masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir)
|
||||
@@ -102,6 +109,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep
|
||||
Notification: notificationRepo,
|
||||
Editor: editorRepo,
|
||||
Prompts: promptRepo,
|
||||
Utility: utilityRepo,
|
||||
Workflow: workflowRepo,
|
||||
Secrets: secretStore,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
userstore "github.com/kandev/kandev/internal/user/store"
|
||||
utilityservice "github.com/kandev/kandev/internal/utility/service"
|
||||
utilitystore "github.com/kandev/kandev/internal/utility/store"
|
||||
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
@@ -27,6 +29,7 @@ type Repositories struct {
|
||||
Notification notificationstore.Repository
|
||||
Editor editorstore.Repository
|
||||
Prompts promptstore.Repository
|
||||
Utility utilitystore.Repository
|
||||
Workflow *workflowrepository.Repository
|
||||
Secrets secrets.SecretStore
|
||||
}
|
||||
@@ -37,6 +40,7 @@ type Services struct {
|
||||
Editor *editorservice.Service
|
||||
Notification *notificationservice.Service
|
||||
Prompts *promptservice.Service
|
||||
Utility *utilityservice.Service
|
||||
Workflow *workflowservice.Service
|
||||
GitHub *github.Service
|
||||
}
|
||||
|
||||
@@ -50,9 +50,14 @@ type Agent interface {
|
||||
RemoteAuth() *RemoteAuth
|
||||
}
|
||||
|
||||
// InferenceAgent is an optional capability for agents that support direct LLM inference.
|
||||
// InferenceAgent is an optional capability for agents that support one-shot LLM inference.
|
||||
// Agents implementing this interface can execute single prompts without a persistent session.
|
||||
type InferenceAgent interface {
|
||||
GenerateText(ctx context.Context, req InferenceRequest) (*InferenceResponse, error)
|
||||
// InferenceConfig returns the configuration for one-shot inference.
|
||||
InferenceConfig() *InferenceConfig
|
||||
|
||||
// InferenceModels returns models available for inference (may be a subset of full models).
|
||||
InferenceModels() []InferenceModel
|
||||
}
|
||||
|
||||
// PassthroughAgent is an optional capability for agents that support CLI passthrough mode.
|
||||
@@ -80,6 +85,25 @@ type Model struct {
|
||||
Source string `json:"source,omitempty"` // "static" or "dynamic"
|
||||
}
|
||||
|
||||
// ToInferenceModel converts a Model to an InferenceModel.
|
||||
func (m Model) ToInferenceModel() InferenceModel {
|
||||
return InferenceModel{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
IsDefault: m.IsDefault,
|
||||
}
|
||||
}
|
||||
|
||||
// ModelsToInferenceModels converts a slice of Models to InferenceModels.
|
||||
func ModelsToInferenceModels(models []Model) []InferenceModel {
|
||||
result := make([]InferenceModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
result = append(result, m.ToInferenceModel())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ModelList is the result of listing models for an agent.
|
||||
type ModelList struct {
|
||||
Models []Model
|
||||
@@ -214,20 +238,27 @@ var DefaultResourceLimits = ResourceLimits{
|
||||
MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour,
|
||||
}
|
||||
|
||||
// InferenceRequest for direct model inference.
|
||||
type InferenceRequest struct {
|
||||
Prompt string
|
||||
Model string
|
||||
MaxTokens int
|
||||
Credentials map[string]string
|
||||
// InferenceConfig describes how an agent executes one-shot prompts.
|
||||
type InferenceConfig struct {
|
||||
// Supported indicates the agent can do one-shot inference.
|
||||
Supported bool
|
||||
// Command is the CLI command for one-shot inference (e.g., ["claude", "--print"]).
|
||||
// Uses the local CLI directly since agents must be installed/authenticated to work.
|
||||
Command Command
|
||||
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
|
||||
ModelFlag Param
|
||||
// OutputFormat describes how to parse the output: "text", "stream-json".
|
||||
OutputFormat string
|
||||
// StdinInput if true, prompt is sent via stdin; otherwise as a positional argument.
|
||||
StdinInput bool
|
||||
}
|
||||
|
||||
// InferenceResponse from direct model inference.
|
||||
type InferenceResponse struct {
|
||||
Text string
|
||||
Model string
|
||||
TokensUsed int
|
||||
FinishReason string
|
||||
// InferenceModel describes a model available for inference.
|
||||
type InferenceModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// RemoteAuth describes all auth methods an agent supports for remote environments.
|
||||
|
||||
@@ -17,6 +17,7 @@ var ampLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*Amp)(nil)
|
||||
_ PassthroughAgent = (*Amp)(nil)
|
||||
_ InferenceAgent = (*Amp)(nil)
|
||||
)
|
||||
|
||||
type Amp struct {
|
||||
@@ -116,6 +117,22 @@ func (a *Amp) PermissionSettings() map[string]PermissionSetting {
|
||||
return ampPermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
func (a *Amp) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("amp", "--execute", "--stream-json", "--stream-json-input"),
|
||||
ModelFlag: NewParam("-m", "{model}"),
|
||||
OutputFormat: "stream-json",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *Amp) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(ampStaticModels())
|
||||
}
|
||||
|
||||
var ampPermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
Supported: true, Default: true, Label: "Auto-approve (Dangerously Allow All)", Description: "Automatically approve all tool calls including shell commands",
|
||||
|
||||
@@ -20,6 +20,7 @@ var auggieLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*Auggie)(nil)
|
||||
_ PassthroughAgent = (*Auggie)(nil)
|
||||
_ InferenceAgent = (*Auggie)(nil)
|
||||
)
|
||||
|
||||
// Auggie implements Agent for the Augment Coding Agent.
|
||||
@@ -147,6 +148,22 @@ func (a *Auggie) PermissionSettings() map[string]PermissionSetting {
|
||||
return auggiePermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
func (a *Auggie) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("auggie", "--print", "--output-format", "json"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
OutputFormat: "auggie-json",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *Auggie) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(auggieStaticModels())
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
var auggiePermTools = []string{"launch-process", "save-file", "str-replace-editor", "remove-files"}
|
||||
|
||||
@@ -17,6 +17,7 @@ var claudeCodeLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*ClaudeCode)(nil)
|
||||
_ PassthroughAgent = (*ClaudeCode)(nil)
|
||||
_ InferenceAgent = (*ClaudeCode)(nil)
|
||||
)
|
||||
|
||||
type ClaudeCode struct {
|
||||
@@ -150,6 +151,22 @@ func (a *ClaudeCode) PermissionSettings() map[string]PermissionSetting {
|
||||
return claudeCodePermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
func (a *ClaudeCode) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("claude", "--print"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
OutputFormat: "text",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference tasks.
|
||||
func (a *ClaudeCode) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(claudeCodeStaticModels())
|
||||
}
|
||||
|
||||
var claudeCodePermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls via stdio protocol",
|
||||
|
||||
@@ -17,6 +17,7 @@ var codexLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*Codex)(nil)
|
||||
_ PassthroughAgent = (*Codex)(nil)
|
||||
_ InferenceAgent = (*Codex)(nil)
|
||||
)
|
||||
|
||||
type Codex struct {
|
||||
@@ -125,6 +126,23 @@ func (a *Codex) PermissionSettings() map[string]PermissionSetting {
|
||||
return codexPermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
// Uses `exec` subcommand for non-interactive execution.
|
||||
func (a *Codex) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("codex", "exec"),
|
||||
ModelFlag: NewParam("-m", "{model}"),
|
||||
OutputFormat: "text",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *Codex) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(codexStaticModels())
|
||||
}
|
||||
|
||||
var codexPermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
Supported: true, Default: true, Label: "Auto-approve", Description: "Skip approval requests from Codex",
|
||||
|
||||
@@ -17,6 +17,7 @@ var copilotLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*Copilot)(nil)
|
||||
_ PassthroughAgent = (*Copilot)(nil)
|
||||
_ InferenceAgent = (*Copilot)(nil)
|
||||
)
|
||||
|
||||
type Copilot struct {
|
||||
@@ -116,6 +117,22 @@ func (a *Copilot) PermissionSettings() map[string]PermissionSetting {
|
||||
return copilotPermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
func (a *Copilot) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("github-copilot", "-p"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
OutputFormat: "text",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *Copilot) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(copilotStaticModels())
|
||||
}
|
||||
|
||||
var copilotPermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Enable all tools, folders and urls without confirmation.",
|
||||
|
||||
@@ -17,6 +17,7 @@ var geminiLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*Gemini)(nil)
|
||||
_ PassthroughAgent = (*Gemini)(nil)
|
||||
_ InferenceAgent = (*Gemini)(nil)
|
||||
)
|
||||
|
||||
type Gemini struct {
|
||||
@@ -134,6 +135,22 @@ func (a *Gemini) PermissionSettings() map[string]PermissionSetting {
|
||||
return geminiPermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
func (a *Gemini) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("gemini", "-p"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
OutputFormat: "text",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *Gemini) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(geminiStaticModels())
|
||||
}
|
||||
|
||||
var geminiPermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Automatically approve all tool calls",
|
||||
|
||||
@@ -18,6 +18,7 @@ var opencodeLogoDark []byte
|
||||
var (
|
||||
_ Agent = (*OpenCode)(nil)
|
||||
_ PassthroughAgent = (*OpenCode)(nil)
|
||||
_ InferenceAgent = (*OpenCode)(nil)
|
||||
)
|
||||
|
||||
type OpenCode struct {
|
||||
@@ -118,6 +119,22 @@ func (a *OpenCode) PermissionSettings() map[string]PermissionSetting {
|
||||
return opencodePermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
func (a *OpenCode) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand("opencode", "ask"),
|
||||
ModelFlag: NewParam("--model", "{model}"),
|
||||
OutputFormat: "text",
|
||||
StdinInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *OpenCode) InferenceModels() []InferenceModel {
|
||||
return ModelsToInferenceModels(opencodeStaticModels())
|
||||
}
|
||||
|
||||
var opencodePermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls",
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
var _ Agent = (*OpenCodeACP)(nil)
|
||||
var (
|
||||
_ Agent = (*OpenCodeACP)(nil)
|
||||
_ InferenceAgent = (*OpenCodeACP)(nil)
|
||||
)
|
||||
|
||||
// OpenCodeACP is the ACP protocol variant of OpenCode.
|
||||
// Uses JSON-RPC 2.0 over stdin/stdout via "opencode acp" instead of REST/SSE.
|
||||
@@ -81,3 +84,14 @@ func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { return nil }
|
||||
func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return opencodePermSettings
|
||||
}
|
||||
|
||||
// InferenceConfig returns configuration for one-shot inference.
|
||||
// Uses the same "opencode ask" command as the SSE variant.
|
||||
func (a *OpenCodeACP) InferenceConfig() *InferenceConfig {
|
||||
return (&OpenCode{}).InferenceConfig()
|
||||
}
|
||||
|
||||
// InferenceModels returns models available for one-shot inference.
|
||||
func (a *OpenCodeACP) InferenceModels() []InferenceModel {
|
||||
return (&OpenCode{}).InferenceModels()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/utility"
|
||||
)
|
||||
|
||||
// ExecuteInferencePrompt executes an inference prompt via an active session's agentctl.
|
||||
// It looks up the inference config from the agent registry and passes it to agentctl.
|
||||
func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID, model, prompt string) (*utility.PromptResponse, error) {
|
||||
if sessionID == "" {
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
|
||||
// Get inference agent from registry
|
||||
ia, ok := m.registry.GetInferenceAgent(agentID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent %q does not support inference", agentID)
|
||||
}
|
||||
|
||||
cfg := ia.InferenceConfig()
|
||||
if cfg == nil || !cfg.Supported {
|
||||
return nil, fmt.Errorf("agent %q inference not supported", agentID)
|
||||
}
|
||||
|
||||
// Get the agentctl client
|
||||
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no execution found for session %s", sessionID)
|
||||
}
|
||||
|
||||
client := execution.GetAgentCtlClient()
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("agentctl client not available for session %s", sessionID)
|
||||
}
|
||||
|
||||
// Build request with inference config
|
||||
req := &utility.PromptRequest{
|
||||
Prompt: prompt,
|
||||
AgentID: agentID,
|
||||
Model: model,
|
||||
InferenceConfig: &utility.InferenceConfigDTO{
|
||||
Command: cfg.Command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
OutputFormat: cfg.OutputFormat,
|
||||
StdinInput: cfg.StdinInput,
|
||||
},
|
||||
}
|
||||
|
||||
return client.InferencePrompt(ctx, req)
|
||||
}
|
||||
|
||||
// ListInferenceAgents returns agents that support inference with their models.
|
||||
// Only returns agents that are actually installed on the system.
|
||||
func (m *Manager) ListInferenceAgents() []InferenceAgentInfo {
|
||||
return m.ListInferenceAgentsWithContext(context.Background())
|
||||
}
|
||||
|
||||
// ListInferenceAgentsWithContext returns installed inference agents using the provided context.
|
||||
func (m *Manager) ListInferenceAgentsWithContext(ctx context.Context) []InferenceAgentInfo {
|
||||
inferenceAgents := m.registry.ListInferenceAgents()
|
||||
result := make([]InferenceAgentInfo, 0, len(inferenceAgents))
|
||||
|
||||
for _, ia := range inferenceAgents {
|
||||
// Get base agent for metadata
|
||||
ag, ok := ia.(agents.Agent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only include agents that are installed
|
||||
installed, err := ag.IsInstalled(ctx)
|
||||
if err != nil || installed == nil || !installed.Available {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, InferenceAgentInfo{
|
||||
ID: ag.ID(),
|
||||
Name: ag.Name(),
|
||||
DisplayName: ag.DisplayName(),
|
||||
Models: ia.InferenceModels(),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// InferenceAgentInfo contains info about an inference-capable agent.
|
||||
type InferenceAgentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Models []agents.InferenceModel `json:"models"`
|
||||
}
|
||||
@@ -133,6 +133,59 @@ func (r *Registry) ListEnabled() []agents.Agent {
|
||||
return result
|
||||
}
|
||||
|
||||
// ListInferenceAgents returns enabled agents that implement InferenceAgent.
|
||||
func (r *Registry) ListInferenceAgents() []agents.InferenceAgent {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []agents.InferenceAgent
|
||||
var forSort []agents.Agent
|
||||
for _, ag := range r.agents {
|
||||
if !ag.Enabled() {
|
||||
continue
|
||||
}
|
||||
if ia, ok := ag.(agents.InferenceAgent); ok {
|
||||
if cfg := ia.InferenceConfig(); cfg != nil && cfg.Supported {
|
||||
result = append(result, ia)
|
||||
forSort = append(forSort, ag)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by DisplayOrder
|
||||
slices.SortStableFunc(result, func(a, b agents.InferenceAgent) int {
|
||||
aIdx, bIdx := -1, -1
|
||||
for i, ag := range forSort {
|
||||
if ag.(agents.InferenceAgent) == a {
|
||||
aIdx = i
|
||||
}
|
||||
if ag.(agents.InferenceAgent) == b {
|
||||
bIdx = i
|
||||
}
|
||||
}
|
||||
return cmp.Compare(forSort[aIdx].DisplayOrder(), forSort[bIdx].DisplayOrder())
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// GetInferenceAgent returns an inference-capable agent by ID.
|
||||
func (r *Registry) GetInferenceAgent(id string) (agents.InferenceAgent, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
ag, exists := r.agents[id]
|
||||
if !exists || !ag.Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
ia, ok := ag.(agents.InferenceAgent)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if cfg := ia.InferenceConfig(); cfg == nil || !cfg.Supported {
|
||||
return nil, false
|
||||
}
|
||||
return ia, true
|
||||
}
|
||||
|
||||
// Exists checks if an agent exists
|
||||
func (r *Registry) Exists(id string) bool {
|
||||
r.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/server/utility"
|
||||
)
|
||||
|
||||
// InferencePrompt executes a one-shot inference prompt via agentctl.
|
||||
func (c *Client) InferencePrompt(ctx context.Context, req *utility.PromptRequest) (*utility.PromptResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/inference/prompt", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("utility prompt failed with status %d: %s", resp.StatusCode, truncateBody(respBody))
|
||||
}
|
||||
|
||||
var result utility.PromptResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/mcp"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/utility"
|
||||
"github.com/kandev/kandev/internal/common/httpmw"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
@@ -123,6 +124,10 @@ func (s *Server) setupRoutes() {
|
||||
api.GET("/git/commit/:sha", s.handleGitShowCommit)
|
||||
}
|
||||
|
||||
// Utility agent routes
|
||||
auxHandler := utility.NewHandler(s.cfg.WorkDir, s.logger.Zap())
|
||||
auxHandler.RegisterRoutes(api)
|
||||
|
||||
// MCP routes (if MCP server is configured)
|
||||
if s.mcpServer != nil {
|
||||
s.mcpServer.RegisterRoutes(s.router)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package utility
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// modelNameRegex validates model names - alphanumeric, hyphens, underscores, dots, slashes, colons.
|
||||
var modelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9._:/-]*$`)
|
||||
|
||||
// validateModelName ensures the model name doesn't contain shell metacharacters.
|
||||
func validateModelName(model string) error {
|
||||
if model == "" {
|
||||
return nil // Empty model is allowed (uses default)
|
||||
}
|
||||
if len(model) > 256 {
|
||||
return fmt.Errorf("model name too long")
|
||||
}
|
||||
if !modelNameRegex.MatchString(model) {
|
||||
return fmt.Errorf("invalid model name: contains disallowed characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InferenceExecutor executes one-shot prompts using the agent's inference config.
|
||||
type InferenceExecutor struct {
|
||||
workDir string
|
||||
}
|
||||
|
||||
// NewInferenceExecutor creates a new inference executor.
|
||||
func NewInferenceExecutor(workDir string) *InferenceExecutor {
|
||||
return &InferenceExecutor{workDir: workDir}
|
||||
}
|
||||
|
||||
// Execute runs a one-shot prompt using the agent's inference configuration.
|
||||
func (e *InferenceExecutor) Execute(ctx context.Context, req *PromptRequest) (*PromptResponse, error) {
|
||||
if req.InferenceConfig == nil {
|
||||
return &PromptResponse{Success: false, Error: "inference config is required"}, nil
|
||||
}
|
||||
|
||||
cfg := req.InferenceConfig
|
||||
if len(cfg.Command) == 0 {
|
||||
return &PromptResponse{Success: false, Error: "inference command is empty"}, nil
|
||||
}
|
||||
|
||||
// Validate model name to prevent command injection.
|
||||
// Model is user-provided and substituted into command arguments.
|
||||
if err := validateModelName(req.Model); err != nil {
|
||||
return &PromptResponse{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Build command from inference config.
|
||||
// Note: Command and ModelFlag come from hardcoded agent definitions in the registry,
|
||||
// not from user input. Only the model name (validated above) and prompt are user-provided.
|
||||
args := e.buildCommand(cfg, req.Model)
|
||||
|
||||
// #nosec G204 -- Command comes from agent registry (hardcoded), model is validated above
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.Dir = e.workDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := e.startCommand(cmd, cfg.StdinInput, req.Prompt); err != nil {
|
||||
return &PromptResponse{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return &PromptResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("process failed: %v, stderr: %s", err, stderr.String()),
|
||||
DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Parse response based on output format
|
||||
response := e.parseResponse(stdout.String(), cfg.OutputFormat)
|
||||
|
||||
return &PromptResponse{
|
||||
Success: true,
|
||||
Response: response,
|
||||
Model: req.Model,
|
||||
DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// startCommand starts the command with optional stdin input.
|
||||
func (e *InferenceExecutor) startCommand(cmd *exec.Cmd, stdinInput bool, prompt string) error {
|
||||
if !stdinInput {
|
||||
cmd.Args = append(cmd.Args, prompt)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
if _, err := stdin.Write([]byte(prompt)); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCommand builds the command arguments from inference config.
|
||||
func (e *InferenceExecutor) buildCommand(cfg *InferenceConfigDTO, model string) []string {
|
||||
args := make([]string, len(cfg.Command))
|
||||
copy(args, cfg.Command)
|
||||
|
||||
if model != "" && len(cfg.ModelFlag) > 0 {
|
||||
for _, part := range cfg.ModelFlag {
|
||||
args = append(args, strings.ReplaceAll(part, "{model}", model))
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// parseResponse extracts text from the output based on format.
|
||||
func (e *InferenceExecutor) parseResponse(output, format string) string {
|
||||
switch format {
|
||||
case "stream-json":
|
||||
return parseStreamJSON(output)
|
||||
case "auggie-json":
|
||||
return parseAuggieJSON(output)
|
||||
default:
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
}
|
||||
|
||||
// parseStreamJSON parses stream-json output format (used by Amp).
|
||||
func parseStreamJSON(output string) string {
|
||||
var result strings.Builder
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
// Increase buffer for large outputs (8MB max)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
text := extractAssistantText(line)
|
||||
if text != "" {
|
||||
result.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore scanner errors - best effort parsing
|
||||
_ = scanner.Err()
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// extractAssistantText extracts text content from a JSON line if it's an assistant message.
|
||||
func extractAssistantText(line string) string {
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
msgType, ok := msg["type"].(string)
|
||||
if !ok || msgType != "assistant" {
|
||||
return ""
|
||||
}
|
||||
|
||||
message, ok := msg["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
content, ok := message["content"].([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return extractTextFromContent(content)
|
||||
}
|
||||
|
||||
// extractTextFromContent extracts text from content blocks.
|
||||
func extractTextFromContent(content []interface{}) string {
|
||||
var result strings.Builder
|
||||
for _, block := range content {
|
||||
b, ok := block.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
blockType, ok := b["type"].(string)
|
||||
if !ok || blockType != "text" {
|
||||
continue
|
||||
}
|
||||
if text, ok := b["text"].(string); ok {
|
||||
result.WriteString(text)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// parseAuggieJSON extracts the result field from auggie JSON output.
|
||||
func parseAuggieJSON(output string) string {
|
||||
var response struct {
|
||||
Result string `json:"result"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &response); err != nil {
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
return strings.TrimSpace(response.Result)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package utility
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Handler provides HTTP handlers for inference operations.
|
||||
type Handler struct {
|
||||
executor *InferenceExecutor
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new inference handler.
|
||||
func NewHandler(workDir string, logger *zap.Logger) *Handler {
|
||||
return &Handler{
|
||||
executor: NewInferenceExecutor(workDir),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the inference routes on the given router group.
|
||||
func (h *Handler) RegisterRoutes(api *gin.RouterGroup) {
|
||||
aux := api.Group("/inference")
|
||||
aux.POST("/prompt", h.handlePrompt)
|
||||
}
|
||||
|
||||
// handlePrompt handles POST /api/v1/inference/prompt
|
||||
func (h *Handler) handlePrompt(c *gin.Context) {
|
||||
var req PromptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, PromptResponse{
|
||||
Success: false,
|
||||
Error: "invalid request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("executing inference prompt",
|
||||
zap.String("agent_id", req.AgentID),
|
||||
zap.String("model", req.Model),
|
||||
)
|
||||
|
||||
resp, err := h.executor.Execute(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("inference prompt failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, PromptResponse{
|
||||
Success: false,
|
||||
Error: "inference execution failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
h.logger.Warn("inference prompt unsuccessful", zap.String("error", resp.Error))
|
||||
} else {
|
||||
h.logger.Info("inference prompt completed", zap.Int("duration_ms", resp.DurationMs))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package utility provides one-shot prompt execution via inference-capable agents.
|
||||
// This is a simplified interface compared to the full session-based adapters,
|
||||
// designed for quick tasks like generating commit messages or PR descriptions.
|
||||
package utility
|
||||
|
||||
// PromptRequest is the request for executing an inference prompt.
|
||||
type PromptRequest struct {
|
||||
// Prompt is the fully resolved prompt text to send to the LLM.
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
|
||||
// AgentID is the agent to use (e.g., "claude-code", "amp").
|
||||
AgentID string `json:"agent_id" binding:"required"`
|
||||
|
||||
// Model is the model to use (e.g., "claude-haiku-4-5").
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// InferenceConfig is the agent's inference configuration.
|
||||
// This is passed from the backend which has access to the agent registry.
|
||||
InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"`
|
||||
|
||||
// MaxTokens is the maximum tokens for the response (default: 1024).
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// InferenceConfigDTO is the inference configuration passed from backend to agentctl.
|
||||
type InferenceConfigDTO struct {
|
||||
// Command is the CLI command for one-shot inference (e.g., ["claude", "--print"]).
|
||||
Command []string `json:"command"`
|
||||
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
|
||||
ModelFlag []string `json:"model_flag,omitempty"`
|
||||
// OutputFormat describes how to parse the output: "text", "stream-json".
|
||||
OutputFormat string `json:"output_format"`
|
||||
// StdinInput if true, prompt is sent via stdin; otherwise as a positional argument.
|
||||
StdinInput bool `json:"stdin_input"`
|
||||
}
|
||||
|
||||
// PromptResponse is the response from executing a utility prompt.
|
||||
type PromptResponse struct {
|
||||
// Success indicates if the prompt completed successfully.
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Response is the generated text.
|
||||
Response string `json:"response,omitempty"`
|
||||
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// PromptTokens is the number of input tokens.
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
|
||||
// ResponseTokens is the number of output tokens.
|
||||
ResponseTokens int `json:"response_tokens,omitempty"`
|
||||
|
||||
// DurationMs is the execution duration in milliseconds.
|
||||
DurationMs int `json:"duration_ms,omitempty"`
|
||||
|
||||
// Error is the error message if the prompt failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ModelInfo represents an available model.
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ModelsResponse is the response for listing available models.
|
||||
type ModelsResponse struct {
|
||||
Models []ModelInfo `json:"models"`
|
||||
}
|
||||
@@ -60,6 +60,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
|
||||
LspAutoInstallLanguages: req.LspAutoInstallLanguages,
|
||||
LspServerConfigs: req.LspServerConfigs,
|
||||
SavedLayouts: req.SavedLayouts,
|
||||
DefaultUtilityAgentID: req.DefaultUtilityAgentID,
|
||||
DefaultUtilityModel: req.DefaultUtilityModel,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.UserSettingsResponse{}, err
|
||||
|
||||
@@ -31,6 +31,8 @@ type UserSettingsDTO struct {
|
||||
LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
|
||||
LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"`
|
||||
SavedLayouts []models.SavedLayout `json:"saved_layouts"`
|
||||
DefaultUtilityAgentID string `json:"default_utility_agent_id"`
|
||||
DefaultUtilityModel string `json:"default_utility_model"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -66,6 +68,8 @@ type UpdateUserSettingsRequest struct {
|
||||
LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"`
|
||||
LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"`
|
||||
SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"`
|
||||
DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"`
|
||||
DefaultUtilityModel *string `json:"default_utility_model,omitempty"`
|
||||
}
|
||||
|
||||
func FromUser(user *models.User) UserDTO {
|
||||
@@ -96,6 +100,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
|
||||
LspAutoInstallLanguages: settings.LspAutoInstallLanguages,
|
||||
LspServerConfigs: settings.LspServerConfigs,
|
||||
SavedLayouts: settings.SavedLayouts,
|
||||
DefaultUtilityAgentID: settings.DefaultUtilityAgentID,
|
||||
DefaultUtilityModel: settings.DefaultUtilityModel,
|
||||
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type UserSettings struct {
|
||||
LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
|
||||
LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"`
|
||||
SavedLayouts []SavedLayout `json:"saved_layouts"`
|
||||
DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents
|
||||
DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ type UpdateUserSettingsRequest struct {
|
||||
LspAutoInstallLanguages *[]string
|
||||
LspServerConfigs *map[string]map[string]interface{}
|
||||
SavedLayouts *[]models.SavedLayout
|
||||
DefaultUtilityAgentID *string
|
||||
DefaultUtilityModel *string
|
||||
}
|
||||
|
||||
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
|
||||
@@ -77,6 +79,15 @@ func (s *Service) PreferredShell(ctx context.Context) (string, error) {
|
||||
return settings.PreferredShell, nil
|
||||
}
|
||||
|
||||
// GetDefaultUtilitySettings returns the user's default utility agent/model settings.
|
||||
func (s *Service) GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) {
|
||||
settings, err := s.repo.GetUserSettings(ctx, s.defaultUser)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return settings.DefaultUtilityAgentID, settings.DefaultUtilityModel, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) {
|
||||
settings, err := s.repo.GetUserSettings(ctx, s.defaultUser)
|
||||
if err != nil {
|
||||
@@ -137,6 +148,12 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe
|
||||
if req.ReleaseNotesLastSeenVersion != nil {
|
||||
settings.ReleaseNotesLastSeenVersion = *req.ReleaseNotesLastSeenVersion
|
||||
}
|
||||
if req.DefaultUtilityAgentID != nil {
|
||||
settings.DefaultUtilityAgentID = strings.TrimSpace(*req.DefaultUtilityAgentID)
|
||||
}
|
||||
if req.DefaultUtilityModel != nil {
|
||||
settings.DefaultUtilityModel = strings.TrimSpace(*req.DefaultUtilityModel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -216,6 +233,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
"lsp_auto_install_languages": settings.LspAutoInstallLanguages,
|
||||
"lsp_server_configs": settings.LspServerConfigs,
|
||||
"saved_layouts": settings.SavedLayouts,
|
||||
"default_utility_agent_id": settings.DefaultUtilityAgentID,
|
||||
"default_utility_model": settings.DefaultUtilityModel,
|
||||
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
|
||||
|
||||
@@ -146,6 +146,8 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
|
||||
"lsp_auto_install_languages": lspAutoInstall,
|
||||
"lsp_server_configs": lspServerConfigs,
|
||||
"saved_layouts": savedLayouts,
|
||||
"default_utility_agent_id": settings.DefaultUtilityAgentID,
|
||||
"default_utility_model": settings.DefaultUtilityModel,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -207,6 +209,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
|
||||
LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"`
|
||||
SavedLayouts []models.SavedLayout `json:"saved_layouts"`
|
||||
DefaultUtilityAgentID string `json:"default_utility_agent_id"`
|
||||
DefaultUtilityModel string `json:"default_utility_model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -249,5 +253,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
if settings.SavedLayouts == nil {
|
||||
settings.SavedLayouts = []models.SavedLayout{}
|
||||
}
|
||||
settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID
|
||||
settings.DefaultUtilityModel = payload.DefaultUtilityModel
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/utility/dto"
|
||||
"github.com/kandev/kandev/internal/utility/service"
|
||||
"github.com/kandev/kandev/internal/utility/template"
|
||||
)
|
||||
|
||||
// Controller handles utility agent operations.
|
||||
type Controller struct {
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
// NewController creates a new utility agent controller.
|
||||
func NewController(svc *service.Service) *Controller {
|
||||
return &Controller{svc: svc}
|
||||
}
|
||||
|
||||
// ListAgents returns all utility agents.
|
||||
func (c *Controller) ListAgents(ctx context.Context) (*dto.UtilityAgentsResponse, error) {
|
||||
agents, err := c.svc.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dtos := make([]dto.UtilityAgentDTO, 0, len(agents))
|
||||
for _, agent := range agents {
|
||||
dtos = append(dtos, dto.FromUtilityAgent(agent))
|
||||
}
|
||||
|
||||
return &dto.UtilityAgentsResponse{Agents: dtos}, nil
|
||||
}
|
||||
|
||||
// GetAgent returns a utility agent by ID.
|
||||
func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.UtilityAgentDTO, error) {
|
||||
agent, err := c.svc.GetAgentByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := dto.FromUtilityAgent(agent)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateAgent creates a new utility agent.
|
||||
func (c *Controller) CreateAgent(ctx context.Context, req dto.CreateUtilityAgentRequest) (*dto.UtilityAgentDTO, error) {
|
||||
agent, err := c.svc.CreateAgent(
|
||||
ctx,
|
||||
req.Name,
|
||||
req.Description,
|
||||
req.Prompt,
|
||||
req.AgentID,
|
||||
req.Model,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := dto.FromUtilityAgent(agent)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateAgent updates an existing utility agent.
|
||||
func (c *Controller) UpdateAgent(ctx context.Context, id string, req dto.UpdateUtilityAgentRequest) (*dto.UtilityAgentDTO, error) {
|
||||
agent, err := c.svc.UpdateAgent(
|
||||
ctx,
|
||||
id,
|
||||
req.Name,
|
||||
req.Description,
|
||||
req.Prompt,
|
||||
req.AgentID,
|
||||
req.Model,
|
||||
req.Enabled,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := dto.FromUtilityAgent(agent)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteAgent deletes a utility agent.
|
||||
func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
|
||||
return c.svc.DeleteAgent(ctx, id)
|
||||
}
|
||||
|
||||
// GetTemplateVariables returns the available template variables.
|
||||
func (c *Controller) GetTemplateVariables(ctx context.Context) *dto.TemplateVariablesResponse {
|
||||
return &dto.TemplateVariablesResponse{
|
||||
Variables: c.svc.GetAvailableVariables(),
|
||||
}
|
||||
}
|
||||
|
||||
// PreparePromptRequest prepares a prompt request by resolving the template.
|
||||
// If defaults is provided and the utility agent has empty agent_id/model, the defaults are used.
|
||||
func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePromptRequest, defaults *service.DefaultUtilitySettings) (*service.PromptRequest, error) {
|
||||
tmplCtx := &template.Context{
|
||||
GitDiff: req.GitDiff,
|
||||
CommitLog: req.CommitLog,
|
||||
ChangedFiles: req.ChangedFiles,
|
||||
DiffSummary: req.DiffSummary,
|
||||
BranchName: req.BranchName,
|
||||
BaseBranch: req.BaseBranch,
|
||||
TaskTitle: req.TaskTitle,
|
||||
TaskDescription: req.TaskDescription,
|
||||
SessionID: req.SessionID,
|
||||
UserPrompt: req.UserPrompt,
|
||||
}
|
||||
return c.svc.PreparePromptRequest(ctx, req.UtilityAgentID, tmplCtx, defaults)
|
||||
}
|
||||
|
||||
// CreateCall creates a call record for tracking.
|
||||
func (c *Controller) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string) (string, error) {
|
||||
call, err := c.svc.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return call.ID, nil
|
||||
}
|
||||
|
||||
// CompleteCall marks a call as completed.
|
||||
func (c *Controller) CompleteCall(ctx context.Context, callID, response string, promptTokens, responseTokens, durationMs int) error {
|
||||
return c.svc.CompleteCall(ctx, callID, response, promptTokens, responseTokens, durationMs)
|
||||
}
|
||||
|
||||
// FailCall marks a call as failed.
|
||||
func (c *Controller) FailCall(ctx context.Context, callID, errorMessage string, durationMs int) error {
|
||||
return c.svc.FailCall(ctx, callID, errorMessage, durationMs)
|
||||
}
|
||||
|
||||
// ListCalls returns the call history for a utility agent.
|
||||
func (c *Controller) ListCalls(ctx context.Context, utilityID string, limit int) (*dto.UtilityAgentCallsResponse, error) {
|
||||
calls, err := c.svc.ListCalls(ctx, utilityID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dtos := make([]dto.UtilityAgentCallDTO, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
dtos = append(dtos, dto.FromUtilityAgentCall(call))
|
||||
}
|
||||
|
||||
return &dto.UtilityAgentCallsResponse{Calls: dtos}, nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/utility/models"
|
||||
"github.com/kandev/kandev/internal/utility/template"
|
||||
)
|
||||
|
||||
// UtilityAgentDTO represents a utility agent for API responses.
|
||||
type UtilityAgentDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Prompt string `json:"prompt"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Model string `json:"model"`
|
||||
Builtin bool `json:"builtin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UtilityAgentsResponse is the response for listing utility agents.
|
||||
type UtilityAgentsResponse struct {
|
||||
Agents []UtilityAgentDTO `json:"agents"`
|
||||
}
|
||||
|
||||
// CreateUtilityAgentRequest is the request for creating a utility agent.
|
||||
type CreateUtilityAgentRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
AgentID string `json:"agent_id" binding:"required"`
|
||||
Model string `json:"model" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateUtilityAgentRequest is the request for updating a utility agent.
|
||||
type UpdateUtilityAgentRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Prompt *string `json:"prompt,omitempty"`
|
||||
AgentID *string `json:"agent_id,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UtilityAgentCallDTO represents a call for API responses.
|
||||
type UtilityAgentCallDTO struct {
|
||||
ID string `json:"id"`
|
||||
UtilityID string `json:"utility_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
ResolvedPrompt string `json:"resolved_prompt"`
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
ResponseTokens int `json:"response_tokens"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// UtilityAgentCallsResponse is the response for listing calls.
|
||||
type UtilityAgentCallsResponse struct {
|
||||
Calls []UtilityAgentCallDTO `json:"calls"`
|
||||
}
|
||||
|
||||
// TemplateVariablesResponse is the response for listing available template variables.
|
||||
type TemplateVariablesResponse struct {
|
||||
Variables []template.VariableInfo `json:"variables"`
|
||||
}
|
||||
|
||||
// FromUtilityAgent converts a model to a DTO.
|
||||
func FromUtilityAgent(agent *models.UtilityAgent) UtilityAgentDTO {
|
||||
return UtilityAgentDTO{
|
||||
ID: agent.ID,
|
||||
Name: agent.Name,
|
||||
Description: agent.Description,
|
||||
Prompt: agent.Prompt,
|
||||
AgentID: agent.AgentID,
|
||||
Model: agent.Model,
|
||||
Builtin: agent.Builtin,
|
||||
Enabled: agent.Enabled,
|
||||
CreatedAt: agent.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: agent.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// FromUtilityAgentCall converts a call model to a DTO.
|
||||
func FromUtilityAgentCall(call *models.UtilityAgentCall) UtilityAgentCallDTO {
|
||||
dto := UtilityAgentCallDTO{
|
||||
ID: call.ID,
|
||||
UtilityID: call.UtilityID,
|
||||
SessionID: call.SessionID,
|
||||
ResolvedPrompt: call.ResolvedPrompt,
|
||||
Response: call.Response,
|
||||
Model: call.Model,
|
||||
PromptTokens: call.PromptTokens,
|
||||
ResponseTokens: call.ResponseTokens,
|
||||
DurationMs: call.DurationMs,
|
||||
Status: call.Status,
|
||||
ErrorMessage: call.ErrorMessage,
|
||||
CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if call.CompletedAt != nil {
|
||||
formatted := call.CompletedAt.UTC().Format(time.RFC3339)
|
||||
dto.CompletedAt = &formatted
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// ExecutePromptRequest is the request for executing a utility prompt.
|
||||
type ExecutePromptRequest struct {
|
||||
// UtilityAgentID is the ID of the utility agent to execute.
|
||||
UtilityAgentID string `json:"utility_agent_id" binding:"required"`
|
||||
|
||||
// SessionID is the active session to piggyback on.
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
// Context variables for template resolution.
|
||||
GitDiff string `json:"git_diff,omitempty"`
|
||||
CommitLog string `json:"commit_log,omitempty"`
|
||||
ChangedFiles string `json:"changed_files,omitempty"`
|
||||
DiffSummary string `json:"diff_summary,omitempty"`
|
||||
BranchName string `json:"branch_name,omitempty"`
|
||||
BaseBranch string `json:"base_branch,omitempty"`
|
||||
TaskTitle string `json:"task_title,omitempty"`
|
||||
TaskDescription string `json:"task_description,omitempty"`
|
||||
UserPrompt string `json:"user_prompt,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutePromptResponse is the response from executing a utility prompt.
|
||||
type ExecutePromptResponse struct {
|
||||
Success bool `json:"success"`
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Response string `json:"response,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
ResponseTokens int `json:"response_tokens,omitempty"`
|
||||
DurationMs int `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ModelInfoDTO represents a model for API responses.
|
||||
type ModelInfoDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ModelsResponse is the response for listing models.
|
||||
type ModelsResponse struct {
|
||||
Models []ModelInfoDTO `json:"models"`
|
||||
}
|
||||
|
||||
// InferenceModelDTO represents a model available for inference.
|
||||
type InferenceModelDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// InferenceAgentDTO represents an agent that supports inference.
|
||||
type InferenceAgentDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Models []InferenceModelDTO `json:"models"`
|
||||
}
|
||||
|
||||
// InferenceAgentsResponse is the response for listing inference agents.
|
||||
type InferenceAgentsResponse struct {
|
||||
Agents []InferenceAgentDTO `json:"agents"`
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/utility/controller"
|
||||
"github.com/kandev/kandev/internal/utility/dto"
|
||||
"github.com/kandev/kandev/internal/utility/service"
|
||||
)
|
||||
|
||||
// InferenceExecutor executes inference prompts via agentctl.
|
||||
type InferenceExecutor interface {
|
||||
// ExecuteInferencePrompt executes an inference prompt via an active session's agentctl.
|
||||
ExecuteInferencePrompt(ctx context.Context, sessionID, agentID, model, prompt string) (*agentctlutil.PromptResponse, error)
|
||||
// ListInferenceAgentsWithContext returns installed agents that support inference.
|
||||
ListInferenceAgentsWithContext(ctx context.Context) []lifecycle.InferenceAgentInfo
|
||||
}
|
||||
|
||||
// UserSettingsProvider provides user settings for default utility agent/model.
|
||||
type UserSettingsProvider interface {
|
||||
// GetDefaultUtilitySettings returns the user's default utility agent/model settings.
|
||||
GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error)
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handlers for utility agents.
|
||||
type Handlers struct {
|
||||
controller *controller.Controller
|
||||
executor InferenceExecutor
|
||||
userSettings UserSettingsProvider
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHandlers creates new utility agent handlers.
|
||||
func NewHandlers(ctrl *controller.Controller, executor InferenceExecutor, userSettings UserSettingsProvider, log *logger.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
controller: ctrl,
|
||||
executor: executor,
|
||||
userSettings: userSettings,
|
||||
logger: log.WithFields(zap.String("component", "utility-handlers")),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the utility agent routes.
|
||||
func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, executor InferenceExecutor, userSettings UserSettingsProvider, log *logger.Logger) {
|
||||
handlers := NewHandlers(ctrl, executor, userSettings, log)
|
||||
api := router.Group("/api/v1/utility")
|
||||
api.GET("/agents", handlers.httpListAgents)
|
||||
api.GET("/agents/:id", handlers.httpGetAgent)
|
||||
api.POST("/agents", handlers.httpCreateAgent)
|
||||
api.PATCH("/agents/:id", handlers.httpUpdateAgent)
|
||||
api.DELETE("/agents/:id", handlers.httpDeleteAgent)
|
||||
api.GET("/template-variables", handlers.httpGetTemplateVariables)
|
||||
api.POST("/execute", handlers.httpExecutePrompt)
|
||||
api.GET("/agents/:id/calls", handlers.httpListCalls)
|
||||
api.GET("/inference-agents", handlers.httpListInferenceAgents)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpListAgents(c *gin.Context) {
|
||||
resp, err := h.controller.ListAgents(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list utility agents", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list utility agents"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpGetAgent(c *gin.Context) {
|
||||
resp, err := h.controller.GetAgent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, service.ErrAgentNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
h.logger.Error("failed to get utility agent", zap.Error(err))
|
||||
c.JSON(status, gin.H{"error": "failed to get utility agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateAgent(c *gin.Context) {
|
||||
var req dto.CreateUtilityAgentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.CreateAgent(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, service.ErrInvalidAgent) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
h.logger.Error("failed to create utility agent", zap.Error(err))
|
||||
c.JSON(status, gin.H{"error": "failed to create utility agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateAgent(c *gin.Context) {
|
||||
var req dto.UpdateUtilityAgentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateAgent(c.Request.Context(), c.Param("id"), req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAgentNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, service.ErrInvalidAgent):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, service.ErrBuiltinAgent):
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
h.logger.Error("failed to update utility agent", zap.Error(err))
|
||||
c.JSON(status, gin.H{"error": "failed to update utility agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDeleteAgent(c *gin.Context) {
|
||||
if err := h.controller.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAgentNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, service.ErrBuiltinAgent):
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
h.logger.Error("failed to delete utility agent", zap.Error(err))
|
||||
c.JSON(status, gin.H{"error": "failed to delete utility agent"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *Handlers) httpGetTemplateVariables(c *gin.Context) {
|
||||
resp := h.controller.GetTemplateVariables(c.Request.Context())
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpExecutePrompt(c *gin.Context) {
|
||||
var req dto.ExecutePromptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, dto.ExecutePromptResponse{Error: "invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Get default utility settings from user settings
|
||||
var defaults *service.DefaultUtilitySettings
|
||||
if h.userSettings != nil {
|
||||
agentID, model, err := h.userSettings.GetDefaultUtilitySettings(ctx)
|
||||
if err == nil && (agentID != "" || model != "") {
|
||||
defaults = &service.DefaultUtilitySettings{AgentID: agentID, Model: model}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the prompt request (resolve template, get agent/model info)
|
||||
prepared, err := h.controller.PreparePromptRequest(ctx, req, defaults)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, service.ErrAgentNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
h.logger.Error("failed to prepare prompt", zap.Error(err))
|
||||
c.JSON(status, dto.ExecutePromptResponse{Error: "failed to prepare prompt"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create call record for tracking
|
||||
callID, err := h.controller.CreateCall(ctx, req.UtilityAgentID, req.SessionID, prepared.ResolvedPrompt, prepared.Model)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create call record", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{Error: "failed to create call record"})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute via agentctl using inference agent
|
||||
resp, err := h.executor.ExecuteInferencePrompt(ctx, req.SessionID, prepared.AgentCLI, prepared.Model, prepared.ResolvedPrompt)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to execute prompt", zap.Error(err), zap.String("call_id", callID))
|
||||
_ = h.controller.FailCall(ctx, callID, err.Error(), 0)
|
||||
c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{
|
||||
CallID: callID,
|
||||
Error: "failed to execute prompt: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
_ = h.controller.FailCall(ctx, callID, resp.Error, resp.DurationMs)
|
||||
c.JSON(http.StatusOK, dto.ExecutePromptResponse{
|
||||
CallID: callID,
|
||||
Error: resp.Error,
|
||||
DurationMs: resp.DurationMs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark call as completed
|
||||
if err := h.controller.CompleteCall(ctx, callID, resp.Response, resp.PromptTokens, resp.ResponseTokens, resp.DurationMs); err != nil {
|
||||
h.logger.Warn("failed to update call record", zap.Error(err), zap.String("call_id", callID))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.ExecutePromptResponse{
|
||||
Success: true,
|
||||
CallID: callID,
|
||||
Response: resp.Response,
|
||||
Model: resp.Model,
|
||||
PromptTokens: resp.PromptTokens,
|
||||
ResponseTokens: resp.ResponseTokens,
|
||||
DurationMs: resp.DurationMs,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) httpListCalls(c *gin.Context) {
|
||||
utilityID := c.Param("id")
|
||||
limit := 50
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.controller.ListCalls(c.Request.Context(), utilityID, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list calls", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list calls"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpListInferenceAgents(c *gin.Context) {
|
||||
inferenceAgents := h.executor.ListInferenceAgentsWithContext(c.Request.Context())
|
||||
|
||||
// Convert to DTO
|
||||
result := make([]dto.InferenceAgentDTO, 0, len(inferenceAgents))
|
||||
for _, ia := range inferenceAgents {
|
||||
models := make([]dto.InferenceModelDTO, 0, len(ia.Models))
|
||||
for _, m := range ia.Models {
|
||||
models = append(models, dto.InferenceModelDTO{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
IsDefault: m.IsDefault,
|
||||
})
|
||||
}
|
||||
result = append(result, dto.InferenceAgentDTO{
|
||||
ID: ia.ID,
|
||||
Name: ia.Name,
|
||||
DisplayName: ia.DisplayName,
|
||||
Models: models,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, dto.InferenceAgentsResponse{Agents: result})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UtilityAgent represents a configured utility agent for quick one-shot tasks.
|
||||
// It references an inference-capable agent (like claude-code, amp) by ID.
|
||||
type UtilityAgent struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Prompt string `json:"prompt" db:"prompt"`
|
||||
AgentID string `json:"agent_id" db:"agent_id"` // Reference to inference agent (e.g., "claude-code")
|
||||
Model string `json:"model" db:"model"` // Model to use (e.g., "claude-haiku-4-5")
|
||||
Builtin bool `json:"builtin" db:"builtin"` // Built-in agents cannot be deleted
|
||||
Enabled bool `json:"enabled" db:"enabled"` // Whether agent is enabled
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// UtilityAgentCall represents a single invocation of a utility agent.
|
||||
type UtilityAgentCall struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
UtilityID string `json:"utility_id" db:"utility_id"`
|
||||
SessionID string `json:"session_id" db:"session_id"`
|
||||
ResolvedPrompt string `json:"resolved_prompt" db:"resolved_prompt"`
|
||||
Response string `json:"response" db:"response"`
|
||||
Model string `json:"model" db:"model"`
|
||||
PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"`
|
||||
ResponseTokens int `json:"response_tokens" db:"response_tokens"`
|
||||
DurationMs int `json:"duration_ms" db:"duration_ms"`
|
||||
Status string `json:"status" db:"status"` // "pending", "completed", "failed"
|
||||
ErrorMessage string `json:"error_message" db:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/utility/models"
|
||||
"github.com/kandev/kandev/internal/utility/store"
|
||||
"github.com/kandev/kandev/internal/utility/template"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAgentNotFound = errors.New("utility agent not found")
|
||||
ErrInvalidAgent = errors.New("invalid utility agent")
|
||||
ErrCallNotFound = errors.New("utility agent call not found")
|
||||
ErrBuiltinAgent = errors.New("cannot modify built-in agent")
|
||||
)
|
||||
|
||||
// Service provides business logic for utility agents.
|
||||
type Service struct {
|
||||
repo store.Repository
|
||||
templateEngine *template.Engine
|
||||
}
|
||||
|
||||
// NewService creates a new utility agents service.
|
||||
func NewService(repo store.Repository) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
templateEngine: template.NewEngine(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListAgents returns all utility agents.
|
||||
func (s *Service) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) {
|
||||
return s.repo.ListAgents(ctx)
|
||||
}
|
||||
|
||||
// GetAgentByID returns a utility agent by ID.
|
||||
func (s *Service) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) {
|
||||
agent, err := s.repo.GetAgentByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// GetAgentByName returns a utility agent by name.
|
||||
func (s *Service) GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) {
|
||||
agent, err := s.repo.GetAgentByName(ctx, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// CreateAgent creates a new utility agent.
|
||||
func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, agentID, model string) (*models.UtilityAgent, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
model = strings.TrimSpace(model)
|
||||
|
||||
if name == "" || prompt == "" || agentID == "" || model == "" {
|
||||
return nil, ErrInvalidAgent
|
||||
}
|
||||
|
||||
agent := &models.UtilityAgent{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Prompt: prompt,
|
||||
AgentID: agentID,
|
||||
Model: model,
|
||||
Builtin: false,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateAgent(ctx, agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// UpdateAgent updates an existing utility agent.
|
||||
func (s *Service) UpdateAgent(ctx context.Context, id string, name, description, prompt, agentID, model *string, enabled *bool) (*models.UtilityAgent, error) {
|
||||
agent, err := s.repo.GetAgentByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
trimmed := strings.TrimSpace(*name)
|
||||
if trimmed == "" {
|
||||
return nil, ErrInvalidAgent
|
||||
}
|
||||
agent.Name = trimmed
|
||||
}
|
||||
if description != nil {
|
||||
agent.Description = strings.TrimSpace(*description)
|
||||
}
|
||||
if prompt != nil {
|
||||
trimmed := strings.TrimSpace(*prompt)
|
||||
if trimmed == "" {
|
||||
return nil, ErrInvalidAgent
|
||||
}
|
||||
agent.Prompt = trimmed
|
||||
}
|
||||
if agentID != nil {
|
||||
agent.AgentID = strings.TrimSpace(*agentID)
|
||||
}
|
||||
if model != nil {
|
||||
agent.Model = strings.TrimSpace(*model)
|
||||
}
|
||||
if enabled != nil {
|
||||
// Allow enabling even with empty agent_id/model - defaults can be used at execution time.
|
||||
// For custom (non-builtin) agents, we still require agent_id and model.
|
||||
if *enabled && !agent.Builtin && (agent.AgentID == "" || agent.Model == "") {
|
||||
return nil, ErrInvalidAgent
|
||||
}
|
||||
agent.Enabled = *enabled
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateAgent(ctx, agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// DeleteAgent deletes a utility agent.
|
||||
func (s *Service) DeleteAgent(ctx context.Context, id string) error {
|
||||
agent, err := s.repo.GetAgentByID(ctx, id)
|
||||
if err != nil {
|
||||
return ErrAgentNotFound
|
||||
}
|
||||
if agent.Builtin {
|
||||
return ErrBuiltinAgent
|
||||
}
|
||||
return s.repo.DeleteAgent(ctx, id)
|
||||
}
|
||||
|
||||
// ResolvePrompt resolves template variables in the agent's prompt.
|
||||
func (s *Service) ResolvePrompt(ctx context.Context, utilityID string, tmplCtx *template.Context) (string, error) {
|
||||
agent, err := s.repo.GetAgentByID(ctx, utilityID)
|
||||
if err != nil {
|
||||
return "", ErrAgentNotFound
|
||||
}
|
||||
return s.templateEngine.Resolve(agent.Prompt, tmplCtx)
|
||||
}
|
||||
|
||||
// GetAvailableVariables returns the list of available template variables.
|
||||
func (s *Service) GetAvailableVariables() []template.VariableInfo {
|
||||
return s.templateEngine.AvailableVariables()
|
||||
}
|
||||
|
||||
// DefaultUtilitySettings contains the user's default utility agent/model settings.
|
||||
type DefaultUtilitySettings struct {
|
||||
AgentID string
|
||||
Model string
|
||||
}
|
||||
|
||||
// PreparePromptRequest prepares a prompt request by resolving the template.
|
||||
// If the utility agent has empty AgentID/Model, the defaults are used.
|
||||
func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tmplCtx *template.Context, defaults *DefaultUtilitySettings) (*PromptRequest, error) {
|
||||
agent, err := s.repo.GetAgentByID(ctx, utilityID)
|
||||
if err != nil {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
|
||||
// Resolve template
|
||||
resolvedPrompt, err := s.templateEngine.Resolve(agent.Prompt, tmplCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use agent's configured values, fall back to defaults if empty
|
||||
agentCLI := agent.AgentID
|
||||
model := agent.Model
|
||||
if agentCLI == "" && defaults != nil {
|
||||
agentCLI = defaults.AgentID
|
||||
}
|
||||
if model == "" && defaults != nil {
|
||||
model = defaults.Model
|
||||
}
|
||||
|
||||
return &PromptRequest{
|
||||
UtilityID: utilityID,
|
||||
ResolvedPrompt: resolvedPrompt,
|
||||
AgentCLI: agentCLI,
|
||||
Model: model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PromptRequest contains the prepared request for executing a utility prompt.
|
||||
type PromptRequest struct {
|
||||
UtilityID string
|
||||
ResolvedPrompt string
|
||||
AgentCLI string // The inference agent ID (e.g., "claude-code", "amp")
|
||||
Model string // The model to use
|
||||
}
|
||||
|
||||
// CreateCall creates a new call record (for tracking history).
|
||||
func (s *Service) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string) (*models.UtilityAgentCall, error) {
|
||||
call := &models.UtilityAgentCall{
|
||||
UtilityID: utilityID,
|
||||
SessionID: sessionID,
|
||||
ResolvedPrompt: resolvedPrompt,
|
||||
Model: model,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.repo.CreateCall(ctx, call); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return call, nil
|
||||
}
|
||||
|
||||
// CompleteCall marks a call as completed with the response.
|
||||
func (s *Service) CompleteCall(ctx context.Context, callID, response string, promptTokens, responseTokens, durationMs int) error {
|
||||
call, err := s.repo.GetCallByID(ctx, callID)
|
||||
if err != nil {
|
||||
return ErrCallNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
call.Response = response
|
||||
call.PromptTokens = promptTokens
|
||||
call.ResponseTokens = responseTokens
|
||||
call.DurationMs = durationMs
|
||||
call.Status = "completed"
|
||||
call.CompletedAt = &now
|
||||
return s.repo.UpdateCall(ctx, call)
|
||||
}
|
||||
|
||||
// FailCall marks a call as failed with an error message.
|
||||
func (s *Service) FailCall(ctx context.Context, callID, errorMessage string, durationMs int) error {
|
||||
call, err := s.repo.GetCallByID(ctx, callID)
|
||||
if err != nil {
|
||||
return ErrCallNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
call.ErrorMessage = errorMessage
|
||||
call.DurationMs = durationMs
|
||||
call.Status = "failed"
|
||||
call.CompletedAt = &now
|
||||
return s.repo.UpdateCall(ctx, call)
|
||||
}
|
||||
|
||||
// ListCalls returns the call history for a utility agent.
|
||||
func (s *Service) ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error) {
|
||||
return s.repo.ListCalls(ctx, utilityID, limit)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/utility/models"
|
||||
)
|
||||
|
||||
// seedBuiltinAgents creates the default built-in utility agents if they don't exist.
|
||||
func (r *sqliteRepository) seedBuiltinAgents() error {
|
||||
builtinAgents := r.getBuiltinAgents()
|
||||
|
||||
for _, agent := range builtinAgents {
|
||||
// Check if agent already exists
|
||||
var exists bool
|
||||
err := r.db.QueryRow(r.db.Rebind("SELECT 1 FROM utility_agents WHERE id = ?"), agent.ID).Scan(&exists)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert built-in agent (disabled by default, user must configure agent/model)
|
||||
_, err = r.db.Exec(r.db.Rebind(`
|
||||
INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model,
|
||||
1, 0, agent.CreatedAt, agent.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert built-in agent %s: %w", agent.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBuiltinAgents returns the predefined built-in utility agents.
|
||||
func (r *sqliteRepository) getBuiltinAgents() []*models.UtilityAgent {
|
||||
now := time.Now().UTC()
|
||||
return []*models.UtilityAgent{
|
||||
r.builtinCommitMessage(now),
|
||||
r.builtinBranchName(now),
|
||||
r.builtinPRDescription(now),
|
||||
r.builtinEnhancePrompt(now),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinCommitMessage(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-commit-message",
|
||||
Name: "commit-message",
|
||||
Description: "Generate a commit message based on staged changes",
|
||||
AgentID: "", // User must configure
|
||||
Model: "", // User must configure
|
||||
Builtin: true,
|
||||
Enabled: false, // Disabled until user configures
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Prompt: `Generate a concise git commit message for the following changes.
|
||||
|
||||
## Staged Changes (git diff --staged):
|
||||
{{GitDiff}}
|
||||
|
||||
## Instructions:
|
||||
1. Follow the Conventional Commits format: <type>(<scope>): <description>
|
||||
2. Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
||||
3. Scope is optional but recommended (e.g., api, ui, db)
|
||||
4. Description should be imperative mood ("add" not "added")
|
||||
5. Keep the first line under 72 characters
|
||||
6. If needed, add a blank line and bullet points for details
|
||||
|
||||
## Output Format:
|
||||
Return ONLY the commit message, no explanations or markdown.`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinBranchName(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-branch-name",
|
||||
Name: "branch-name",
|
||||
Description: "Generate a branch name based on task description",
|
||||
AgentID: "", // User must configure
|
||||
Model: "", // User must configure
|
||||
Builtin: true,
|
||||
Enabled: false, // Disabled until user configures
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Prompt: `Generate a git branch name for the following task.
|
||||
|
||||
## Task Title:
|
||||
{{TaskTitle}}
|
||||
|
||||
## Task Description:
|
||||
{{TaskDescription}}
|
||||
|
||||
## Instructions:
|
||||
1. Use kebab-case (lowercase with hyphens)
|
||||
2. Start with a type prefix: feature/, fix/, refactor/, docs/, chore/
|
||||
3. Keep it concise but descriptive (max 50 characters total)
|
||||
4. Include relevant keywords from the task
|
||||
5. Avoid special characters except hyphens
|
||||
|
||||
## Output Format:
|
||||
Return ONLY the branch name, no explanations.
|
||||
Example: feature/add-user-authentication`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinPRDescription(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-pr-description",
|
||||
Name: "pr-description",
|
||||
Description: "Generate a PR description based on commits and changes",
|
||||
AgentID: "", // User must configure
|
||||
Model: "", // User must configure
|
||||
Builtin: true,
|
||||
Enabled: false, // Disabled until user configures
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Prompt: `Generate a Pull Request description based on the following information.
|
||||
|
||||
## Commits:
|
||||
{{CommitLog}}
|
||||
|
||||
## Changed Files:
|
||||
{{ChangedFiles}}
|
||||
|
||||
## Diff Summary:
|
||||
{{DiffSummary}}
|
||||
|
||||
## Instructions:
|
||||
1. Start with a clear title summarizing the PR
|
||||
2. Include a "## Summary" section explaining what this PR does
|
||||
3. Add a "## Changes" section with bullet points of key changes
|
||||
4. Include a "## Testing" section describing how to test
|
||||
5. If applicable, mention breaking changes or migration steps
|
||||
6. Keep it professional and concise
|
||||
|
||||
## Output Format:
|
||||
Return ONLY the PR description in Markdown format. And nothing else.`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinEnhancePrompt(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-enhance-prompt",
|
||||
Name: "enhance-prompt",
|
||||
Description: "Enhance and expand a user prompt with context and clarity",
|
||||
AgentID: "", // User must configure
|
||||
Model: "", // User must configure
|
||||
Builtin: true,
|
||||
Enabled: false, // Disabled until user configures
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Prompt: `Enhance the following user prompt to be clearer and more actionable for a coding assistant.
|
||||
|
||||
## User's Original Prompt:
|
||||
{{UserPrompt}}
|
||||
|
||||
## Task Context:
|
||||
Title: {{TaskTitle}}
|
||||
Description: {{TaskDescription}}
|
||||
|
||||
## Current Git Changes (if any):
|
||||
{{GitDiff}}
|
||||
|
||||
## Instructions:
|
||||
1. Expand vague requests into specific, actionable instructions
|
||||
2. Add relevant context from the task and git changes
|
||||
3. Clarify ambiguous terms or requirements
|
||||
4. Structure the prompt clearly with numbered steps if appropriate
|
||||
5. Keep the original intent - don't change what the user wants to do
|
||||
6. Be concise - don't add unnecessary verbosity
|
||||
|
||||
## Output Format:
|
||||
Return ONLY the enhanced prompt text, ready to be sent to the coding assistant.
|
||||
Do not include explanations or meta-commentary around the prompt.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/utility/models"
|
||||
)
|
||||
|
||||
// Repository defines the interface for utility agent storage.
|
||||
type Repository interface {
|
||||
// Agents
|
||||
ListAgents(ctx context.Context) ([]*models.UtilityAgent, error)
|
||||
GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error)
|
||||
GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error)
|
||||
CreateAgent(ctx context.Context, agent *models.UtilityAgent) error
|
||||
UpdateAgent(ctx context.Context, agent *models.UtilityAgent) error
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
|
||||
// Calls (history)
|
||||
ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error)
|
||||
GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error)
|
||||
CreateCall(ctx context.Context, call *models.UtilityAgentCall) error
|
||||
UpdateCall(ctx context.Context, call *models.UtilityAgentCall) error
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package store
|
||||
|
||||
import "github.com/jmoiron/sqlx"
|
||||
|
||||
// Provide creates the SQLite utility agents store using separate writer and reader pools.
|
||||
func Provide(writer, reader *sqlx.DB) (*sqliteRepository, func() error, error) {
|
||||
repo, err := newSQLiteRepositoryWithDB(writer, reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return repo, repo.Close, nil
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/kandev/kandev/internal/utility/models"
|
||||
)
|
||||
|
||||
type sqliteRepository struct {
|
||||
db *sqlx.DB // writer
|
||||
ro *sqlx.DB // reader
|
||||
ownsDB bool
|
||||
}
|
||||
|
||||
var _ Repository = (*sqliteRepository)(nil)
|
||||
|
||||
func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) {
|
||||
return newSQLiteRepository(writer, reader, false)
|
||||
}
|
||||
|
||||
func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) {
|
||||
repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB}
|
||||
if err := repo.initSchema(); err != nil {
|
||||
if ownsDB {
|
||||
if closeErr := writer.Close(); closeErr != nil {
|
||||
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) initSchema() error {
|
||||
// New simplified schema for utility agents
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS utility_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
prompt TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL DEFAULT 'claude-code',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
builtin INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS utility_agent_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
utility_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
resolved_prompt TEXT NOT NULL DEFAULT '',
|
||||
response TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
response_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
FOREIGN KEY (utility_id) REFERENCES utility_agents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_utility_calls_utility_id ON utility_agent_calls(utility_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_utility_calls_session_id ON utility_agent_calls(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_utility_calls_created_at ON utility_agent_calls(created_at);
|
||||
`
|
||||
if _, err := r.db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add enabled column if it doesn't exist (migration for existing DBs)
|
||||
_, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`)
|
||||
|
||||
// Seed built-in agents
|
||||
if err := r.seedBuiltinAgents(); err != nil {
|
||||
return fmt.Errorf("failed to seed built-in agents: %w", err)
|
||||
}
|
||||
|
||||
// Migration: fix template placeholders from {{.Key}} to {{Key}} format
|
||||
if err := r.migrateTemplatePlaceholders(); err != nil {
|
||||
return fmt.Errorf("failed to migrate template placeholders: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateTemplatePlaceholders fixes old {{.Key}} format to {{Key}} format in builtin prompts.
|
||||
func (r *sqliteRepository) migrateTemplatePlaceholders() error {
|
||||
placeholders := []string{
|
||||
"GitDiff", "CommitLog", "ChangedFiles", "DiffSummary", "BranchName", "BaseBranch",
|
||||
"TaskTitle", "TaskDescription", "SessionID", "WorkspacePath", "UserPrompt",
|
||||
}
|
||||
for _, p := range placeholders {
|
||||
old := "{{." + p + "}}"
|
||||
new := "{{" + p + "}}"
|
||||
_, _ = r.db.Exec(r.db.Rebind(`UPDATE utility_agents SET prompt = REPLACE(prompt, ?, ?) WHERE builtin = 1`), old, new)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) Close() error {
|
||||
if !r.ownsDB {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
if err := r.db.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if r.ro != r.db {
|
||||
if err := r.ro.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) {
|
||||
rows, err := r.ro.QueryContext(ctx, `
|
||||
SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at
|
||||
FROM utility_agents
|
||||
ORDER BY builtin DESC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanAgentRows(rows)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) scanAgentRows(rows *sql.Rows) ([]*models.UtilityAgent, error) {
|
||||
var agents []*models.UtilityAgent
|
||||
for rows.Next() {
|
||||
agent := &models.UtilityAgent{}
|
||||
var builtinInt, enabledInt int
|
||||
if err := rows.Scan(
|
||||
&agent.ID, &agent.Name, &agent.Description, &agent.Prompt,
|
||||
&agent.AgentID, &agent.Model, &builtinInt, &enabledInt,
|
||||
&agent.CreatedAt, &agent.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agent.Builtin = builtinInt == 1
|
||||
agent.Enabled = enabledInt == 1
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
return agents, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) {
|
||||
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
||||
SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at
|
||||
FROM utility_agents WHERE id = ?
|
||||
`), id)
|
||||
return r.scanAgentRow(row)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) {
|
||||
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
||||
SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at
|
||||
FROM utility_agents WHERE name = ?
|
||||
`), name)
|
||||
return r.scanAgentRow(row)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) scanAgentRow(row *sql.Row) (*models.UtilityAgent, error) {
|
||||
agent := &models.UtilityAgent{}
|
||||
var builtinInt, enabledInt int
|
||||
if err := row.Scan(
|
||||
&agent.ID, &agent.Name, &agent.Description, &agent.Prompt,
|
||||
&agent.AgentID, &agent.Model, &builtinInt, &enabledInt,
|
||||
&agent.CreatedAt, &agent.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agent.Builtin = builtinInt == 1
|
||||
agent.Enabled = enabledInt == 1
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.UtilityAgent) error {
|
||||
if agent.ID == "" {
|
||||
agent.ID = uuid.New().String()
|
||||
}
|
||||
agent.Name = strings.TrimSpace(agent.Name)
|
||||
agent.Description = strings.TrimSpace(agent.Description)
|
||||
agent.Prompt = strings.TrimSpace(agent.Prompt)
|
||||
if agent.CreatedAt.IsZero() {
|
||||
agent.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
agent.UpdatedAt = time.Now().UTC()
|
||||
|
||||
builtinInt := 0
|
||||
if agent.Builtin {
|
||||
builtinInt = 1
|
||||
}
|
||||
enabledInt := 0
|
||||
if agent.Enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model,
|
||||
builtinInt, enabledInt, agent.CreatedAt, agent.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.UtilityAgent) error {
|
||||
if agent == nil {
|
||||
return errors.New("agent is nil")
|
||||
}
|
||||
agent.Name = strings.TrimSpace(agent.Name)
|
||||
agent.Description = strings.TrimSpace(agent.Description)
|
||||
agent.Prompt = strings.TrimSpace(agent.Prompt)
|
||||
agent.UpdatedAt = time.Now().UTC()
|
||||
|
||||
enabledInt := 0
|
||||
if agent.Enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE utility_agents
|
||||
SET name = ?, description = ?, prompt = ?, agent_id = ?, model = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`), agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, enabledInt, agent.UpdatedAt, agent.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM utility_agents WHERE id = ? AND builtin = 0`), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
|
||||
SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at
|
||||
FROM utility_agent_calls
|
||||
WHERE utility_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`), utilityID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanCallRows(rows)
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentCall, error) {
|
||||
var calls []*models.UtilityAgentCall
|
||||
for rows.Next() {
|
||||
call := &models.UtilityAgentCall{}
|
||||
if err := rows.Scan(
|
||||
&call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response,
|
||||
&call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs,
|
||||
&call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
calls = append(calls, call)
|
||||
}
|
||||
return calls, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error) {
|
||||
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
|
||||
SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at
|
||||
FROM utility_agent_calls WHERE id = ?
|
||||
`), id)
|
||||
call := &models.UtilityAgentCall{}
|
||||
if err := row.Scan(
|
||||
&call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response,
|
||||
&call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs,
|
||||
&call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) CreateCall(ctx context.Context, call *models.UtilityAgentCall) error {
|
||||
if call.ID == "" {
|
||||
call.ID = uuid.New().String()
|
||||
}
|
||||
if call.CreatedAt.IsZero() {
|
||||
call.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if call.Status == "" {
|
||||
call.Status = "pending"
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), call.ID, call.UtilityID, call.SessionID, call.ResolvedPrompt, call.Response,
|
||||
call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs,
|
||||
call.Status, call.ErrorMessage, call.CreatedAt, call.CompletedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) UpdateCall(ctx context.Context, call *models.UtilityAgentCall) error {
|
||||
if call == nil {
|
||||
return errors.New("call is nil")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE utility_agent_calls
|
||||
SET response = ?, model = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
`), call.Response, call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs,
|
||||
call.Status, call.ErrorMessage, call.CompletedAt, call.ID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Context holds the data available for template resolution.
|
||||
type Context struct {
|
||||
// Git-related
|
||||
GitDiff string // Output of git diff --staged or git diff
|
||||
CommitLog string // Output of git log for the branch
|
||||
ChangedFiles string // List of changed files
|
||||
DiffSummary string // Summary of changes (insertions, deletions)
|
||||
BranchName string // Current branch name
|
||||
BaseBranch string // Base branch (main/master)
|
||||
|
||||
// Task-related
|
||||
TaskTitle string // Title of the current task
|
||||
TaskDescription string // Description of the current task
|
||||
|
||||
// Session-related
|
||||
SessionID string // Current session ID
|
||||
WorkspacePath string // Path to the workspace/worktree
|
||||
|
||||
// User input
|
||||
UserPrompt string // User's original prompt text (for enhance-prompt)
|
||||
|
||||
// Custom key-value pairs for extensibility
|
||||
Custom map[string]string
|
||||
}
|
||||
|
||||
// Engine resolves template variables in prompts.
|
||||
type Engine struct{}
|
||||
|
||||
// NewEngine creates a new template engine.
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{}
|
||||
}
|
||||
|
||||
// templatePattern matches {{Key}} placeholders.
|
||||
var templatePattern = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`)
|
||||
|
||||
// Resolve resolves template variables in the given prompt using the provided context.
|
||||
// Uses {{Key}} format (no dot) for consistency with scriptengine.
|
||||
func (e *Engine) Resolve(promptTemplate string, ctx *Context) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = &Context{}
|
||||
}
|
||||
data := e.buildTemplateData(ctx)
|
||||
|
||||
// Use regex replacement for deterministic single-pass substitution
|
||||
result := templatePattern.ReplaceAllStringFunc(promptTemplate, func(match string) string {
|
||||
// Extract key from {{Key}}
|
||||
key := strings.TrimSuffix(strings.TrimPrefix(match, "{{"), "}}")
|
||||
if val, ok := data[key]; ok {
|
||||
return val
|
||||
}
|
||||
return match // Leave unknown placeholders unchanged
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildTemplateData creates the data map for template execution.
|
||||
func (e *Engine) buildTemplateData(ctx *Context) map[string]string {
|
||||
data := map[string]string{
|
||||
"GitDiff": ctx.GitDiff,
|
||||
"CommitLog": ctx.CommitLog,
|
||||
"ChangedFiles": ctx.ChangedFiles,
|
||||
"DiffSummary": ctx.DiffSummary,
|
||||
"BranchName": ctx.BranchName,
|
||||
"BaseBranch": ctx.BaseBranch,
|
||||
"TaskTitle": ctx.TaskTitle,
|
||||
"TaskDescription": ctx.TaskDescription,
|
||||
"SessionID": ctx.SessionID,
|
||||
"WorkspacePath": ctx.WorkspacePath,
|
||||
"UserPrompt": ctx.UserPrompt,
|
||||
}
|
||||
|
||||
// Add custom key-value pairs
|
||||
for k, v := range ctx.Custom {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// AvailableVariables returns the list of available template variables.
|
||||
func (e *Engine) AvailableVariables() []VariableInfo {
|
||||
return []VariableInfo{
|
||||
{Name: "GitDiff", Description: "Output of git diff --staged or git diff", Example: "diff --git a/main.go...", Category: "git"},
|
||||
{Name: "CommitLog", Description: "Git log for the current branch", Example: "abc123 Fix bug in handler", Category: "git"},
|
||||
{Name: "ChangedFiles", Description: "List of changed files", Example: "src/main.go\nsrc/utils.go", Category: "git"},
|
||||
{Name: "DiffSummary", Description: "Summary of changes (files, insertions, deletions)", Example: "3 files, +50 -10", Category: "git"},
|
||||
{Name: "BranchName", Description: "Current git branch name", Example: "feature/add-login", Category: "git"},
|
||||
{Name: "BaseBranch", Description: "Base branch (main/master/develop)", Example: "main", Category: "git"},
|
||||
{Name: "TaskTitle", Description: "Title of the current task", Example: "Add user authentication", Category: "task"},
|
||||
{Name: "TaskDescription", Description: "Description of the current task", Example: "Implement OAuth2 login flow", Category: "task"},
|
||||
{Name: "SessionID", Description: "Current session ID", Example: "sess_abc123", Category: "session"},
|
||||
{Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"},
|
||||
{Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"},
|
||||
}
|
||||
}
|
||||
|
||||
// VariableInfo describes a template variable.
|
||||
type VariableInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Example string `json:"example"`
|
||||
Category string `json:"category"` // "git", "task", "session"
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import {
|
||||
listUtilityAgents,
|
||||
listInferenceAgents,
|
||||
updateUtilityAgent,
|
||||
deleteUtilityAgent,
|
||||
type UtilityAgent,
|
||||
type InferenceAgent,
|
||||
} from "@/lib/api/domains/utility-api";
|
||||
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import { UtilityAgentDialog } from "@/components/settings/utility-agent-dialog";
|
||||
import {
|
||||
DefaultModelSection,
|
||||
PerActionOverridesSection,
|
||||
CustomAgentsSection,
|
||||
USE_DEFAULT,
|
||||
} from "@/components/settings/utility-sections";
|
||||
|
||||
function buildAllModels(inferenceAgents: InferenceAgent[]) {
|
||||
return inferenceAgents.flatMap((ia) =>
|
||||
ia.models.map((m) => ({
|
||||
value: `${ia.id}|${m.id}`,
|
||||
label: `${ia.display_name} / ${m.name}`,
|
||||
agentName: ia.display_name,
|
||||
modelName: m.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleBuiltinChange(
|
||||
agent: UtilityAgent,
|
||||
value: string,
|
||||
setAgents: React.Dispatch<React.SetStateAction<UtilityAgent[]>>,
|
||||
) {
|
||||
const isDefault = value === USE_DEFAULT;
|
||||
const [agentId, model] = isDefault ? ["", ""] : value.split("|");
|
||||
await updateUtilityAgent(agent.id, { agent_id: agentId, model, enabled: true });
|
||||
setAgents((prev) =>
|
||||
prev.map((a) => (a.id === agent.id ? { ...a, agent_id: agentId, model } : a)),
|
||||
);
|
||||
}
|
||||
|
||||
export default function UtilityAgentsSettingsPage() {
|
||||
const [agents, setAgents] = useState<UtilityAgent[]>([]);
|
||||
const [inferenceAgents, setInferenceAgents] = useState<InferenceAgent[]>([]);
|
||||
const [defaultAgentId, setDefaultAgentId] = useState("");
|
||||
const [defaultModel, setDefaultModel] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<UtilityAgent | null>(null);
|
||||
|
||||
const builtins = useMemo(() => agents.filter((a) => a.builtin), [agents]);
|
||||
const customAgents = useMemo(() => agents.filter((a) => !a.builtin), [agents]);
|
||||
const allModels = useMemo(() => buildAllModels(inferenceAgents), [inferenceAgents]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [agentsRes, inferenceRes, settingsRes] = await Promise.all([
|
||||
listUtilityAgents({ cache: "no-store" }),
|
||||
listInferenceAgents(),
|
||||
fetchUserSettings({ cache: "no-store" }),
|
||||
]);
|
||||
setAgents(agentsRes.agents);
|
||||
setInferenceAgents(inferenceRes.agents);
|
||||
setDefaultAgentId(settingsRes.settings.default_utility_agent_id || "");
|
||||
setDefaultModel(settingsRes.settings.default_utility_model || "");
|
||||
} catch {
|
||||
setAgents([]);
|
||||
setInferenceAgents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleDefaultChange = async (agentId: string, model: string) => {
|
||||
setDefaultAgentId(agentId);
|
||||
setDefaultModel(model);
|
||||
await updateUserSettings({ default_utility_agent_id: agentId, default_utility_model: model });
|
||||
};
|
||||
|
||||
const openEditDialog = (agent: UtilityAgent | null) => {
|
||||
setEditingAgent(agent);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingAgent(null);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return <div className="py-8 text-center text-muted-foreground text-sm">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h2 className="text-xl font-semibold">Utility Agents</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
One-shot AI helpers for commits, PRs, and prompts.
|
||||
</p>
|
||||
</header>
|
||||
<Separator />
|
||||
<DefaultModelSection
|
||||
inferenceAgents={inferenceAgents}
|
||||
defaultAgentId={defaultAgentId}
|
||||
defaultModel={defaultModel}
|
||||
onDefaultChange={handleDefaultChange}
|
||||
/>
|
||||
<PerActionOverridesSection
|
||||
builtins={builtins}
|
||||
allModels={allModels}
|
||||
defaultModel={defaultModel}
|
||||
onModelChange={(agent, value) => handleBuiltinChange(agent, value, setAgents)}
|
||||
onEdit={openEditDialog}
|
||||
/>
|
||||
<CustomAgentsSection
|
||||
agents={customAgents}
|
||||
onAdd={() => openEditDialog(null)}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={async (agent) => {
|
||||
await deleteUtilityAgent(agent.id);
|
||||
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
|
||||
}}
|
||||
/>
|
||||
<UtilityAgentDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
agent={editingAgent}
|
||||
onSuccess={closeDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconMessageCircle,
|
||||
IconBrandGithub,
|
||||
IconSparkles,
|
||||
IconWand,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -298,6 +299,16 @@ export function SettingsAppSidebar() {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Utility Agents */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === "/settings/utility"}>
|
||||
<Link href="/settings/utility">
|
||||
<IconWand className="h-4 w-4" />
|
||||
<span>Utility Agents</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<ExecutorsSidebarSection pathname={pathname} executors={executors} />
|
||||
<SecretsSidebarSection pathname={pathname} />
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import {
|
||||
type UtilityAgent,
|
||||
createUtilityAgent,
|
||||
updateUtilityAgent,
|
||||
getTemplateVariables,
|
||||
listInferenceAgents,
|
||||
type TemplateVariable,
|
||||
type InferenceAgent,
|
||||
type InferenceModel,
|
||||
} from "@/lib/api/domains/utility-api";
|
||||
import { ScriptEditor } from "./profile-edit/script-editor";
|
||||
import type { ScriptPlaceholder } from "./profile-edit/script-editor-completions";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agent: UtilityAgent | null;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
agent_id: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const defaultFormState: FormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
prompt: "",
|
||||
agent_id: "claude-code",
|
||||
model: "",
|
||||
};
|
||||
|
||||
function toScriptPlaceholders(variables: TemplateVariable[]): ScriptPlaceholder[] {
|
||||
return variables.map((v) => ({
|
||||
key: v.name,
|
||||
description: v.description,
|
||||
example: v.example,
|
||||
executor_types: [],
|
||||
}));
|
||||
}
|
||||
|
||||
type AgentModelSelectProps = {
|
||||
agentId: string;
|
||||
model: string;
|
||||
inferenceAgents: InferenceAgent[];
|
||||
availableModels: InferenceModel[];
|
||||
onAgentChange: (agentId: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
};
|
||||
|
||||
function AgentModelSelect({
|
||||
agentId,
|
||||
model,
|
||||
inferenceAgents,
|
||||
availableModels,
|
||||
onAgentChange,
|
||||
onModelChange,
|
||||
}: AgentModelSelectProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Agent</Label>
|
||||
<Select value={agentId} onValueChange={onAgentChange}>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select agent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inferenceAgents.map((ia) => (
|
||||
<SelectItem key={ia.id} value={ia.id}>
|
||||
{ia.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Select value={model} onValueChange={onModelChange} disabled={availableModels.length === 0}>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select model..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UtilityAgentFormProps = {
|
||||
form: FormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
||||
isBuiltin: boolean;
|
||||
inferenceAgents: InferenceAgent[];
|
||||
availableModels: InferenceModel[];
|
||||
placeholders: ScriptPlaceholder[];
|
||||
};
|
||||
|
||||
function UtilityAgentForm({
|
||||
form,
|
||||
setForm,
|
||||
isBuiltin,
|
||||
inferenceAgents,
|
||||
availableModels,
|
||||
placeholders,
|
||||
}: UtilityAgentFormProps) {
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g., commit-message"
|
||||
disabled={isBuiltin}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Brief description of what this agent does"
|
||||
/>
|
||||
</div>
|
||||
<AgentModelSelect
|
||||
agentId={form.agent_id}
|
||||
model={form.model}
|
||||
inferenceAgents={inferenceAgents}
|
||||
availableModels={availableModels}
|
||||
onAgentChange={(v) => setForm((f) => ({ ...f, agent_id: v, model: "" }))}
|
||||
onModelChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>Prompt Template</Label>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<ScriptEditor
|
||||
value={form.prompt}
|
||||
onChange={(v) => setForm((f) => ({ ...f, prompt: v }))}
|
||||
language="plaintext"
|
||||
height="200px"
|
||||
placeholders={placeholders}
|
||||
lineNumbers="off"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type {"{{"} to see available variables with autocomplete
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Props) {
|
||||
const [form, setForm] = useState<FormState>(defaultFormState);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
|
||||
const [inferenceAgents, setInferenceAgents] = useState<InferenceAgent[]>([]);
|
||||
const isEdit = Boolean(agent);
|
||||
|
||||
// Fetch template variables and inference agents
|
||||
useEffect(() => {
|
||||
getTemplateVariables()
|
||||
.then(({ variables }) => setPlaceholders(toScriptPlaceholders(variables)))
|
||||
.catch(() => setPlaceholders([]));
|
||||
|
||||
listInferenceAgents()
|
||||
.then(({ agents }) => setInferenceAgents(agents))
|
||||
.catch(() => setInferenceAgents([]));
|
||||
}, []);
|
||||
|
||||
// Get models for the selected agent
|
||||
const selectedAgent = useMemo(
|
||||
() => inferenceAgents.find((a) => a.id === form.agent_id),
|
||||
[inferenceAgents, form.agent_id],
|
||||
);
|
||||
|
||||
const availableModels = selectedAgent?.models ?? [];
|
||||
|
||||
// Auto-select default model when agent changes
|
||||
useEffect(() => {
|
||||
if (selectedAgent && !form.model) {
|
||||
const defaultModel = selectedAgent.models.find((m) => m.is_default);
|
||||
if (defaultModel) {
|
||||
setForm((f) => ({ ...f, model: defaultModel.id }));
|
||||
}
|
||||
}
|
||||
}, [selectedAgent, form.model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
setForm({
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
prompt: agent.prompt,
|
||||
agent_id: agent.agent_id || "claude-code",
|
||||
model: agent.model || "",
|
||||
});
|
||||
} else {
|
||||
setForm(defaultFormState);
|
||||
}
|
||||
}, [agent, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
prompt: form.prompt,
|
||||
agent_id: form.agent_id,
|
||||
model: form.model,
|
||||
};
|
||||
|
||||
if (isEdit && agent) {
|
||||
await updateUtilityAgent(agent.id, data);
|
||||
} else {
|
||||
await createUtilityAgent(data);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to save agent:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = isEdit ? "Edit Utility Agent" : "Create Utility Agent";
|
||||
const getSubmitLabel = () => {
|
||||
if (saving) return "Saving...";
|
||||
return isEdit ? "Save Changes" : "Create Agent";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UtilityAgentForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isBuiltin={agent?.builtin ?? false}
|
||||
inferenceAgents={inferenceAgents}
|
||||
availableModels={availableModels}
|
||||
placeholders={placeholders}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || !form.name} className="cursor-pointer">
|
||||
{getSubmitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import type { UtilityAgent, InferenceAgent } from "@/lib/api/domains/utility-api";
|
||||
|
||||
const USE_DEFAULT = "__USE_DEFAULT__";
|
||||
|
||||
export type ModelOption = { value: string; label: string; agentName: string; modelName: string };
|
||||
|
||||
// Default model selector section
|
||||
type DefaultModelSectionProps = {
|
||||
inferenceAgents: InferenceAgent[];
|
||||
defaultAgentId: string;
|
||||
defaultModel: string;
|
||||
onDefaultChange: (agentId: string, model: string) => void;
|
||||
};
|
||||
|
||||
export function DefaultModelSection({
|
||||
inferenceAgents,
|
||||
defaultAgentId,
|
||||
defaultModel,
|
||||
onDefaultChange,
|
||||
}: DefaultModelSectionProps) {
|
||||
const selectedAgent = inferenceAgents.find((a) => a.id === defaultAgentId);
|
||||
const modelOptions = selectedAgent?.models ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">Default utility agent model</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the default model used by all built-in utility actions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-[180px]">
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Agent</Label>
|
||||
<Select value={defaultAgentId} onValueChange={(v) => onDefaultChange(v, "")}>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select agent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inferenceAgents.map((ia) => (
|
||||
<SelectItem key={ia.id} value={ia.id}>
|
||||
{ia.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-[180px]">
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Model</Label>
|
||||
<Select
|
||||
value={defaultModel}
|
||||
onValueChange={(v) => onDefaultChange(defaultAgentId, v)}
|
||||
disabled={!defaultAgentId}
|
||||
>
|
||||
<SelectTrigger className="cursor-pointer">
|
||||
<SelectValue placeholder="Select model..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Builtin action row
|
||||
type BuiltinActionRowProps = {
|
||||
agent: UtilityAgent;
|
||||
allModels: ModelOption[];
|
||||
defaultLabel: string;
|
||||
onModelChange: (agent: UtilityAgent, value: string) => void;
|
||||
onEdit: (agent: UtilityAgent) => void;
|
||||
};
|
||||
|
||||
export function BuiltinActionRow({
|
||||
agent,
|
||||
allModels,
|
||||
defaultLabel,
|
||||
onModelChange,
|
||||
onEdit,
|
||||
}: BuiltinActionRowProps) {
|
||||
const currentValue =
|
||||
agent.agent_id && agent.model ? `${agent.agent_id}|${agent.model}` : USE_DEFAULT;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 py-2 px-2 rounded hover:bg-muted/50 group">
|
||||
<div className="w-[420px] shrink-0 cursor-pointer" onClick={() => onEdit(agent)}>
|
||||
<div className="text-sm font-medium">{agent.name}</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
|
||||
</div>
|
||||
<Select value={currentValue} onValueChange={(v) => onModelChange(agent, v)}>
|
||||
<SelectTrigger className="w-[240px] cursor-pointer">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={USE_DEFAULT}>{defaultLabel}</SelectItem>
|
||||
{allModels.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.agentName} / {m.modelName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom agent row
|
||||
type CustomAgentRowProps = {
|
||||
agent: UtilityAgent;
|
||||
onEdit: (agent: UtilityAgent) => void;
|
||||
onDelete: (agent: UtilityAgent) => void;
|
||||
};
|
||||
|
||||
export function CustomAgentRow({ agent, onEdit, onDelete }: CustomAgentRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-3 rounded hover:bg-muted/50 group">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{agent.name}</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{agent.model || "Not configured"}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(agent)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(agent)}
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive cursor-pointer"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-action overrides section
|
||||
type PerActionOverridesSectionProps = {
|
||||
builtins: UtilityAgent[];
|
||||
allModels: ModelOption[];
|
||||
defaultModel: string;
|
||||
onModelChange: (agent: UtilityAgent, value: string) => void;
|
||||
onEdit: (agent: UtilityAgent) => void;
|
||||
};
|
||||
|
||||
export function PerActionOverridesSection({
|
||||
builtins,
|
||||
allModels,
|
||||
defaultModel,
|
||||
onModelChange,
|
||||
onEdit,
|
||||
}: PerActionOverridesSectionProps) {
|
||||
if (builtins.length === 0) return null;
|
||||
|
||||
const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Separator />
|
||||
<h3 className="text-base font-medium pt-2">Actions</h3>
|
||||
<div className="space-y-0">
|
||||
{builtins.map((agent) => (
|
||||
<BuiltinActionRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
allModels={allModels}
|
||||
defaultLabel={defaultLabel}
|
||||
onModelChange={onModelChange}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom agents section
|
||||
type CustomAgentsSectionProps = {
|
||||
agents: UtilityAgent[];
|
||||
onAdd: () => void;
|
||||
onEdit: (agent: UtilityAgent) => void;
|
||||
onDelete: (agent: UtilityAgent) => void;
|
||||
};
|
||||
|
||||
export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">Custom utility agents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your own utility agents with custom prompts.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onAdd} size="sm" className="cursor-pointer">
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{agents.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4">No custom utility agents.</p>
|
||||
)}
|
||||
{agents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<CustomAgentRow key={agent.id} agent={agent} onEdit={onEdit} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the USE_DEFAULT constant
|
||||
export { USE_DEFAULT };
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
|
||||
// --- Discard Confirmation Dialog ---
|
||||
@@ -104,12 +105,10 @@ export function CommitDialog({
|
||||
stagedAdditions={stagedAdditions}
|
||||
stagedDeletions={stagedDeletions}
|
||||
/>
|
||||
<Textarea
|
||||
<Input
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -234,7 +233,7 @@ export function PRDialog({
|
||||
value={prBody}
|
||||
onChange={(e) => onPrBodyChange(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
className="resize-none max-h-[200px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -45,6 +45,8 @@ export type ChatInputEditorAreaProps = {
|
||||
setContextPopoverOpen: (open: boolean) => void;
|
||||
contextFiles: ContextFile[];
|
||||
onImplementPlan?: () => void;
|
||||
onEnhancePrompt?: () => void;
|
||||
isEnhancingPrompt?: boolean;
|
||||
};
|
||||
|
||||
export function ChatInputEditorArea({
|
||||
@@ -79,17 +81,27 @@ export function ChatInputEditorArea({
|
||||
setContextPopoverOpen,
|
||||
contextFiles,
|
||||
onImplementPlan,
|
||||
onEnhancePrompt,
|
||||
isEnhancingPrompt,
|
||||
}: ChatInputEditorAreaProps) {
|
||||
// Block submit while enhancing prompt, but keep editor editable for programmatic updates
|
||||
const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<Tooltip open={showRequestChangesTooltip}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex-1 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 transition-opacity",
|
||||
isEnhancingPrompt && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<TipTapInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmitWithReset}
|
||||
onSubmit={wrappedSubmit}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={isDisabled || hasClarification}
|
||||
planModeEnabled={planModeEnabled}
|
||||
@@ -124,7 +136,7 @@ export function ChatInputEditorArea({
|
||||
isDisabled={isDisabled}
|
||||
isSending={isSending}
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmitWithReset}
|
||||
onSubmit={wrappedSubmit}
|
||||
submitKey={submitKey}
|
||||
contextCount={contextCount}
|
||||
contextPopoverOpen={contextPopoverOpen}
|
||||
@@ -133,6 +145,8 @@ export function ChatInputEditorArea({
|
||||
contextFiles={contextFiles}
|
||||
onToggleFile={onToggleContextFile}
|
||||
onImplementPlan={onImplementPlan}
|
||||
onEnhancePrompt={onEnhancePrompt}
|
||||
isEnhancingPrompt={isEnhancingPrompt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { IconAlertTriangle, IconPlus } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ChatInputEditorAreaProps,
|
||||
} from "./chat-input-body";
|
||||
import type { ContextItem } from "@/lib/types/context";
|
||||
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
|
||||
|
||||
// Re-export ImageAttachment type for consumers
|
||||
export type { ImageAttachment } from "./image-attachment-preview";
|
||||
@@ -137,9 +138,15 @@ function buildContextAreaProps(
|
||||
};
|
||||
}
|
||||
|
||||
type EnhancePromptExtras = {
|
||||
onEnhancePrompt?: () => void;
|
||||
isEnhancingPrompt?: boolean;
|
||||
};
|
||||
|
||||
function buildEditorAreaProps(
|
||||
s: ContainerState,
|
||||
p: ChatInputContainerProps,
|
||||
extras: EnhancePromptExtras = {},
|
||||
): ChatInputEditorAreaProps {
|
||||
return {
|
||||
inputRef: s.inputRef,
|
||||
@@ -173,6 +180,8 @@ function buildEditorAreaProps(
|
||||
setContextPopoverOpen: s.setContextPopoverOpen,
|
||||
contextFiles: p.contextFiles ?? [],
|
||||
onImplementPlan: p.onImplementPlan,
|
||||
onEnhancePrompt: extras.onEnhancePrompt,
|
||||
isEnhancingPrompt: extras.isEnhancingPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,6 +232,21 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
onSubmit: props.onSubmit,
|
||||
});
|
||||
|
||||
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
|
||||
sessionId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
});
|
||||
|
||||
const handleEnhancePrompt = useCallback(() => {
|
||||
const currentValue = s.value?.trim();
|
||||
if (!currentValue) return;
|
||||
enhancePrompt(currentValue, (enhanced) => {
|
||||
// Use setValue to directly update TipTap editor (handleChange only updates React state)
|
||||
s.inputRef.current?.setValue(enhanced);
|
||||
});
|
||||
}, [s, enhancePrompt]);
|
||||
|
||||
if (p.isFailed) {
|
||||
return (
|
||||
<FailedSessionBanner
|
||||
@@ -249,7 +273,10 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
planModeEnabled={props.planModeEnabled}
|
||||
showFocusHint={s.showFocusHint}
|
||||
contextAreaProps={buildContextAreaProps(s, p)}
|
||||
editorAreaProps={buildEditorAreaProps(s, p)}
|
||||
editorAreaProps={buildEditorAreaProps(s, p, {
|
||||
onEnhancePrompt: handleEnhancePrompt,
|
||||
isEnhancingPrompt,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconPlugConnected,
|
||||
IconPlugConnectedX,
|
||||
IconRocket,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -45,6 +46,10 @@ export type ChatInputToolbarProps = {
|
||||
contextFiles?: ContextFile[];
|
||||
onToggleFile?: (file: ContextFile) => void;
|
||||
onImplementPlan?: () => void;
|
||||
/** Callback to enhance the current prompt with AI */
|
||||
onEnhancePrompt?: () => void;
|
||||
/** Whether prompt enhancement is in progress */
|
||||
isEnhancingPrompt?: boolean;
|
||||
};
|
||||
|
||||
type SubmitButtonProps = {
|
||||
@@ -157,12 +162,34 @@ function ImplementPlanButton({ onClick }: { onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EnhancePromptButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
aria-label="Enhance prompt with AI"
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{isLoading ? <GridSpinner className="h-4 w-4" /> : <IconSparkles className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Enhance prompt with AI</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
|
||||
const hasMcp = mcpServers.length > 0;
|
||||
const tooltipText = hasMcp
|
||||
? `MCP Servers: ${mcpServers.join(", ")}`
|
||||
: "Agent does not support MCP";
|
||||
|
||||
const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -172,11 +199,7 @@ function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
|
||||
hasMcp ? "text-foreground" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{hasMcp ? (
|
||||
<IconPlugConnected className="h-4 w-4" />
|
||||
) : (
|
||||
<IconPlugConnectedX className="h-4 w-4" />
|
||||
)}
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
@@ -184,6 +207,70 @@ function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
type ToolbarRightSectionProps = {
|
||||
taskId: string | null;
|
||||
sessionId: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription: string;
|
||||
planModeEnabled: boolean;
|
||||
isAgentBusy: boolean;
|
||||
isDisabled: boolean;
|
||||
isSending: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
|
||||
onEnhancePrompt?: () => void;
|
||||
isEnhancingPrompt?: boolean;
|
||||
onImplementPlan?: () => void;
|
||||
};
|
||||
|
||||
function ToolbarRightSection({
|
||||
taskId,
|
||||
sessionId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
planModeEnabled,
|
||||
isAgentBusy,
|
||||
isDisabled,
|
||||
isSending,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitShortcut,
|
||||
onEnhancePrompt,
|
||||
isEnhancingPrompt,
|
||||
onImplementPlan,
|
||||
}: ToolbarRightSectionProps) {
|
||||
const showEnhance = onEnhancePrompt && !isAgentBusy;
|
||||
const showImplement = planModeEnabled && !isAgentBusy && onImplementPlan;
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<SessionsDropdown
|
||||
taskId={taskId}
|
||||
activeSessionId={sessionId}
|
||||
taskTitle={taskTitle}
|
||||
taskDescription={taskDescription}
|
||||
/>
|
||||
<TokenUsageDisplay sessionId={sessionId} />
|
||||
<ModelSelector sessionId={sessionId} />
|
||||
{showEnhance && (
|
||||
<EnhancePromptButton onClick={onEnhancePrompt} isLoading={isEnhancingPrompt ?? false} />
|
||||
)}
|
||||
{showImplement && <ImplementPlanButton onClick={onImplementPlan} />}
|
||||
<div className="ml-1">
|
||||
<SubmitButton
|
||||
isAgentBusy={isAgentBusy}
|
||||
isDisabled={isDisabled}
|
||||
isSending={isSending}
|
||||
planModeEnabled={planModeEnabled}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
submitShortcut={submitShortcut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatInputToolbar = memo(function ChatInputToolbar({
|
||||
planModeEnabled,
|
||||
planModeAvailable = true,
|
||||
@@ -206,6 +293,8 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
|
||||
contextFiles = [],
|
||||
onToggleFile,
|
||||
onImplementPlan,
|
||||
onEnhancePrompt,
|
||||
isEnhancingPrompt = false,
|
||||
}: ChatInputToolbarProps) {
|
||||
const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT;
|
||||
|
||||
@@ -247,30 +336,22 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<SessionsDropdown
|
||||
taskId={taskId}
|
||||
activeSessionId={sessionId}
|
||||
taskTitle={taskTitle}
|
||||
taskDescription={taskDescription}
|
||||
/>
|
||||
<TokenUsageDisplay sessionId={sessionId} />
|
||||
<ModelSelector sessionId={sessionId} />
|
||||
{planModeEnabled && !isAgentBusy && onImplementPlan && (
|
||||
<ImplementPlanButton onClick={onImplementPlan} />
|
||||
)}
|
||||
<div className="ml-1">
|
||||
<SubmitButton
|
||||
isAgentBusy={isAgentBusy}
|
||||
isDisabled={isDisabled}
|
||||
isSending={isSending}
|
||||
planModeEnabled={planModeEnabled}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
submitShortcut={submitShortcut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ToolbarRightSection
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
taskTitle={taskTitle}
|
||||
taskDescription={taskDescription}
|
||||
planModeEnabled={planModeEnabled}
|
||||
isAgentBusy={isAgentBusy}
|
||||
isDisabled={isDisabled}
|
||||
isSending={isSending}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
submitShortcut={submitShortcut}
|
||||
onEnhancePrompt={onEnhancePrompt}
|
||||
isEnhancingPrompt={isEnhancingPrompt}
|
||||
onImplementPlan={onImplementPlan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ export function escapeHtml(str: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Convert plain text with newlines to HTML paragraphs for TipTap */
|
||||
export function textToHtml(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
return lines.map((line) => `<p>${escapeHtml(line) || "<br>"}</p>`).join("");
|
||||
}
|
||||
|
||||
// ── Code fence parsing ──────────────────────────────────────────────
|
||||
|
||||
export type FenceSegment =
|
||||
|
||||
@@ -14,7 +14,7 @@ import { common, createLowlight } from "lowlight";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getChatDraftContent, setChatDraftContent } from "@/lib/local-storage";
|
||||
import { getMarkdownText, escapeHtml, handleEditorPaste } from "./tiptap-helpers";
|
||||
import { getMarkdownText, textToHtml, handleEditorPaste } from "./tiptap-helpers";
|
||||
import { CodeBlockView } from "./tiptap-code-block-view";
|
||||
import { ContextMention } from "./tiptap-mention-extension";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
@@ -279,7 +279,7 @@ function syncEditorValue({
|
||||
}
|
||||
|
||||
isSyncingRef.current = true;
|
||||
editor.commands.setContent(`<p>${escapeHtml(value)}</p>`);
|
||||
editor.commands.setContent(textToHtml(value));
|
||||
isSyncingRef.current = false;
|
||||
initialSyncDoneRef.current = true;
|
||||
}
|
||||
@@ -346,7 +346,7 @@ function useEditorImperativeHandle(
|
||||
if (v === "") {
|
||||
editor.commands.clearContent();
|
||||
} else {
|
||||
editor.commands.setContent(`<p>${escapeHtml(v)}</p>`);
|
||||
editor.commands.setContent(textToHtml(v));
|
||||
}
|
||||
isSyncingRef.current = false;
|
||||
onChange(v);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
@@ -135,12 +136,10 @@ export function CommitDialog({
|
||||
uncommittedDeletions={uncommittedDeletions}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
<Input
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -249,7 +248,7 @@ export function PRDialog({
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
className="resize-none max-h-[200px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -240,7 +240,6 @@ function buildTaskTopBarProps(params: {
|
||||
taskId: taskProps.taskId,
|
||||
activeSessionId: params.effectiveSessionId,
|
||||
taskTitle: taskProps.taskTitle,
|
||||
taskDescription: taskProps.taskDescription,
|
||||
baseBranch: taskProps.baseBranch ?? undefined,
|
||||
onStartAgent: agent.handleStartAgent,
|
||||
onStopAgent: agent.handleStopAgent,
|
||||
|
||||
@@ -38,7 +38,6 @@ type TaskTopBarProps = {
|
||||
taskId?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription?: string;
|
||||
baseBranch?: string;
|
||||
onStartAgent?: (agentProfileId: string) => void;
|
||||
onStopAgent?: () => void;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||
import { IconGitCommit, IconGitPullRequest, IconLoader2, IconCheck } from "@tabler/icons-react";
|
||||
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
IconGitCommit,
|
||||
IconGitPullRequest,
|
||||
IconLoader2,
|
||||
IconCheck,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,10 +19,13 @@ import {
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
|
||||
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import type { FileInfo } from "@/lib/state/slices";
|
||||
|
||||
@@ -41,7 +50,9 @@ type VcsDialogsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function computeFileSummary(files: Record<string, FileInfo> | undefined) {
|
||||
type FileSummary = { count: number; additions: number; deletions: number };
|
||||
|
||||
function computeFileSummary(files: Record<string, FileInfo> | undefined): FileSummary {
|
||||
const count = files ? Object.keys(files).length : 0;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
@@ -54,16 +65,78 @@ function computeFileSummary(files: Record<string, FileInfo> | undefined) {
|
||||
return { count, additions, deletions };
|
||||
}
|
||||
|
||||
function FileSummaryText({ count, additions, deletions }: FileSummary) {
|
||||
if (count === 0) return <span>No changes to commit</span>;
|
||||
return (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{count}</span> file{count !== 1 ? "s" : ""}{" "}
|
||||
changed
|
||||
{(additions > 0 || deletions > 0) && (
|
||||
<span className="ml-2">
|
||||
(<span className="text-green-600">+{additions}</span>
|
||||
{" / "}
|
||||
<span className="text-red-600">-{deletions}</span>)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type GenerateButtonProps = {
|
||||
onClick: () => void;
|
||||
isGenerating: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip: string;
|
||||
size?: "icon" | "sm";
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
function GenerateButton({
|
||||
onClick,
|
||||
isGenerating,
|
||||
disabled,
|
||||
tooltip,
|
||||
size = "icon",
|
||||
showLabel,
|
||||
}: GenerateButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size={size}
|
||||
variant="ghost"
|
||||
className={
|
||||
size === "icon" ? "h-7 w-7 cursor-pointer" : "h-6 px-2 cursor-pointer gap-1.5 text-xs"
|
||||
}
|
||||
onClick={onClick}
|
||||
disabled={disabled || isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="h-4 w-4" />
|
||||
)}
|
||||
{showLabel && "Generate"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type CommitDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
fileSummary: { count: number; additions: number; deletions: number };
|
||||
fileSummary: FileSummary;
|
||||
commitMessage: string;
|
||||
onCommitMessageChange: (v: string) => void;
|
||||
stageAll: boolean;
|
||||
onStageAllChange: (v: boolean) => void;
|
||||
isGitLoading: boolean;
|
||||
onCommit: () => void;
|
||||
onGenerateMessage?: () => void;
|
||||
isGenerating?: boolean;
|
||||
};
|
||||
|
||||
function CommitDialog({
|
||||
@@ -76,8 +149,9 @@ function CommitDialog({
|
||||
onStageAllChange,
|
||||
isGitLoading,
|
||||
onCommit,
|
||||
onGenerateMessage,
|
||||
isGenerating,
|
||||
}: CommitDialogProps) {
|
||||
const { count, additions, deletions } = fileSummary;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
@@ -89,30 +163,27 @@ function CommitDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{count > 0 ? (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{count}</span> file
|
||||
{count !== 1 ? "s" : ""} changed
|
||||
{(additions > 0 || deletions > 0) && (
|
||||
<span className="ml-2">
|
||||
(<span className="text-green-600">+{additions}</span>
|
||||
{" / "}
|
||||
<span className="text-red-600">-{deletions}</span>)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>No changes to commit</span>
|
||||
<FileSummaryText {...fileSummary} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
{onGenerateMessage && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
||||
<GenerateButton
|
||||
onClick={onGenerateMessage}
|
||||
isGenerating={isGenerating ?? false}
|
||||
disabled={fileSummary.count === 0}
|
||||
tooltip="Generate commit message with AI"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="vcs-stage-all"
|
||||
@@ -162,6 +233,8 @@ type PRDialogProps = {
|
||||
onPrDraftChange: (v: boolean) => void;
|
||||
isGitLoading: boolean;
|
||||
onCreatePR: () => void;
|
||||
onGenerateDescription?: () => void;
|
||||
isGenerating?: boolean;
|
||||
};
|
||||
|
||||
function PRBranchSummary({
|
||||
@@ -202,6 +275,8 @@ function PRDialog({
|
||||
onPrDraftChange,
|
||||
isGitLoading,
|
||||
onCreatePR,
|
||||
onGenerateDescription,
|
||||
isGenerating,
|
||||
}: PRDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -218,27 +293,36 @@ function PRDialog({
|
||||
<Label htmlFor="vcs-pr-title" className="text-sm">
|
||||
Title
|
||||
</Label>
|
||||
<input
|
||||
<Input
|
||||
id="vcs-pr-title"
|
||||
type="text"
|
||||
placeholder="Pull request title..."
|
||||
value={prTitle}
|
||||
onChange={(e) => onPrTitleChange(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vcs-pr-body" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="vcs-pr-body" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
{onGenerateDescription && (
|
||||
<GenerateButton
|
||||
onClick={onGenerateDescription}
|
||||
isGenerating={isGenerating ?? false}
|
||||
tooltip="Generate PR description with AI"
|
||||
size="sm"
|
||||
showLabel
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id="vcs-pr-body"
|
||||
placeholder="Describe your changes..."
|
||||
value={prBody}
|
||||
onChange={(e) => onPrBodyChange(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
className="resize-none max-h-[200px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -277,6 +361,53 @@ function PRDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type UseCommitDialogReturn = {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
message: string;
|
||||
setMessage: (v: string) => void;
|
||||
stageAll: boolean;
|
||||
setStageAll: (v: boolean) => void;
|
||||
openDialog: () => void;
|
||||
};
|
||||
|
||||
function useCommitDialogState(): UseCommitDialogReturn {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [stageAll, setStageAll] = useState(true);
|
||||
const openDialog = useCallback(() => {
|
||||
setMessage("");
|
||||
setStageAll(true);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
return { open, setOpen, message, setMessage, stageAll, setStageAll, openDialog };
|
||||
}
|
||||
|
||||
type UsePRDialogReturn = {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
title: string;
|
||||
setTitle: (v: string) => void;
|
||||
body: string;
|
||||
setBody: (v: string) => void;
|
||||
draft: boolean;
|
||||
setDraft: (v: boolean) => void;
|
||||
openDialog: (taskTitle?: string) => void;
|
||||
};
|
||||
|
||||
function usePRDialogState(): UsePRDialogReturn {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [draft, setDraft] = useState(true);
|
||||
const openDialog = useCallback((taskTitle?: string) => {
|
||||
setTitle(taskTitle || "");
|
||||
setBody("");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
return { open, setOpen, title, setTitle, body, setBody, draft, setDraft, openDialog };
|
||||
}
|
||||
|
||||
export function VcsDialogsProvider({
|
||||
sessionId,
|
||||
baseBranch,
|
||||
@@ -284,48 +415,36 @@ export function VcsDialogsProvider({
|
||||
displayBranch,
|
||||
children,
|
||||
}: VcsDialogsProviderProps) {
|
||||
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [stageAll, setStageAll] = useState(true);
|
||||
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
||||
const [prTitle, setPrTitle] = useState("");
|
||||
const [prBody, setPrBody] = useState("");
|
||||
const [prDraft, setPrDraft] = useState(true);
|
||||
|
||||
const cs = useCommitDialogState();
|
||||
const ps = usePRDialogState();
|
||||
const { toast } = useToast();
|
||||
const gitWithFeedback = useGitWithFeedback();
|
||||
const gitStatus = useSessionGitStatus(sessionId);
|
||||
const { commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId);
|
||||
|
||||
const {
|
||||
isGeneratingCommitMessage,
|
||||
isGeneratingPRDescription,
|
||||
generateCommitMessage,
|
||||
generatePRDescription,
|
||||
} = useUtilityAgentGenerator({ sessionId, taskTitle });
|
||||
const fileSummary = computeFileSummary(gitStatus?.files);
|
||||
|
||||
const openCommitDialog = useCallback(() => {
|
||||
setCommitMessage("");
|
||||
setStageAll(true);
|
||||
setCommitDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!commitMessage.trim()) return;
|
||||
setCommitDialogOpen(false);
|
||||
await gitWithFeedback(() => commit(commitMessage.trim(), stageAll), "Commit");
|
||||
setCommitMessage("");
|
||||
}, [commitMessage, stageAll, gitWithFeedback, commit]);
|
||||
|
||||
const openPRDialog = useCallback(() => {
|
||||
setPrTitle(taskTitle || "");
|
||||
setPrBody("");
|
||||
setPrDialogOpen(true);
|
||||
}, [taskTitle]);
|
||||
if (!cs.message.trim()) return;
|
||||
cs.setOpen(false);
|
||||
await gitWithFeedback(() => commit(cs.message.trim(), cs.stageAll), "Commit");
|
||||
cs.setMessage("");
|
||||
}, [cs, gitWithFeedback, commit]);
|
||||
|
||||
const handleCreatePR = useCallback(async () => {
|
||||
if (!prTitle.trim()) return;
|
||||
setPrDialogOpen(false);
|
||||
if (!ps.title.trim()) return;
|
||||
ps.setOpen(false);
|
||||
try {
|
||||
const result = await createPR(prTitle.trim(), prBody.trim(), baseBranch, prDraft);
|
||||
const result = await createPR(ps.title.trim(), ps.body.trim(), baseBranch, ps.draft);
|
||||
if (result.success) {
|
||||
const title = ps.draft ? "Draft PR created" : "PR created";
|
||||
toast({
|
||||
title: prDraft ? "Draft PR created" : "PR created",
|
||||
title,
|
||||
description: result.pr_url || "PR created successfully",
|
||||
variant: "success",
|
||||
});
|
||||
@@ -344,37 +463,49 @@ export function VcsDialogsProvider({
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
setPrTitle("");
|
||||
setPrBody("");
|
||||
}, [prTitle, prBody, baseBranch, prDraft, createPR, toast]);
|
||||
ps.setTitle("");
|
||||
ps.setBody("");
|
||||
}, [ps, baseBranch, createPR, toast]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
openCommitDialog: cs.openDialog,
|
||||
openPRDialog: () => ps.openDialog(taskTitle),
|
||||
}),
|
||||
[cs.openDialog, ps, taskTitle],
|
||||
);
|
||||
|
||||
return (
|
||||
<VcsDialogsContext.Provider value={{ openCommitDialog, openPRDialog }}>
|
||||
<VcsDialogsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<CommitDialog
|
||||
open={commitDialogOpen}
|
||||
onOpenChange={setCommitDialogOpen}
|
||||
open={cs.open}
|
||||
onOpenChange={cs.setOpen}
|
||||
fileSummary={fileSummary}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
stageAll={stageAll}
|
||||
onStageAllChange={setStageAll}
|
||||
commitMessage={cs.message}
|
||||
onCommitMessageChange={cs.setMessage}
|
||||
stageAll={cs.stageAll}
|
||||
onStageAllChange={cs.setStageAll}
|
||||
isGitLoading={isGitLoading}
|
||||
onCommit={handleCommit}
|
||||
onGenerateMessage={() => generateCommitMessage(cs.setMessage)}
|
||||
isGenerating={isGeneratingCommitMessage}
|
||||
/>
|
||||
<PRDialog
|
||||
open={prDialogOpen}
|
||||
onOpenChange={setPrDialogOpen}
|
||||
open={ps.open}
|
||||
onOpenChange={ps.setOpen}
|
||||
displayBranch={displayBranch}
|
||||
baseBranch={baseBranch}
|
||||
prTitle={prTitle}
|
||||
onPrTitleChange={setPrTitle}
|
||||
prBody={prBody}
|
||||
onPrBodyChange={setPrBody}
|
||||
prDraft={prDraft}
|
||||
onPrDraftChange={setPrDraft}
|
||||
prTitle={ps.title}
|
||||
onPrTitleChange={ps.setTitle}
|
||||
prBody={ps.body}
|
||||
onPrBodyChange={ps.setBody}
|
||||
prDraft={ps.draft}
|
||||
onPrDraftChange={ps.setDraft}
|
||||
isGitLoading={isGitLoading}
|
||||
onCreatePR={handleCreatePR}
|
||||
onGenerateDescription={() => generatePRDescription(ps.setBody)}
|
||||
isGenerating={isGeneratingPRDescription}
|
||||
/>
|
||||
</VcsDialogsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { executeUtilityPrompt, type ExecutePromptRequest } from "@/lib/api/domains/utility-api";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import type { FileInfo } from "@/lib/state/slices";
|
||||
|
||||
type GeneratorType = "commit-message" | "pr-description" | "enhance-prompt";
|
||||
|
||||
const UTILITY_AGENT_IDS: Record<GeneratorType, string> = {
|
||||
"commit-message": "builtin-commit-message",
|
||||
"pr-description": "builtin-pr-description",
|
||||
"enhance-prompt": "builtin-enhance-prompt",
|
||||
};
|
||||
|
||||
type UseUtilityAgentGeneratorOptions = {
|
||||
sessionId: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription?: string;
|
||||
};
|
||||
|
||||
type GenerateOptions = {
|
||||
onSuccess?: (content: string) => void;
|
||||
// Additional context for PR description
|
||||
commitLog?: string;
|
||||
diffSummary?: string;
|
||||
// User's original prompt for enhancement
|
||||
userPrompt?: string;
|
||||
};
|
||||
|
||||
export function useUtilityAgentGenerator({
|
||||
sessionId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
}: UseUtilityAgentGeneratorOptions) {
|
||||
const [isGenerating, setIsGenerating] = useState<GeneratorType | null>(null);
|
||||
const { toast } = useToast();
|
||||
const gitStatus = useSessionGitStatus(sessionId);
|
||||
|
||||
const collectGitContext = useCallback(() => {
|
||||
const changedFiles = gitStatus?.files ? Object.keys(gitStatus.files) : [];
|
||||
const diffs = gitStatus?.files
|
||||
? Object.values(gitStatus.files as Record<string, FileInfo>)
|
||||
.filter((f) => f.diff)
|
||||
.map((f) => f.diff)
|
||||
.join("\n\n")
|
||||
: undefined;
|
||||
return { changedFiles, diffs };
|
||||
}, [gitStatus]);
|
||||
|
||||
const generate = useCallback(
|
||||
async (type: GeneratorType, options?: GenerateOptions) => {
|
||||
if (!sessionId) {
|
||||
toast({
|
||||
title: "No active session",
|
||||
description: "Start a session first to use AI generation",
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(type);
|
||||
try {
|
||||
const { changedFiles, diffs } = collectGitContext();
|
||||
|
||||
const request: ExecutePromptRequest = {
|
||||
utility_agent_id: UTILITY_AGENT_IDS[type],
|
||||
session_id: sessionId,
|
||||
task_title: taskTitle,
|
||||
task_description: taskDescription,
|
||||
git_diff: diffs || undefined,
|
||||
changed_files: changedFiles.join("\n") || undefined,
|
||||
commit_log: options?.commitLog,
|
||||
diff_summary: options?.diffSummary,
|
||||
user_prompt: options?.userPrompt,
|
||||
};
|
||||
|
||||
const resp = await executeUtilityPrompt(request);
|
||||
|
||||
// Clear generating state BEFORE calling onSuccess so the editor is re-enabled
|
||||
setIsGenerating(null);
|
||||
|
||||
if (resp.success && resp.response) {
|
||||
// Use requestAnimationFrame to ensure React has re-rendered with editor enabled
|
||||
const response = resp.response;
|
||||
requestAnimationFrame(() => {
|
||||
options?.onSuccess?.(response);
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Generation failed",
|
||||
description: resp.error || "Failed to generate content",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setIsGenerating(null);
|
||||
toast({
|
||||
title: "Generation failed",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[sessionId, taskTitle, taskDescription, collectGitContext, toast],
|
||||
);
|
||||
|
||||
const generateCommitMessage = useCallback(
|
||||
(onSuccess: (message: string) => void) => {
|
||||
return generate("commit-message", { onSuccess });
|
||||
},
|
||||
[generate],
|
||||
);
|
||||
|
||||
const generatePRDescription = useCallback(
|
||||
(
|
||||
onSuccess: (description: string) => void,
|
||||
extra?: { commitLog?: string; diffSummary?: string },
|
||||
) => {
|
||||
return generate("pr-description", { onSuccess, ...extra });
|
||||
},
|
||||
[generate],
|
||||
);
|
||||
|
||||
const enhancePrompt = useCallback(
|
||||
(userPrompt: string, onSuccess: (enhanced: string) => void) => {
|
||||
return generate("enhance-prompt", { onSuccess, userPrompt });
|
||||
},
|
||||
[generate],
|
||||
);
|
||||
|
||||
return {
|
||||
isGenerating,
|
||||
isGeneratingCommitMessage: isGenerating === "commit-message",
|
||||
isGeneratingPRDescription: isGenerating === "pr-description",
|
||||
isEnhancingPrompt: isGenerating === "enhance-prompt",
|
||||
generateCommitMessage,
|
||||
generatePRDescription,
|
||||
enhancePrompt,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export async function updateUserSettings(
|
||||
lsp_auto_install_languages?: string[];
|
||||
lsp_server_configs?: Record<string, Record<string, unknown>>;
|
||||
saved_layouts?: SavedLayout[];
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
},
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
|
||||
// Types
|
||||
export type UtilityAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
agent_id: string;
|
||||
model: string;
|
||||
builtin: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UtilityAgentCall = {
|
||||
id: string;
|
||||
utility_id: string;
|
||||
session_id: string;
|
||||
resolved_prompt: string;
|
||||
response: string;
|
||||
model: string;
|
||||
prompt_tokens: number;
|
||||
response_tokens: number;
|
||||
duration_ms: number;
|
||||
status: string;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
};
|
||||
|
||||
export type TemplateVariable = {
|
||||
name: string;
|
||||
description: string;
|
||||
example: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
export type InferenceModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export type InferenceAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
models: InferenceModel[];
|
||||
};
|
||||
|
||||
export type ExecutePromptRequest = {
|
||||
utility_agent_id: string;
|
||||
session_id?: string;
|
||||
git_diff?: string;
|
||||
commit_log?: string;
|
||||
changed_files?: string;
|
||||
diff_summary?: string;
|
||||
branch_name?: string;
|
||||
base_branch?: string;
|
||||
task_title?: string;
|
||||
task_description?: string;
|
||||
user_prompt?: string;
|
||||
};
|
||||
|
||||
export type ExecutePromptResponse = {
|
||||
success: boolean;
|
||||
call_id?: string;
|
||||
response?: string;
|
||||
model?: string;
|
||||
prompt_tokens?: number;
|
||||
response_tokens?: number;
|
||||
duration_ms?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// API Functions
|
||||
export async function listUtilityAgents(
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<{ agents: UtilityAgent[] }> {
|
||||
return fetchJson<{ agents: UtilityAgent[] }>("/api/v1/utility/agents", options);
|
||||
}
|
||||
|
||||
export async function getUtilityAgent(
|
||||
id: string,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<UtilityAgent> {
|
||||
return fetchJson<UtilityAgent>(`/api/v1/utility/agents/${id}`, options);
|
||||
}
|
||||
|
||||
export async function createUtilityAgent(
|
||||
data: Partial<UtilityAgent>,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<UtilityAgent> {
|
||||
return fetchJson<UtilityAgent>("/api/v1/utility/agents", {
|
||||
...options,
|
||||
init: { method: "POST", body: JSON.stringify(data), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUtilityAgent(
|
||||
id: string,
|
||||
data: Partial<UtilityAgent>,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<UtilityAgent> {
|
||||
return fetchJson<UtilityAgent>(`/api/v1/utility/agents/${id}`, {
|
||||
...options,
|
||||
init: { method: "PATCH", body: JSON.stringify(data), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUtilityAgent(id: string, options?: ApiRequestOptions): Promise<void> {
|
||||
await fetchJson<{ success: boolean }>(`/api/v1/utility/agents/${id}`, {
|
||||
...options,
|
||||
init: { method: "DELETE", ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTemplateVariables(
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<{ variables: TemplateVariable[] }> {
|
||||
return fetchJson<{ variables: TemplateVariable[] }>(
|
||||
"/api/v1/utility/template-variables",
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listInferenceAgents(
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<{ agents: InferenceAgent[] }> {
|
||||
return fetchJson<{ agents: InferenceAgent[] }>("/api/v1/utility/inference-agents", options);
|
||||
}
|
||||
|
||||
export async function executeUtilityPrompt(
|
||||
req: ExecutePromptRequest,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<ExecutePromptResponse> {
|
||||
return fetchJson<ExecutePromptResponse>("/api/v1/utility/execute", {
|
||||
...options,
|
||||
init: { method: "POST", body: JSON.stringify(req), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUtilityCalls(
|
||||
utilityId: string,
|
||||
limit = 50,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<{ calls: UtilityAgentCall[] }> {
|
||||
return fetchJson<{ calls: UtilityAgentCall[] }>(
|
||||
`/api/v1/utility/agents/${utilityId}/calls?limit=${limit}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -364,6 +364,8 @@ export type UserSettings = {
|
||||
lsp_auto_install_languages?: string[];
|
||||
lsp_server_configs?: Record<string, Record<string, unknown>>;
|
||||
saved_layouts?: SavedLayout[];
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user