diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index c935d9c..855d293 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.Json; @@ -15,13 +16,14 @@ public static class ApiKeysEndpoint { public static void MapApiKeysEndpoints(this WebApplication app) { + // Admin-Endpoints app.MapGet("/api/admin/api-keys", HandleGetAll); app.MapPost("/api/admin/api-keys", HandleCreate); app.MapPut("/api/admin/api-keys/{keyId}", HandleUpdate); - app.MapPut("/api/admin/api-keys/{keyId}/activate", HandleActivate); - app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate); + app.MapPut("/api/admin/api-keys/{keyId}/public", HandleTogglePublic); app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete); app.MapPost("/api/admin/api-keys/{keyId}/check", HandleCheck); + app.MapPost("/api/admin/api-keys/check-all", HandleCheckAll); // Scan-Endpoints app.MapPost("/api/admin/api-keys/scan", HandleScanTrigger); @@ -30,6 +32,11 @@ public static class ApiKeysEndpoint app.MapPost("/api/admin/api-keys/scanned/{id}/recheck", HandleRecheck); app.MapPost("/api/admin/api-keys/scanned/{id}/adopt", HandleAdopt); app.MapDelete("/api/admin/api-keys/scanned/{id}", HandleDeleteScanned); + + // User-Endpoints (für alle authentifizierten User) + app.MapGet("/api/api-keys/available", HandleGetAvailable); + app.MapPut("/api/api-keys/select/{keyId}", HandleSelectKey); + app.MapGet("/api/api-keys/selected", HandleGetSelected); } private static async Task HandleGetAll(HttpContext ctx, DatabaseService db) @@ -43,7 +50,7 @@ public static class ApiKeysEndpoint k.Provider, k.Name, k.ApiKey, - k.IsActive, + k.IsPublic, k.Status, k.Plan, k.Usage7d, @@ -73,13 +80,15 @@ public static class ApiKeysEndpoint } var now = DateTime.UtcNow.ToString("o"); + var isPublic = body.TryGetProperty("isPublic", out var pub) && pub.GetBoolean(); + var record = new ApiKeyRecord { KeyId = Guid.NewGuid().ToString("N"), Provider = provider, Name = name, ApiKey = apiKey, - IsActive = false, + IsPublic = isPublic, CreatedAt = now, UpdatedAt = now }; @@ -91,12 +100,12 @@ public static class ApiKeysEndpoint record.Provider, record.Name, record.ApiKey, - record.IsActive, + record.IsPublic, record.CreatedAt }); } - private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, string keyId) { if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } @@ -116,15 +125,10 @@ public static class ApiKeysEndpoint name = existing.Name; await db.UpdateApiKey(keyId, name, apiKey); - - // Wenn aktiver Key geändert wird → Token im Service aktualisieren - if (existing.IsActive && existing.Provider == "anthropic" && !string.IsNullOrWhiteSpace(apiKey)) - anthropic.UpdateToken(apiKey); - await ctx.Response.WriteAsJsonAsync(new { ok = true }); } - private static async Task HandleActivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + private static async Task HandleTogglePublic(HttpContext ctx, DatabaseService db, string keyId) { if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } @@ -136,16 +140,12 @@ public static class ApiKeysEndpoint return; } - await db.SetActiveApiKey(keyId, existing.Provider); - - // Token im laufenden Service aktualisieren - if (existing.Provider == "anthropic") - anthropic.UpdateToken(existing.ApiKey); - - await ctx.Response.WriteAsJsonAsync(new { ok = true }); + var newPublic = !existing.IsPublic; + await db.SetApiKeyPublic(keyId, newPublic); + await ctx.Response.WriteAsJsonAsync(new { ok = true, isPublic = newPublic }); } - private static async Task HandleDeactivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) + private static async Task HandleDelete(HttpContext ctx, DatabaseService db, string keyId) { if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } @@ -157,31 +157,6 @@ public static class ApiKeysEndpoint return; } - await db.DeactivateApiKey(keyId); - - // Token im laufenden Service leeren - if (existing.Provider == "anthropic") - anthropic.UpdateToken(""); - - await ctx.Response.WriteAsJsonAsync(new { ok = true }); - } - - private static async Task HandleDelete(HttpContext ctx, DatabaseService db, AnthropicService anthropic, string keyId) - { - if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } - - var existing = await db.GetApiKey(keyId); - if (existing == null) - { - ctx.Response.StatusCode = 404; - await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); - return; - } - - // Wenn aktiver Key gelöscht wird → Token leeren - if (existing.IsActive && existing.Provider == "anthropic") - anthropic.UpdateToken(""); - await db.DeleteApiKey(keyId); await ctx.Response.WriteAsJsonAsync(new { ok = true }); } @@ -226,6 +201,25 @@ public static class ApiKeysEndpoint }); } + private static async Task HandleCheckAll(HttpContext ctx, IHttpClientFactory httpFactory, DatabaseService db) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + + var allKeys = await db.GetApiKeys(); + var anthropicKeys = allKeys.Where(k => k.Provider == "anthropic").ToList(); + + var results = new List(); + foreach (var key in anthropicKeys) + { + var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey); + var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status; + await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h); + results.Add(new { key.KeyId, key.Name, status = dbStatus, result.Plan, result.Usage5h, result.Usage7d }); + } + + await ctx.Response.WriteAsJsonAsync(new { checked_ = results.Count, results }); + } + // ── Scan-Endpoints ── private static Task HandleScanTrigger(HttpContext ctx, KeyScannerService scanner) @@ -350,7 +344,7 @@ public static class ApiKeysEndpoint Provider = "anthropic", Name = title, ApiKey = sk.ApiKey, - IsActive = false, + IsPublic = false, Status = sk.Status, Plan = sk.Plan, Usage7d = sk.Usage7d, @@ -374,4 +368,80 @@ public static class ApiKeysEndpoint await ctx.Response.WriteAsJsonAsync(new { ok = true }); } + // ── User-Endpoints ── + + private static async Task HandleGetAvailable(HttpContext ctx, DatabaseService db) + { + var userId = ctx.GetUserId(); + if (userId == null) { ctx.Response.StatusCode = 401; return; } + + var isAdmin = ctx.IsAdmin(); + var keys = isAdmin ? await db.GetApiKeys() : await db.GetPublicApiKeys(); + + var result = keys.Select(k => new + { + k.KeyId, + k.Provider, + k.Name, + k.IsPublic, + k.Status, + k.Plan, + k.Usage7d, + k.Usage5h, + k.CheckedAt + }); + await ctx.Response.WriteAsJsonAsync(result); + } + + private static async Task HandleSelectKey(HttpContext ctx, DatabaseService db, string keyId) + { + var userId = ctx.GetUserId(); + if (userId == null) { ctx.Response.StatusCode = 401; return; } + + var key = await db.GetApiKey(keyId); + if (key == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); + return; + } + + // Nicht-Admins dürfen nur public Keys auswählen + if (!ctx.IsAdmin() && !key.IsPublic) + { + ctx.Response.StatusCode = 403; + await ctx.Response.WriteAsJsonAsync(new { error = "Key ist nicht öffentlich" }); + return; + } + + await db.SetUserSelectedApiKey(userId, keyId); + await ctx.Response.WriteAsJsonAsync(new { ok = true, keyId, name = key.Name }); + } + + private static async Task HandleGetSelected(HttpContext ctx, DatabaseService db) + { + var userId = ctx.GetUserId(); + if (userId == null) { ctx.Response.StatusCode = 401; return; } + + var key = await db.GetUserSelectedApiKey(userId); + if (key == null) + { + await ctx.Response.WriteAsJsonAsync(new { selected = false }); + return; + } + + await ctx.Response.WriteAsJsonAsync(new + { + selected = true, + keyId = key.KeyId, + provider = key.Provider, + name = key.Name, + isPublic = key.IsPublic, + status = key.Status, + plan = key.Plan, + usage5h = key.Usage5h, + usage7d = key.Usage7d + }); + } + } diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index e361010..4bf8513 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -51,6 +51,16 @@ public static class ChatEndpoint } var userId = req.HttpContext.GetUserId(); + + // API-Key des Users auflösen + var userApiKey = await db.GetUserSelectedApiKey(userId); + if (userApiKey == null) + { + res.StatusCode = 400; + await res.WriteAsJsonAsync(new { error = "Kein API-Key ausgewählt. Bitte wähle einen Key aus." }); + return; + } + var maxIterations = config.GetValue("MaxIterations", 100); var hasRemote = remoteMgr.GetBySessionId(sessionId) != null; @@ -142,7 +152,7 @@ public static class ChatEndpoint var preparerHistory = await chatService.BuildHistoryFromDb(sessionId, includeTag: "preparer", promptBlocks: ctx.PromptBlocks); var preparerResult = await anthropic.RunPreparer( - res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, cancellationToken); + res, ctx.Preparer, preparerHistory, ctx.ChainId, ctx.Workspace, sessionId, userApiKey.ApiKey, cancellationToken); if (!preparerResult.ShouldForward) { @@ -216,7 +226,7 @@ public static class ChatEndpoint else { // Standard: Anthropic - await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); + await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, apiToken: userApiKey.ApiKey, sessionId: sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); } } finally diff --git a/Models/ApiKeyRecord.cs b/Models/ApiKeyRecord.cs index b7d6b38..7f2b570 100644 --- a/Models/ApiKeyRecord.cs +++ b/Models/ApiKeyRecord.cs @@ -7,7 +7,7 @@ public class ApiKeyRecord public string Provider { get; set; } // anthropic, gemini, openai, ... public string Name { get; set; } // Anzeigename z.B. "Tausi Pro Key" public string ApiKey { get; set; } // Der eigentliche Key - public bool IsActive { get; set; } // Nur einer pro Provider aktiv + public bool IsPublic { get; set; } // Für alle User sichtbar/auswählbar public string Status { get; set; } // ok, invalid, rate_limited, quota_exceeded, error public string Plan { get; set; } // Pro, Max, Pro/Max, API Console, Unknown public double? Usage7d { get; set; } diff --git a/Models/ChatContext.cs b/Models/ChatContext.cs index 2b4804b..ab295a6 100644 --- a/Models/ChatContext.cs +++ b/Models/ChatContext.cs @@ -21,6 +21,7 @@ public class ChatContext public AgentRecord Agent { get; set; } public SessionRecord Session { get; set; } public bool PlanMode { get; set; } + public string ApiToken { get; set; } // Preparer-Agent (vor dem Worker) public SubAgentContext Preparer { get; set; } diff --git a/Models/UserRecord.cs b/Models/UserRecord.cs index 0551834..aab5b66 100644 --- a/Models/UserRecord.cs +++ b/Models/UserRecord.cs @@ -13,6 +13,7 @@ public class UserRecord public string Persona { get; set; } public string Settings { get; set; } public string DefaultPath { get; set; } + public string SelectedApiKeyId { get; set; } public string GetEffectivePersona() { diff --git a/Program.cs b/Program.cs index 4b3fe23..cc3ee07 100644 --- a/Program.cs +++ b/Program.cs @@ -66,21 +66,14 @@ var embeddingBgService = new Gott.Services.EmbeddingBackgroundService(vectorSear builder.Services.AddSingleton(embeddingBgService); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService) -// Token wird aus DB geladen (api_keys Tabelle), Fallback auf Config -var anthropicToken = ""; -var activeKey = dbService.GetActiveApiKey("anthropic").GetAwaiter().GetResult(); -if (activeKey != null) - anthropicToken = activeKey.ApiKey; -else if (!string.IsNullOrWhiteSpace(builder.Configuration["AnthropicToken"])) - anthropicToken = builder.Configuration["AnthropicToken"]; +// AnthropicService (Singleton – Token wird pro Request vom User aufgelöst) var payloadLogging = builder.Configuration.GetValue("PayloadLogging", false); var payloadLoggingGzip = builder.Configuration.GetValue("PayloadLoggingGzip", false); builder.Services.AddSingleton(sp => { var svc = new AnthropicService( sp.GetRequiredService(), - anthropicToken, toolService, dbService, memoryService, + toolService, dbService, memoryService, payloadLogging, payloadLoggingGzip, vectorSearch: vectorSearchService, embeddingBg: embeddingBgService); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index b6d5a83..96d3855 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -16,7 +16,6 @@ namespace Gott.Services; public class AnthropicService { private readonly IHttpClientFactory _httpFactory; - private string _token; private readonly ToolService _toolService; private readonly DatabaseService _db; private readonly MemoryService _memoryService; @@ -27,10 +26,9 @@ public class AnthropicService private ChatService _chatService; private TelegramService _telegramService; - public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) + public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) { _httpFactory = httpFactory; - _token = token; _toolService = toolService; _db = db; _memoryService = memoryService; @@ -54,25 +52,17 @@ public class AnthropicService _telegramService = telegramService; } - /// - /// Aktualisiert den API-Token zur Laufzeit (z.B. wenn ein anderer Key aktiviert wird). - /// - public void UpdateToken(string token) - { - _token = token ?? ""; - } - /// /// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle. /// Fire-and-forget – Fehler werden stillschweigend ignoriert. /// - private void TryUpdateKeyMeta(HttpResponseMessage httpResp) + private void TryUpdateKeyMeta(HttpResponseMessage httpResp, string apiToken) { try { var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers); - if (meta == null || string.IsNullOrEmpty(_token)) return; - _ = _db.UpdateApiKeyMetaByToken(_token, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h); + if (meta == null || string.IsNullOrEmpty(apiToken)) return; + _ = _db.UpdateApiKeyMetaByToken(apiToken, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h); } catch { /* silent */ } } @@ -84,6 +74,7 @@ public class AnthropicService List messages, string chainId, string workspace, + string apiToken, string sessionId = null, string agentId = null, string userId = null, @@ -181,7 +172,7 @@ public class AnthropicService var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null; AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote, includeGitHubSearch: useGitHubSearch && _toolService.HasGitHubToken, includeSerperSearch: useSerperSearch && _toolService.HasSerperKey); - var client = CreateClient(); + var client = CreateClient(apiToken); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); LogPayload(json, sessionId, chainId, iterations); var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); @@ -192,7 +183,7 @@ public class AnthropicService workCt ); - TryUpdateKeyMeta(httpResp); + TryUpdateKeyMeta(httpResp, apiToken); if (!httpResp.IsSuccessStatusCode) { @@ -227,7 +218,7 @@ public class AnthropicService return; } - var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId); + var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId, userId, apiToken); lastStopReason = stopReason; promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten @@ -311,7 +302,7 @@ public class AnthropicService // ── Review-Agent Phase ── if (review != null && stopReason == "end_turn" && reviewRound < review.MaxReviewIterations) { - var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, usage, iterations, reviewRound, workCt, sseCt); + var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, apiToken, usage, iterations, reviewRound, workCt, sseCt); iterations += reviewResult.ReviewIterationCount; reviewRound++; @@ -336,7 +327,7 @@ public class AnthropicService // ── Finalizer-Agent Phase ── if (finalizer != null && stopReason == "end_turn") { - var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, usage, iterations, workCt, sseCt); + var finalizerIterations = await RunFinalizer(response, finalizer, messages, chainId, workspace, sessionId, apiToken, usage, iterations, workCt, sseCt); iterations += finalizerIterations; } @@ -467,6 +458,7 @@ public class AnthropicService string chainId, string workspace, string sessionId, + string apiToken, CancellationToken cancellationToken) { // Preparer läuft VOR dem Worker und nutzt den Request-CancellationToken direkt. @@ -487,7 +479,7 @@ public class AnthropicService var (text, iterations) = await RunAgentChat( response, preparer.ModelId, preparer.SystemPrompt, preparer.AgentId, preparer.Workspace, preparer.UseMemory, preparer.WebSearchVersion, - preparer.ToolChoice, preparerMessages, chainId, sessionId, usage, + preparer.ToolChoice, preparerMessages, chainId, sessionId, apiToken, usage, 0, "preparer", preparer.Agent?.Name ?? "Vorbereiter-Agent", cancellationToken, cancellationToken); @@ -555,6 +547,7 @@ public class AnthropicService string chainId, string workspace, string sessionId, + string apiToken, TokenUsage usage, int baseIterations, int reviewRound, @@ -579,7 +572,7 @@ public class AnthropicService var (reviewText, reviewIterations) = await RunAgentChat( response, review.ModelId, review.SystemPrompt, review.AgentId, workspace, review.UseMemory, review.WebSearchVersion, - review.ToolChoice, reviewMessages, chainId, sessionId, usage, + review.ToolChoice, reviewMessages, chainId, sessionId, apiToken, usage, baseIterations, "review", review.Agent?.Name ?? "Review-Agent", workCt, sseCt); @@ -630,6 +623,7 @@ public class AnthropicService string chainId, string workspace, string sessionId, + string apiToken, TokenUsage usage, int baseIterations, CancellationToken workCt, @@ -647,7 +641,7 @@ public class AnthropicService var (text, iterations) = await RunAgentChat( response, finalizer.ModelId, finalizer.SystemPrompt, finalizer.AgentId, finalizer.Workspace, finalizer.UseMemory, finalizer.WebSearchVersion, - finalizer.ToolChoice, finalizerMessages, chainId, sessionId, usage, + finalizer.ToolChoice, finalizerMessages, chainId, sessionId, apiToken, usage, baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent", workCt, sseCt); @@ -794,6 +788,7 @@ public class AnthropicService List messages, string chainId, string sessionId, + string apiToken, TokenUsage usage, int baseIterations, string agentType, @@ -825,7 +820,7 @@ public class AnthropicService AddToolsToPayload(payload, agentWorkspace, agentId, useMemory, webSearchVersion, agentToolChoice, iterations); - var client = CreateClient(); + var client = CreateClient(apiToken); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); LogPayload(json, sessionId, chainId, baseIterations + iterations); var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); @@ -834,7 +829,7 @@ public class AnthropicService new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }, cancellationToken: workCt); - TryUpdateKeyMeta(httpResp); + TryUpdateKeyMeta(httpResp, apiToken); var respBody = await httpResp.Content.ReadAsStringAsync(workCt); @@ -925,7 +920,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, telegramService: _telegramService); } + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, telegramService: _telegramService, apiToken: apiToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1047,7 +1042,7 @@ public class AnthropicService AddToolsToPayload(payload, ctx.Workspace, ctx.AgentId, ctx.UseMemory, ctx.WebSearchVersion, ctx.ToolChoice, iterations); - var client = CreateClient(); + var client = CreateClient(ctx.ApiToken); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); LogPayload(json, ctx.SessionId, ctx.ChainId, iterations); var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); @@ -1055,7 +1050,7 @@ public class AnthropicService using var httpResp = await client.SendAsync( new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }); - TryUpdateKeyMeta(httpResp); + TryUpdateKeyMeta(httpResp, ctx.ApiToken); var respBody = await httpResp.Content.ReadAsStringAsync(); @@ -1133,7 +1128,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: ctx.Session?.UserId, telegramService: _telegramService); } + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: ctx.Session?.UserId, telegramService: _telegramService, apiToken: ctx.ApiToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1243,7 +1238,8 @@ public class AnthropicService CancellationToken sseCt = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, - string userId = null) + string userId = null, + string apiToken = null) { var stream = await httpResp.Content.ReadAsStreamAsync(workCt); // Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht. @@ -1425,7 +1421,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: userId, telegramService: _telegramService); } + try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch, embeddingBg: _embeddingBg, db: _db, chatService: _chatService, anthropicService: this, userId: userId, telegramService: _telegramService, apiToken: apiToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1694,10 +1690,10 @@ public class AnthropicService return 0; } - private HttpClient CreateClient() + private HttpClient CreateClient(string apiToken) { var client = _httpFactory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,code-execution-2025-08-25"); client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)"); diff --git a/Services/CronjobService.cs b/Services/CronjobService.cs index 27e6141..cb2008f 100644 --- a/Services/CronjobService.cs +++ b/Services/CronjobService.cs @@ -86,6 +86,12 @@ public class CronjobService : BackgroundService if (ctx == null) throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert"); + // API-Key des Cronjob-Owners auflösen + var userApiKey = await _db.GetUserSelectedApiKey(job.UserId); + if (userApiKey == null) + throw new Exception($"Kein API-Key für User {job.UserId} ausgewählt"); + ctx.ApiToken = userApiKey.ApiKey; + // History aus DB laden var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks); @@ -134,6 +140,15 @@ public class CronjobService : BackgroundService return; } + // API-Key des Cronjob-Owners auflösen + var notifyApiKey = await _db.GetUserSelectedApiKey(job.UserId); + if (notifyApiKey == null) + { + _logger.LogWarning("Kein API-Key für User {UserId} (Cronjob '{Name}' Notification)", job.UserId, job.Name); + return; + } + notifyCtx.ApiToken = notifyApiKey.ApiKey; + // Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}"; diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs index a12860f..83a82dd 100644 --- a/Services/DatabaseService.Integrations.cs +++ b/Services/DatabaseService.Integrations.cs @@ -324,7 +324,7 @@ public partial class DatabaseService { var now = DateTime.UtcNow.ToString("o"); conn.Execute(@" - INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) + INSERT INTO api_keys (key_id, provider, name, api_key, is_public, created_at, updated_at) VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)", new { keyId = Guid.NewGuid().ToString("N"), token, now }); Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert"); @@ -351,12 +351,11 @@ public partial class DatabaseService new { provider }); } - public async Task GetActiveApiKey(string provider) + public async Task> GetPublicApiKeys() { using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1", - new { provider }); + return await conn.QueryAsync( + "SELECT * FROM api_keys WHERE is_public = 1 ORDER BY provider, name"); } public async Task GetApiKey(string keyId) @@ -372,8 +371,8 @@ public partial class DatabaseService { using var conn = CreateConnection(); await conn.ExecuteAsync(@" - INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at) - VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)", + INSERT INTO api_keys (key_id, provider, name, api_key, is_public, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at) + VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsPublic, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)", record); return await conn.ExecuteScalarAsync("SELECT last_insert_rowid()"); } @@ -409,29 +408,16 @@ public partial class DatabaseService using var conn = CreateConnection(); await conn.ExecuteAsync(@" UPDATE api_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now, updated_at = @now - WHERE api_key = @apiKey AND is_active = 1", + WHERE api_key = @apiKey", new { apiKey, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") }); } - public async Task SetActiveApiKey(string keyId, string provider) - { - using var conn = CreateConnection(); - // Erst alle deaktivieren - await conn.ExecuteAsync( - "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider", - new { provider, now = DateTime.UtcNow.ToString("o") }); - // Dann den gewählten aktivieren - await conn.ExecuteAsync( - "UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId", - new { keyId, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task DeactivateApiKey(string keyId) + public async Task SetApiKeyPublic(string keyId, bool isPublic) { using var conn = CreateConnection(); await conn.ExecuteAsync( - "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId", - new { keyId, now = DateTime.UtcNow.ToString("o") }); + "UPDATE api_keys SET is_public = @isPublic, updated_at = @now WHERE key_id = @keyId", + new { keyId, isPublic, now = DateTime.UtcNow.ToString("o") }); } public async Task DeleteApiKey(string keyId) diff --git a/Services/DatabaseService.Users.cs b/Services/DatabaseService.Users.cs index d0fbfa3..9976e61 100644 --- a/Services/DatabaseService.Users.cs +++ b/Services/DatabaseService.Users.cs @@ -187,4 +187,32 @@ public partial class DatabaseService await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId }); } + // ── User API-Key Auswahl ── + + public async Task SetUserSelectedApiKey(string userId, string keyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE users SET selected_api_key_id = @keyId, updated_at = @now WHERE user_id = @userId", + new { userId, keyId = (object)keyId ?? DBNull.Value, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task GetUserSelectedApiKeyId(string userId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT selected_api_key_id FROM users WHERE user_id = @userId", + new { userId }); + } + + public async Task GetUserSelectedApiKey(string userId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync(@" + SELECT k.* FROM api_keys k + INNER JOIN users u ON u.selected_api_key_id = k.key_id + WHERE u.user_id = @userId", + new { userId }); + } + } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index b5faf0e..c1b8883 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -341,6 +341,10 @@ public partial class DatabaseService TryAddColumn(conn, "api_keys", "usage_5h", "REAL"); TryAddColumn(conn, "api_keys", "checked_at", "TEXT"); TryAddColumn(conn, "api_keys", "source_url", "TEXT"); + TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0"); + + // users: ausgewählter API-Key + TryAddColumn(conn, "users", "selected_api_key_id", "TEXT"); // Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren MigrateImagesToWebP(conn); diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs index 31f5118..8626041 100644 --- a/Services/TelegramService.cs +++ b/Services/TelegramService.cs @@ -80,7 +80,7 @@ public class TelegramService return; } - await HandleMessage(chatId, text, bot.BotToken, session); + await HandleMessage(chatId, text, bot.BotToken, bot.UserId, session); } private async Task HandleStart(long chatId, TelegramBotRecord bot, SessionRecord session) @@ -159,15 +159,24 @@ public class TelegramService // ── Nachricht an Claude weiterleiten ── - private async Task HandleMessage(long chatId, string userText, string botToken, SessionRecord session) + private async Task HandleMessage(long chatId, string userText, string botToken, string botUserId, SessionRecord session) { - var ctx = await _chatService.PrepareContext(session.SessionId, null, _maxIterations); + var ctx = await _chatService.PrepareContext(session.SessionId, botUserId, _maxIterations); if (ctx == null) { await SendMessage(chatId, botToken, "❌ Kein Agent konfiguriert. Bitte weise der Session im Web-UI einen Agent zu."); return; } + // API-Key des Bot-Owners auflösen + var userApiKey = await _db.GetUserSelectedApiKey(botUserId); + if (userApiKey == null) + { + await SendMessage(chatId, botToken, "❌ Kein API-Key ausgewählt. Der Bot-Besitzer muss im Web-UI einen Key auswählen."); + return; + } + ctx.ApiToken = userApiKey.ApiKey; + await SendChatAction(chatId, botToken, "typing"); try diff --git a/Services/ToolService.cs b/Services/ToolService.cs index ba51ff0..de38ee2 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -391,7 +391,7 @@ public class ToolService }; } - public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, string userId = null, TelegramService telegramService = null) + public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null, DatabaseService db = null, ChatService chatService = null, AnthropicService anthropicService = null, string userId = null, TelegramService telegramService = null, string apiToken = null) { switch (name) { @@ -765,9 +765,10 @@ public class ToolService return "Fehler: Kann keine Nachricht an die eigene Session senden"; // Context für Ziel-Session vorbereiten (analog zu CronjobService) - var ctx = await chatService.PrepareContext(targetSessionId, userId: null); + var ctx = await chatService.PrepareContext(targetSessionId, userId: userId); if (ctx == null) return $"Fehler: Session '{targetSession.Name}' ist nicht konfiguriert (kein Agent zugewiesen?)"; + ctx.ApiToken = apiToken; // History aufbauen – User-Message NICHT in DB speichern (wie Cronjob) // Stattdessen als kontextualisierte Nachricht in die History injizieren diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index f4e31ab..1a95e65 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -45,6 +45,14 @@ border-color: var(--f3-border); } +.chat-usage-overlay .usage-badge-nodata { + flex-direction: row; + align-items: center; + width: auto; + gap: var(--badge-spacing); + padding: var(--box-padding); +} + #chat { flex: 1; overflow-y: auto; diff --git a/wwwroot/index.html b/wwwroot/index.html index 43de231..498d926 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -559,6 +559,7 @@

API-Key

+
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 900569d..c12e9a7 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -219,10 +219,16 @@ function apiSaveServerSettings(data) { return _put('/api/admin/server-settings', function apiGetApiKeys() { return _get('/api/admin/api-keys'); } function apiCreateApiKey(data) { return _post('/api/admin/api-keys', data, 'Fehler beim Erstellen'); } function apiUpdateApiKey(id, data) { return _put('/api/admin/api-keys/' + id, data, 'Fehler beim Aktualisieren'); } -function apiActivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/activate', undefined, 'Fehler beim Aktivieren'); } -function apiDeactivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/deactivate', undefined, 'Fehler beim Deaktivieren'); } +function apiToggleApiKeyPublic(id) { return _put('/api/admin/api-keys/' + id + '/public', undefined, 'Fehler beim Umschalten'); } + +// ── User Key Selection API ── + +function apiGetAvailableKeys() { return _get('/api/api-keys/available', []); } +function apiSelectApiKey(keyId) { return _put('/api/api-keys/select/' + keyId, undefined, 'Fehler beim Auswählen'); } +function apiGetSelectedKey() { return _get('/api/api-keys/selected', null); } function apiDeleteApiKey(id) { return _del('/api/admin/api-keys/' + id, 'Fehler beim Löschen'); } function apiCheckApiKey(id) { return _post('/api/admin/api-keys/' + id + '/check', undefined, 'Fehler beim Prüfen'); } +function apiCheckAllApiKeys() { return _post('/api/admin/api-keys/check-all', undefined, 'Fehler beim Prüfen'); } // ── Key Scanner API (Admin) ── diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js index 597b6c4..f378087 100644 --- a/wwwroot/js/pages/api-keys-page.js +++ b/wwwroot/js/pages/api-keys-page.js @@ -63,6 +63,7 @@ registerPage('api-keys', { var list = document.getElementById('apiKeysList'); var newBtn = document.getElementById('apiKeysNewBtn'); + var checkAllBtn = document.getElementById('apiKeysCheckAllBtn'); var form = document.getElementById('apiKeysForm'); var formTitle = document.getElementById('apiKeysFormTitle'); var providerSelect = document.getElementById('apiKeysProvider'); @@ -119,21 +120,14 @@ registerPage('api-keys', { var badgeRow = document.createElement('div'); badgeRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:2px'; - if (key.isActive) { - var deactivateBtn = document.createElement('button'); - deactivateBtn.className = 'icon-btn'; - deactivateBtn.title = 'Deaktivieren'; - deactivateBtn.innerHTML = ''; - deactivateBtn.addEventListener('click', function() { deactivateKey(key); }); - badgeRow.appendChild(deactivateBtn); - } else { - var activateBtn = document.createElement('button'); - activateBtn.className = 'icon-btn'; - activateBtn.title = 'Aktivieren'; - activateBtn.innerHTML = ''; - activateBtn.addEventListener('click', function() { activateKey(key); }); - badgeRow.appendChild(activateBtn); - } + var publicBtn = document.createElement('button'); + publicBtn.className = 'icon-btn'; + publicBtn.title = key.isPublic ? 'Öffentlich (klicken zum Privatschalten)' : 'Privat (klicken zum Veröffentlichen)'; + publicBtn.innerHTML = key.isPublic + ? '' + : ''; + publicBtn.addEventListener('click', function() { togglePublic(key); }); + badgeRow.appendChild(publicBtn); var usageBadge = createUsageBadge(key.usage5h, key.usage7d); if (usageBadge) badgeRow.appendChild(usageBadge); @@ -234,20 +228,10 @@ registerPage('api-keys', { nameInput.focus(); } - async function activateKey(key) { + async function togglePublic(key) { try { - await apiActivateApiKey(key.keyId); - showToast(key.name + ' aktiviert'); - await loadKeys(); - } catch (err) { - showToast(err.message, 'error'); - } - } - - async function deactivateKey(key) { - try { - await apiDeactivateApiKey(key.keyId); - showToast(key.name + ' deaktiviert'); + await apiToggleApiKeyPublic(key.keyId); + showToast(key.name + (key.isPublic ? ' privat geschaltet' : ' veröffentlicht')); await loadKeys(); } catch (err) { showToast(err.message, 'error'); @@ -265,6 +249,19 @@ registerPage('api-keys', { } } + // Alle Keys prüfen + checkAllBtn.addEventListener('click', function() { + withButtonLoading(checkAllBtn, 'Alle prüfen', async function() { + try { + var result = await apiCheckAllApiKeys(); + showToast(result.checked_ + ' Keys geprüft'); + await loadKeys(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + // Neuer Key newBtn.addEventListener('click', function() { editingKeyId = null; diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 95dacc7..98f7ee5 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -44,17 +44,25 @@ function _createChatUsageBadge(usage5h, usage7d) { } async function _initChatUsageBadge() { - if (!currentUser || !currentUser.isAdmin) return; + if (!currentUser) return; var container = document.getElementById('chatUsageBadge'); if (!container) return; try { - var keys = await apiGetApiKeys(); - var activeKey = keys.find(function(k) { return k.isActive; }); - if (!activeKey) { container.hidden = true; return; } + var selectedKey = await apiGetSelectedKey(); + if (!selectedKey || !selectedKey.selected) { + container.innerHTML = ''; + var noKeyBtn = document.createElement('button'); + noKeyBtn.className = 'chat-usage-nokey'; + noKeyBtn.innerHTML = ' Key auswählen'; + noKeyBtn.addEventListener('click', function() { _showKeySwitchModal(); }); + container.appendChild(noKeyBtn); + container.hidden = false; + return; + } - _chatUsageKeyId = activeKey.keyId; - _renderChatUsageBadge(container, activeKey); + _chatUsageKeyId = selectedKey.keyId; + _renderChatUsageBadge(container, selectedKey); container.hidden = false; // Scroll-Listener: Badge ausblenden wenn User nach oben scrollt @@ -73,13 +81,12 @@ async function _initChatUsageBadge() { } async function _refreshChatUsageBadge() { - if (!currentUser || !currentUser.isAdmin) return; + if (!currentUser) return; var container = document.getElementById('chatUsageBadge'); if (!container || !_chatUsageKeyId) return; try { - var keys = await apiGetApiKeys(); - var activeKey = keys.find(function(k) { return k.isActive; }); - if (activeKey) _renderChatUsageBadge(container, activeKey); + var selectedKey = await apiGetSelectedKey(); + if (selectedKey) _renderChatUsageBadge(container, selectedKey); } catch(e) { /* ignore */ } } @@ -87,52 +94,68 @@ function _renderChatUsageBadge(container, key) { container.innerHTML = ''; var badge = _createChatUsageBadge(key.usage5h, key.usage7d); - if (badge) { - badge.addEventListener('click', function(e) { - e.stopPropagation(); - _showKeySwitchModal(); - }); - badge.title = (key.name || 'API Key') + ' · ' + (key.plan || '?'); - container.appendChild(badge); + if (!badge) { + badge = document.createElement('div'); + badge.className = 'usage-badge usage-badge-nodata'; + badge.innerHTML = ' ' + (key.name || 'API Key'); } - - var checkBtn = document.createElement('button'); - checkBtn.className = 'chat-usage-check icon-btn'; - checkBtn.innerHTML = ''; - checkBtn.title = 'Key prüfen'; - checkBtn.addEventListener('click', async function(e) { + badge.addEventListener('click', function(e) { e.stopPropagation(); - checkBtn.disabled = true; - checkBtn.querySelector('i').className = 'fa fa-spinner fa-spin'; - try { - var result = await apiCheckApiKey(_chatUsageKeyId); - if (result) { - var keys = await apiGetApiKeys(); - var activeKey = keys.find(function(k) { return k.isActive; }); - if (activeKey) _renderChatUsageBadge(container, activeKey); - } - } catch(e) { - console.error('Key check failed:', e); - } - checkBtn.disabled = false; - checkBtn.querySelector('i').className = 'fa fa-refresh'; + _showKeySwitchModal(); }); - container.appendChild(checkBtn); + badge.title = (key.name || 'API Key') + ' · ' + (key.plan || '?'); + badge.style.cursor = 'pointer'; + container.appendChild(badge); + + // Check-Button nur für Admins + if (currentUser && currentUser.isAdmin) { + var checkBtn = document.createElement('button'); + checkBtn.className = 'chat-usage-check icon-btn'; + checkBtn.innerHTML = ''; + checkBtn.title = 'Key prüfen'; + checkBtn.addEventListener('click', async function(e) { + e.stopPropagation(); + checkBtn.disabled = true; + checkBtn.querySelector('i').className = 'fa fa-spinner fa-spin'; + try { + await apiCheckApiKey(_chatUsageKeyId); + var selectedKey = await apiGetSelectedKey(); + if (selectedKey) _renderChatUsageBadge(container, selectedKey); + } catch(e) { + console.error('Key check failed:', e); + } + checkBtn.disabled = false; + checkBtn.querySelector('i').className = 'fa fa-refresh'; + }); + container.appendChild(checkBtn); + } } async function _showKeySwitchModal() { var keys = []; - try { keys = await apiGetApiKeys(); } catch(e) { return; } + try { keys = await apiGetAvailableKeys(); } catch(e) { return; } + + var selectedKey = null; + try { selectedKey = await apiGetSelectedKey(); } catch(e) { /* ignore */ } + var selectedKeyId = selectedKey ? selectedKey.keyId : null; var { body, close } = _createModal('API Key wechseln', { modal: 'key-switch-modal' }); + if (keys.length === 0) { + var empty = document.createElement('div'); + empty.style.cssText = 'padding:1rem;text-align:center;opacity:0.7'; + empty.textContent = 'Keine Keys verfügbar. Ein Admin muss Keys als öffentlich markieren.'; + body.appendChild(empty); + return; + } + var list = document.createElement('div'); list.className = 'key-switch-list'; keys.forEach(function(key) { var row = document.createElement('div'); row.className = 'key-switch-row'; - if (key.isActive) row.classList.add('active'); + if (key.keyId === selectedKeyId) row.classList.add('active'); var info = document.createElement('div'); info.className = 'key-switch-info'; @@ -157,15 +180,13 @@ async function _showKeySwitchModal() { row.appendChild(badge); } - if (!key.isActive) { + if (key.keyId !== selectedKeyId) { row.style.cursor = 'pointer'; row.addEventListener('click', async function() { row.style.opacity = '0.5'; row.style.pointerEvents = 'none'; try { - var activeKey = keys.find(function(k) { return k.isActive; }); - if (activeKey) await apiDeactivateApiKey(activeKey.keyId); - await apiActivateApiKey(key.keyId); + await apiSelectApiKey(key.keyId); close(); await _initChatUsageBadge(); } catch(e) {