Compare commits

...

6 Commits

9 changed files with 566 additions and 1 deletions
+34
View File
@@ -122,6 +122,16 @@ func (db *DB) RunMigrations() error {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME, last_used_at DATETIME,
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS model_groups (
id TEXT PRIMARY KEY,
strategy TEXT NOT NULL DEFAULT 'heuristic',
selector_model TEXT,
targets TEXT NOT NULL DEFAULT '[]',
complexity_threshold INTEGER,
heuristic_rules TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
} }
@@ -177,6 +187,19 @@ func (db *DB) RunMigrations() error {
return fmt.Errorf("failed to insert default client: %w", err) return fmt.Errorf("failed to insert default client: %w", err)
} }
// Seed default model groups
defaultGroups := []struct {
id, strategy, targets string
}{
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`},
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`},
{"gemini-auto", "heuristic", `["gemini-2.0-flash","gemini-2.5-pro"]`},
}
for _, g := range defaultGroups {
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets) VALUES (?, ?, ?)`,
g.id, g.strategy, g.targets)
}
return nil return nil
} }
@@ -262,3 +285,14 @@ type ClientToken struct {
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
LastUsedAt *time.Time `db:"last_used_at"` LastUsedAt *time.Time `db:"last_used_at"`
} }
type ModelGroup struct {
ID string `db:"id" json:"id"`
Strategy string `db:"strategy" json:"strategy"`
SelectorModel *string `db:"selector_model" json:"selector_model"`
Targets string `db:"targets" json:"targets"` // JSON array
ComplexityThreshold *int `db:"complexity_threshold" json:"complexity_threshold"`
HeuristicRules *string `db:"heuristic_rules" json:"heuristic_rules"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
+53
View File
@@ -0,0 +1,53 @@
package router
import (
"context"
"fmt"
"strconv"
"strings"
"gophergate/internal/db"
)
const classifierSystemPrompt = `You are a task complexity classifier. Rate the following user message on a scale of 1 to %d, where:
1 = trivial/simple (basic facts, greetings, simple math)
%d = highly complex (multi-step reasoning, code generation, architecture design)
Reply with ONLY the number. No explanation.`
func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.ModelGroup, targets []string, userMessage string) (*Decision, error) {
maxRating := len(targets)
if maxRating < 2 {
maxRating = 2
}
prompt := fmt.Sprintf(classifierSystemPrompt, maxRating, maxRating)
ratingStr, err := classify(ctx, getSelectorModel(group, targets), prompt, userMessage)
if err != nil {
// Classifier failed — fall back to heuristic
return routeHeuristic(group, targets, userMessage)
}
rating, err := strconv.Atoi(strings.TrimSpace(ratingStr))
if err != nil || rating < 1 {
rating = 1
}
if rating > maxRating {
rating = maxRating
}
idx := rating - 1 // 0-based index into targets
return &Decision{
SelectedModel: targets[idx],
Strategy: "classifier",
Reason: fmt.Sprintf("complexity rating: %d/%d", rating, maxRating),
}, nil
}
func getSelectorModel(group db.ModelGroup, targets []string) string {
if group.SelectorModel != nil && *group.SelectorModel != "" {
return *group.SelectorModel
}
// Default: use the first (cheapest) target model as the selector
return targets[0]
}
+66
View File
@@ -0,0 +1,66 @@
package router
import (
"encoding/json"
"strings"
"gophergate/internal/db"
)
// HeuristicRule defines a pattern-based routing rule.
type HeuristicRule struct {
Pattern string `json:"pattern"`
TargetIdx int `json:"target"`
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
func routeHeuristic(group db.ModelGroup, targets []string, userMessage string) (*Decision, error) {
selected := targets[0]
reason := "default (first target)"
// If heuristic_rules is set, use them
if group.HeuristicRules != nil && *group.HeuristicRules != "" {
var rules []HeuristicRule
if err := json.Unmarshal([]byte(*group.HeuristicRules), &rules); err == nil {
searchMsg := userMessage
for _, rule := range rules {
pattern := rule.Pattern
msg := searchMsg
if !rule.CaseSensitive {
pattern = strings.ToLower(pattern)
msg = strings.ToLower(msg)
}
if strings.Contains(msg, pattern) {
if rule.TargetIdx >= 0 && rule.TargetIdx < len(targets) {
selected = targets[rule.TargetIdx]
reason = "matched heuristic rule: " + rule.Pattern
break
}
}
}
}
}
// Built-in fallback heuristics
if reason == "default (first target)" && len(targets) > 1 {
msgLower := strings.ToLower(userMessage)
complexIndicators := []string{
"step by step", "explain in detail", "reason through",
"think carefully", "analyze", "debug", "write code",
"implement", "refactor", "architecture",
}
for _, indicator := range complexIndicators {
if strings.Contains(msgLower, indicator) {
selected = targets[len(targets)-1]
reason = "complex task indicator: " + indicator
break
}
}
}
return &Decision{
SelectedModel: selected,
Strategy: "heuristic",
Reason: reason,
}, nil
}
+76
View File
@@ -0,0 +1,76 @@
package router
import (
"context"
"encoding/json"
"fmt"
"gophergate/internal/db"
)
// Decision holds the result of a routing decision.
type Decision struct {
SelectedModel string `json:"selected_model"`
Strategy string `json:"strategy"` // "heuristic" or "classifier"
Reason string `json:"reason"`
}
// ClassifierFunc is the callback for classifier-based routing.
type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error)
// Router resolves model groups to concrete models.
type Router struct {
groups map[string]db.ModelGroup
classify ClassifierFunc
}
// New creates a Router. classify may be nil if no classifier groups exist.
func New(groups []db.ModelGroup, classify ClassifierFunc) *Router {
r := &Router{
groups: make(map[string]db.ModelGroup),
classify: classify,
}
for _, g := range groups {
r.groups[g.ID] = g
}
return r
}
// IsGroup returns true if the model name is a group ID.
func (r *Router) IsGroup(modelID string) bool {
_, ok := r.groups[modelID]
return ok
}
// Route resolves a group to a concrete model.
func (r *Router) Route(ctx context.Context, groupID string, userMessage string) (*Decision, error) {
group, ok := r.groups[groupID]
if !ok {
return nil, fmt.Errorf("unknown model group: %s", groupID)
}
var targets []string
if err := json.Unmarshal([]byte(group.Targets), &targets); err != nil || len(targets) == 0 {
return nil, fmt.Errorf("invalid or empty targets for group %s", groupID)
}
switch group.Strategy {
case "heuristic":
return routeHeuristic(group, targets, userMessage)
case "classifier":
if r.classify == nil {
return routeHeuristic(group, targets, userMessage)
}
return routeClassifier(ctx, r.classify, group, targets, userMessage)
default:
return nil, fmt.Errorf("unknown strategy: %s", group.Strategy)
}
}
// Reload replaces the group definitions without recreating the router.
func (r *Router) Reload(groups []db.ModelGroup) {
r.groups = make(map[string]db.ModelGroup)
for _, g := range groups {
r.groups[g.ID] = g
}
}
+76
View File
@@ -0,0 +1,76 @@
package server
import (
"net/http"
"gophergate/internal/db"
"github.com/gin-gonic/gin"
)
func (s *Server) handleGetModelGroups(c *gin.Context) {
var groups []db.ModelGroup
if err := s.database.Select(&groups, "SELECT * FROM model_groups ORDER BY id"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if groups == nil {
groups = []db.ModelGroup{}
}
c.JSON(http.StatusOK, groups)
}
func (s *Server) handleCreateModelGroup(c *gin.Context) {
var group db.ModelGroup
if err := c.ShouldBindJSON(&group); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := s.database.Exec(`
INSERT INTO model_groups (id, strategy, selector_model, targets, complexity_threshold, heuristic_rules)
VALUES (?, ?, ?, ?, ?, ?)`,
group.ID, group.Strategy, group.SelectorModel, group.Targets,
group.ComplexityThreshold, group.HeuristicRules)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.refreshRouter()
c.JSON(http.StatusCreated, group)
}
func (s *Server) handleUpdateModelGroup(c *gin.Context) {
id := c.Param("id")
var group db.ModelGroup
if err := c.ShouldBindJSON(&group); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := s.database.Exec(`
UPDATE model_groups SET strategy=?, selector_model=?, targets=?, complexity_threshold=?, heuristic_rules=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
group.Strategy, group.SelectorModel, group.Targets,
group.ComplexityThreshold, group.HeuristicRules, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.refreshRouter()
c.JSON(http.StatusOK, group)
}
func (s *Server) handleDeleteModelGroup(c *gin.Context) {
id := c.Param("id")
_, err := s.database.Exec("DELETE FROM model_groups WHERE id=?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.refreshRouter()
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
+81
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"context"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -15,6 +16,7 @@ import (
"gophergate/internal/middleware" "gophergate/internal/middleware"
"gophergate/internal/models" "gophergate/internal/models"
"gophergate/internal/providers" "gophergate/internal/providers"
"gophergate/internal/router"
"gophergate/internal/utils" "gophergate/internal/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -30,6 +32,7 @@ type Server struct {
logger *RequestLogger logger *RequestLogger
registry *models.ModelRegistry registry *models.ModelRegistry
registryMu sync.RWMutex registryMu sync.RWMutex
modelRouter *router.Router
} }
func NewServer(cfg *config.Config, database *db.DB) *Server { func NewServer(cfg *config.Config, database *db.DB) *Server {
@@ -64,6 +67,9 @@ func NewServer(cfg *config.Config, database *db.DB) *Server {
} }
s.setupRoutes() s.setupRoutes()
// Initialize model group router
s.refreshRouter()
return s return s
} }
@@ -168,9 +174,51 @@ func (s *Server) RefreshProviders() error {
} }
} }
s.refreshRouter()
return nil return nil
} }
func (s *Server) refreshRouter() {
var groups []db.ModelGroup
if err := s.database.Select(&groups, "SELECT * FROM model_groups"); err != nil {
fmt.Printf("Warning: Failed to load model groups: %v\n", err)
groups = nil
}
var classifyFn router.ClassifierFunc
if openaiProvider, ok := s.providers["openai"]; ok {
classifyFn = func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error) {
req := &models.UnifiedRequest{
Model: selectorModel,
Messages: []models.UnifiedMessage{
{Role: "system", Content: []models.UnifiedContentPart{{Type: "text", Text: systemPrompt}}},
{Role: "user", Content: []models.UnifiedContentPart{{Type: "text", Text: userMessage}}},
},
MaxTokens: uint32Ptr(5),
Stream: false,
}
resp, err := openaiProvider.ChatCompletion(ctx, req)
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("no choices in classifier response")
}
content, ok := resp.Choices[0].Message.Content.(string)
if !ok {
return "", fmt.Errorf("classifier response content is not a string")
}
return content, nil
}
}
if s.modelRouter == nil {
s.modelRouter = router.New(groups, classifyFn)
} else {
s.modelRouter.Reload(groups)
}
}
func (s *Server) setupRoutes() { func (s *Server) setupRoutes() {
// Static files // Static files
s.router.StaticFile("/", "./static/index.html") s.router.StaticFile("/", "./static/index.html")
@@ -228,6 +276,11 @@ func (s *Server) setupRoutes() {
admin.GET("/models", s.handleGetModels) admin.GET("/models", s.handleGetModels)
admin.PUT("/models/:id", s.handleUpdateModel) admin.PUT("/models/:id", s.handleUpdateModel)
admin.GET("/model-groups", s.handleGetModelGroups)
admin.POST("/model-groups", s.handleCreateModelGroup)
admin.PUT("/model-groups/:id", s.handleUpdateModelGroup)
admin.DELETE("/model-groups/:id", s.handleDeleteModelGroup)
admin.GET("/users", s.handleGetUsers) admin.GET("/users", s.handleGetUsers)
admin.POST("/users", s.handleCreateUser) admin.POST("/users", s.handleCreateUser)
admin.PUT("/users/:id", s.handleUpdateUser) admin.PUT("/users/:id", s.handleUpdateUser)
@@ -474,6 +527,18 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
} }
} }
// Check if model is a group and route to a concrete model
if s.modelRouter != nil && s.modelRouter.IsGroup(modelID) {
userMessage := extractUserMessage(req.Messages)
decision, err := s.modelRouter.Route(c.Request.Context(), modelID, userMessage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)})
return
}
modelID = decision.SelectedModel
log.Printf("[ROUTER] %s -> %s (%s: %s)", req.Model, modelID, decision.Strategy, decision.Reason)
}
// Convert ChatCompletionRequest to UnifiedRequest // Convert ChatCompletionRequest to UnifiedRequest
unifiedReq := &models.UnifiedRequest{ unifiedReq := &models.UnifiedRequest{
Model: modelID, Model: modelID,
@@ -633,6 +698,20 @@ if unifiedReq.MaxTokens == nil {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
func extractUserMessage(messages []models.ChatMessage) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
switch c := messages[i].Content.(type) {
case string:
return c
default:
return ""
}
}
}
return ""
}
func (s *Server) handleImageGenerations(c *gin.Context) { func (s *Server) handleImageGenerations(c *gin.Context) {
startTime := time.Now() startTime := time.Now()
var req models.ImageGenerationRequest var req models.ImageGenerationRequest
@@ -799,3 +878,5 @@ func (s *Server) Run() error {
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port) addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
return s.router.Run(addr) return s.router.Run(addr)
} }
func uint32Ptr(v uint32) *uint32 { return &v }
+6 -1
View File
@@ -89,6 +89,10 @@
<i class="fas fa-brain"></i> <i class="fas fa-brain"></i>
<span>Models</span> <span>Models</span>
</li> </li>
<li class="menu-item" data-page="model-groups">
<i class="fas fa-code-branch"></i>
<span>Model Groups</span>
</li>
</ul> </ul>
</div> </div>
@@ -164,7 +168,7 @@
<script src="/js/auth.js?v=7"></script> <script src="/js/auth.js?v=7"></script>
<script src="/js/charts.js?v=7"></script> <script src="/js/charts.js?v=7"></script>
<script src="/js/websocket.js?v=7"></script> <script src="/js/websocket.js?v=7"></script>
<script src="/js/dashboard.js?v=7"></script> <script src="/js/dashboard.js?v=8"></script>
<!-- Page Modules --> <!-- Page Modules -->
<script src="/js/pages/overview.js?v=7"></script> <script src="/js/pages/overview.js?v=7"></script>
@@ -177,5 +181,6 @@
<script src="/js/pages/settings.js?v=7"></script> <script src="/js/pages/settings.js?v=7"></script>
<script src="/js/pages/logs.js?v=7"></script> <script src="/js/pages/logs.js?v=7"></script>
<script src="/js/pages/users.js?v=7"></script> <script src="/js/pages/users.js?v=7"></script>
<script src="/js/pages/model_groups.js?v=8"></script>
</body> </body>
</html> </html>
+6
View File
@@ -119,6 +119,7 @@ class Dashboard {
'settings': 'Settings', 'settings': 'Settings',
'logs': 'Logs', 'logs': 'Logs',
'models': 'Models', 'models': 'Models',
'model-groups': 'Model Groups',
'users': 'User Management' 'users': 'User Management'
}; };
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard'; if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
@@ -130,6 +131,11 @@ class Dashboard {
if (content) { if (content) {
content.innerHTML = await this.getPageTemplate(page); content.innerHTML = await this.getPageTemplate(page);
await this.initializePageScript(page); await this.initializePageScript(page);
// Model Groups page uses its own render method
if (page === 'model-groups' && typeof modelGroupsPage !== 'undefined') {
await modelGroupsPage.render();
}
} }
} catch (error) { } catch (error) {
console.error(`Error loading page ${page}:`, error); console.error(`Error loading page ${page}:`, error);
+168
View File
@@ -0,0 +1,168 @@
// Model Groups Management Page
class ModelGroupsPage {
constructor() {
this.container = document.getElementById('page-content');
}
async render() {
this.container.innerHTML = `
<div class="page-header">
<h3>Model Groups</h3>
<p class="text-muted">Define auto-routing groups that pick the best model for each request.</p>
<button class="btn btn-primary" onclick="modelGroupsPage.showCreateForm()">
<i class="fas fa-plus"></i> Add Group
</button>
</div>
<div id="model-groups-list" class="table-container"></div>
<div id="model-group-form" class="form-container" style="display:none;"></div>
`;
await this.loadGroups();
}
async loadGroups() {
try {
const groups = await api.get('/api/model-groups');
const list = document.getElementById('model-groups-list');
if (!groups || groups.length === 0) {
list.innerHTML = '<div class="empty-state">No model groups defined. Create one to enable auto-routing.</div>';
return;
}
let html = '<table class="data-table"><thead><tr>';
html += '<th>Group ID</th><th>Strategy</th><th>Targets</th><th>Actions</th>';
html += '</tr></thead><tbody>';
groups.forEach(g => {
html += '<tr>';
html += '<td><code>' + this.esc(g.id) + '</code></td>';
html += '<td><span class="badge">' + this.esc(g.strategy) + '</span></td>';
html += '<td><code>' + this.esc(g.targets) + '</code></td>';
html += '<td>';
html += '<button class="btn btn-sm" onclick="modelGroupsPage.showEditForm(\'' + this.esc(g.id) + '\')">Edit</button> ';
html += '<button class="btn btn-sm btn-danger" onclick="modelGroupsPage.deleteGroup(\'' + this.esc(g.id) + '\')">Delete</button>';
html += '</td></tr>';
});
html += '</tbody></table>';
list.innerHTML = html;
} catch (err) {
document.getElementById('model-groups-list').innerHTML =
'<div class="error-message">Failed to load model groups: ' + this.esc(err.message) + '</div>';
}
}
showCreateForm() {
this.renderForm(null);
}
async showEditForm(id) {
try {
const groups = await api.get('/api/model-groups');
const group = groups.find(g => g.id === id);
if (group) this.renderForm(group);
} catch (err) {
alert('Failed to load group: ' + err.message);
}
}
renderForm(group) {
const isEdit = !!group;
const form = document.getElementById('model-group-form');
form.style.display = 'block';
form.innerHTML = `
<h4>${isEdit ? 'Edit' : 'Create'} Model Group</h4>
<form onsubmit="modelGroupsPage.saveGroup(event, ${isEdit})">
<div class="form-control">
<label>Group ID</label>
<input type="text" id="mg-id" value="${this.esc(group ? group.id : '')}" ${isEdit ? 'readonly' : 'required'}
placeholder="e.g. deepseek-auto">
<small>Clients use this as the model name.</small>
</div>
<div class="form-control">
<label>Strategy</label>
<select id="mg-strategy">
<option value="heuristic" ${group && group.strategy === 'heuristic' ? 'selected' : ''}>Heuristic (rules-based)</option>
<option value="classifier" ${group && group.strategy === 'classifier' ? 'selected' : ''}>Classifier (LLM judge)</option>
</select>
</div>
<div class="form-control">
<label>Targets (JSON array)</label>
<input type="text" id="mg-targets" value='${this.esc(group ? group.targets : '["cheap-model","smart-model"]')}' required>
<small>First target = cheapest/fastest. Last target = smartest/most expensive.</small>
</div>
<div class="form-control" id="mg-selector-row" style="${group && group.strategy === 'classifier' ? '' : 'display:none'}">
<label>Selector Model</label>
<input type="text" id="mg-selector-model" value="${this.esc(group && group.selector_model ? group.selector_model : 'gpt-4o-mini')}"
placeholder="Model used to judge task complexity">
</div>
<div class="form-control" id="mg-threshold-row" style="${group && group.strategy === 'classifier' ? '' : 'display:none'}">
<label>Complexity Threshold</label>
<input type="number" id="mg-threshold" value="${group && group.complexity_threshold ? group.complexity_threshold : ''}" min="1"
placeholder="Tasks rated >= this go to the smart model">
</div>
<div class="form-control" id="mg-rules-row" style="${group && group.strategy === 'heuristic' ? '' : 'display:none'}">
<label>Heuristic Rules (JSON array)</label>
<textarea id="mg-rules" rows="4" placeholder='[{"pattern":"step by step","target":1}]'>${group && group.heuristic_rules ? group.heuristic_rules : ''}</textarea>
<small>Pattern to match in user messages. target = index into targets array.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="document.getElementById('model-group-form').style.display='none'">Cancel</button>
</div>
</form>
`;
document.getElementById('mg-strategy').onchange = function() {
var isClassifier = this.value === 'classifier';
document.getElementById('mg-selector-row').style.display = isClassifier ? '' : 'none';
document.getElementById('mg-threshold-row').style.display = isClassifier ? '' : 'none';
document.getElementById('mg-rules-row').style.display = isClassifier ? 'none' : '';
};
}
async saveGroup(event, isEdit) {
event.preventDefault();
var id = document.getElementById('mg-id').value.trim();
var strategy = document.getElementById('mg-strategy').value;
var targets = document.getElementById('mg-targets').value;
var selectorModel = document.getElementById('mg-selector-model').value.trim() || null;
var thresholdVal = document.getElementById('mg-threshold').value;
var rules = document.getElementById('mg-rules').value.trim() || null;
try { JSON.parse(targets); } catch (e) { alert('Targets must be valid JSON array'); return; }
if (rules) { try { JSON.parse(rules); } catch (e) { alert('Heuristic rules must be valid JSON'); return; } }
var body = { id: id, strategy: strategy, targets: targets, selector_model: selectorModel, heuristic_rules: rules };
if (thresholdVal) body.complexity_threshold = parseInt(thresholdVal);
try {
if (isEdit) {
await api.put('/api/model-groups/' + encodeURIComponent(id), body);
} else {
await api.post('/api/model-groups', body);
}
document.getElementById('model-group-form').style.display = 'none';
await this.loadGroups();
} catch (err) {
alert('Failed to save: ' + err.message);
}
}
async deleteGroup(id) {
if (!confirm('Delete model group "' + id + '"? This cannot be undone.')) return;
try {
await api.delete('/api/model-groups/' + encodeURIComponent(id));
await this.loadGroups();
} catch (err) {
alert('Failed to delete: ' + err.message);
}
}
esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
}
var modelGroupsPage = new ModelGroupsPage();