Nik Afiq ad50d641bd
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 43s
CI / build-ha-gateway (push) Successful in 47s
CI / build-discord-bot (push) Successful in 41s
feat: enhance AI model management in Discord bot
- Updated LLMClient interface to support model-specific generation and model listing.
- Integrated model store and validator into the command application for managing AI models.
- Implemented commands for setting, getting, and listing active AI models in Discord.
- Enhanced AI query handling to utilize the selected model and return model information in responses.
- Added caching mechanism for model validation to improve performance.
- Introduced gRPC methods for listing available AI models in the ai-gateway.
- Updated protobuf definitions to include model-related fields and messages.
- Added tests for model store and validator functionalities.
2026-04-21 22:52:00 +09:00

291 lines
9.1 KiB
Go

package app
import (
"context"
"fmt"
"slices"
"strings"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/modelstore"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/modelvalidator"
)
// Choice is one Discord autocomplete entry.
type Choice struct {
Label string
Value string
}
// CommandApp orchestrates Discord command use cases against ha-gateway.
type CommandApp struct {
ha driven.HAGateway
ai driven.AIGateway
models *modelstore.Store
validator *modelvalidator.Validator
}
// NewCommandApp constructs the Discord command application service.
func NewCommandApp(ha driven.HAGateway, ai driven.AIGateway, models *modelstore.Store, validator *modelvalidator.Validator) *CommandApp {
return &CommandApp{ha: ha, ai: ai, models: models, validator: validator}
}
// HandleLightList formats discovered lights into a monospace-friendly response.
func (a *CommandApp) HandleLightList(ctx context.Context) (string, error) {
lights, err := a.ha.ListLights(ctx)
if err != nil {
return "", fmt.Errorf("handle light list: %w", err)
}
if len(lights) == 0 {
return "No lights found.", nil
}
lines := make([]string, 0, len(lights)+2)
lines = append(lines, "```text")
for _, light := range lights {
lines = append(lines, formatLightLine(light))
}
lines = append(lines, "```")
return strings.Join(lines, "\n"), nil
}
// HandleLightOn issues a turn-on request and returns a user-facing confirmation.
func (a *CommandApp) HandleLightOn(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) (string, error) {
name, err := a.lookupLightName(ctx, entityID)
if err != nil {
return "", fmt.Errorf("lookup light name: %w", err)
}
if err := a.ha.TurnOnLight(ctx, entityID, brightnessPct, colorTempKelvin); err != nil {
return "", fmt.Errorf("handle light on: %w", err)
}
details := make([]string, 0, 2)
if brightnessPct != nil {
details = append(details, fmt.Sprintf("brightness %d%%", *brightnessPct))
}
if colorTempKelvin != nil {
details = append(details, fmt.Sprintf("%dK", *colorTempKelvin))
}
if len(details) == 0 {
return fmt.Sprintf("Turned on `%s`.", name), nil
}
return fmt.Sprintf("Turned on `%s` (%s).", name, strings.Join(details, ", ")), nil
}
// HandleLightOff issues a turn-off request and returns a user-facing confirmation.
func (a *CommandApp) HandleLightOff(ctx context.Context, entityID string, transition *uint32) (string, error) {
name, err := a.lookupLightName(ctx, entityID)
if err != nil {
return "", fmt.Errorf("lookup light name: %w", err)
}
if err := a.ha.TurnOffLight(ctx, entityID, transition); err != nil {
return "", fmt.Errorf("handle light off: %w", err)
}
if transition == nil {
return fmt.Sprintf("Turned off `%s`.", name), nil
}
return fmt.Sprintf("Turned off `%s` with %ds transition.", name, *transition), nil
}
// HandleLightToggle issues a toggle request and returns a user-facing confirmation.
func (a *CommandApp) HandleLightToggle(ctx context.Context, entityID string) (string, error) {
name, err := a.lookupLightName(ctx, entityID)
if err != nil {
return "", fmt.Errorf("lookup light name: %w", err)
}
if err := a.ha.ToggleLight(ctx, entityID); err != nil {
return "", fmt.Errorf("handle light toggle: %w", err)
}
return fmt.Sprintf("Toggled `%s`.", name), nil
}
// HandleSwitchList formats discovered switches into a monospace-friendly response.
func (a *CommandApp) HandleSwitchList(ctx context.Context) (string, error) {
switches, err := a.ha.ListSwitches(ctx)
if err != nil {
return "", fmt.Errorf("handle switch list: %w", err)
}
if len(switches) == 0 {
return "No switches found.", nil
}
lines := make([]string, 0, len(switches)+2)
lines = append(lines, "```text")
for _, sw := range switches {
label := sw.FriendlyName
if label == "" {
label = sw.EntityID
}
details := sw.DeviceClass
if details == "" {
details = "switch"
}
lines = append(lines, fmt.Sprintf("%s %-15s %-12s %s", stateEmoji(sw.State), label, sw.State, details))
}
lines = append(lines, "```")
return strings.Join(lines, "\n"), nil
}
// HandleAIQuery forwards a free-form request to ai-gateway.
func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) {
reply, modelUsed, err := a.ai.Query(ctx, text, a.models.Get())
if err != nil {
return "", fmt.Errorf("handle ai query: %w", err)
}
return fmt.Sprintf("%s\n\n_(via %s)_", reply, modelUsed), nil
}
// HandleAIModelSet validates and stores the selected model globally.
func (a *CommandApp) HandleAIModelSet(ctx context.Context, name string) (string, error) {
canonical, err := a.validator.Normalize(ctx, name)
if err != nil {
if err.Error() == "ambiguous model name" {
return "", fmt.Errorf("unknown model: %s. Be more specific.", name)
}
if err.Error() == "unknown model" {
return "", fmt.Errorf("unknown model: %s. Try /ai model list.", name)
}
return "", fmt.Errorf("validate model: %w", err)
}
a.models.Set(canonical)
return fmt.Sprintf("Active model set to `%s`.", canonical), nil
}
// HandleAIModelGet reports the current selected model or default state.
func (a *CommandApp) HandleAIModelGet(ctx context.Context) (string, error) {
cur := a.models.Get()
if cur == "" {
return "No model override set. Using ai-gateway default.", nil
}
return fmt.Sprintf("Active model: `%s`", cur), nil
}
// HandleAIModelList shows the installed models and marks the active selection.
func (a *CommandApp) HandleAIModelList(ctx context.Context) (string, error) {
models, err := a.validator.Known(ctx)
if err != nil {
return "", fmt.Errorf("list models: %w", err)
}
if len(models) == 0 {
return "No models installed on the Ollama host.", nil
}
active := a.models.Get()
lines := make([]string, 0, len(models)+1)
lines = append(lines, "Available models:")
for _, model := range models {
marker := ""
if model == active {
marker = " <- active"
}
lines = append(lines, fmt.Sprintf("- `%s`%s", model, marker))
}
return strings.Join(lines, "\n"), nil
}
// AutocompleteAIModels returns model names for the /ai model set command.
func (a *CommandApp) AutocompleteAIModels(ctx context.Context) ([]Choice, error) {
models, err := a.validator.Known(ctx)
if err != nil {
return nil, fmt.Errorf("autocomplete ai models: %w", err)
}
choices := make([]Choice, 0, len(models))
for _, model := range models {
choices = append(choices, Choice{Label: model, Value: model})
}
return choices, nil
}
// AutocompleteLights maps discovered lights into Discord autocomplete choices.
func (a *CommandApp) AutocompleteLights(ctx context.Context) ([]Choice, error) {
lights, err := a.ha.ListLights(ctx)
if err != nil {
return nil, fmt.Errorf("autocomplete lights: %w", err)
}
choices := make([]Choice, 0, len(lights))
for _, light := range lights {
label := light.FriendlyName
if label == "" {
label = light.EntityID
}
choices = append(choices, Choice{Label: label, Value: light.EntityID})
}
return choices, nil
}
// AutocompleteSwitches maps discovered switches into Discord autocomplete choices.
func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) {
switches, err := a.ha.ListSwitches(ctx)
if err != nil {
return nil, fmt.Errorf("autocomplete switches: %w", err)
}
choices := make([]Choice, 0, len(switches))
for _, sw := range switches {
label := sw.FriendlyName
if label == "" {
label = sw.EntityID
}
choices = append(choices, Choice{Label: label, Value: sw.EntityID})
}
return choices, nil
}
// lookupLightName falls back to the entity ID so confirmations remain useful
// even when Home Assistant does not expose a friendly name.
func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (string, error) {
lights, err := a.ha.ListLights(ctx)
if err != nil {
return "", fmt.Errorf("list lights: %w", err)
}
idx := slices.IndexFunc(lights, func(light driven.Light) bool {
return light.EntityID == entityID
})
if idx == -1 {
return entityID, nil
}
if lights[idx].FriendlyName == "" {
return entityID, nil
}
return lights[idx].FriendlyName, nil
}
// formatLightLine keeps list output compact because Discord code blocks are
// easier to scan than rich embeds for dense discovery data.
func formatLightLine(light driven.Light) string {
label := light.FriendlyName
if label == "" {
label = light.EntityID
}
parts := make([]string, 0, 3)
if len(light.SupportedColorModes) > 0 {
parts = append(parts, strings.Join(light.SupportedColorModes, ","))
}
if light.MinColorTempKelvin > 0 && light.MaxColorTempKelvin > 0 {
parts = append(parts, fmt.Sprintf("%d-%dK", light.MinColorTempKelvin, light.MaxColorTempKelvin))
}
if light.IsHueGroup {
parts = append(parts, "hue-group")
}
details := strings.Join(parts, " ")
if details == "" {
details = "-"
}
return fmt.Sprintf("%s %-15s %-12s %s", stateEmoji(light.State), label, light.State, details)
}
// stateEmoji compresses common Home Assistant states into visually scannable output.
func stateEmoji(state string) string {
switch state {
case "on":
return "🟢"
case "off":
return "🔴"
default:
return "⚠️"
}
}