User-spezifische API-Key-Auswahl, globales is_active entfernt
Backend:
- is_active komplett entfernt, durch is_public (Admin-Toggle) ersetzt
- Neues selected_api_key_id auf users-Tabelle
- AnthropicService: globaler _token entfernt, apiToken pro Request
- Token-Durchreichung: StreamChat → ProcessStream → RunAgentChat →
RunPreparer/RunReview/RunFinalizer, ExecuteTool, send_to_session
- ChatEndpoint: User-Key aus DB laden, an StreamChat übergeben
- TelegramService: Key per bot.UserId auflösen
- CronjobService: Key per job.UserId auflösen
- Neue Endpoints: GET /api/api-keys/available, PUT /api/api-keys/select/{keyId},
GET /api/api-keys/selected, PUT /api/admin/api-keys/{keyId}/public,
POST /api/admin/api-keys/check-all
- Program.cs: AnthropicService ohne Token-Parameter
Frontend:
- API-Keys-Page: Public-Toggle (Globe/Lock) statt Activate/Deactivate
- Alle prüfen Button für Batch-Key-Check
- Chat: Key-Auswahl für alle User (nicht nur Admin)
- Dummy-Badge wenn kein Key ausgewählt
- Fallback-Badge bei Key ohne Usage-Daten
- Key-Switch-Modal: apiGetAvailableKeys + apiSelectApiKey
This commit is contained in:
1 parent
cc46cf2bbc
commit
dde0fede7d
18 files changed
+340
-193
No files matched your search
+116
-46
@@ -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<object>();
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<int>("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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+2
-9
@@ -66,21 +66,14 @@ var embeddingBgService = new Gott.Services.EmbeddingBackgroundService(vectorSear
|
||||
builder.Services.AddSingleton(embeddingBgService);
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<Gott.Services.EmbeddingBackgroundService>());
|
||||
|
||||
// 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<bool>("PayloadLogging", false);
|
||||
var payloadLoggingGzip = builder.Configuration.GetValue<bool>("PayloadLoggingGzip", false);
|
||||
builder.Services.AddSingleton<AnthropicService>(sp =>
|
||||
{
|
||||
var svc = new AnthropicService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
anthropicToken, toolService, dbService, memoryService,
|
||||
toolService, dbService, memoryService,
|
||||
payloadLogging, payloadLoggingGzip,
|
||||
vectorSearch: vectorSearchService,
|
||||
embeddingBg: embeddingBgService);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aktualisiert den API-Token zur Laufzeit (z.B. wenn ein anderer Key aktiviert wird).
|
||||
/// </summary>
|
||||
public void UpdateToken(string token)
|
||||
{
|
||||
_token = token ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
|
||||
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
|
||||
/// </summary>
|
||||
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<object> 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<object> 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)");
|
||||
|
||||
@@ -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)}";
|
||||
|
||||
|
||||
@@ -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<ApiKeyRecord> GetActiveApiKey(string provider)
|
||||
public async Task<IEnumerable<ApiKeyRecord>> GetPublicApiKeys()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1",
|
||||
new { provider });
|
||||
return await conn.QueryAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE is_public = 1 ORDER BY provider, name");
|
||||
}
|
||||
|
||||
public async Task<ApiKeyRecord> 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<long>("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)
|
||||
|
||||
@@ -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<string> GetUserSelectedApiKeyId(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<string>(
|
||||
"SELECT selected_api_key_id FROM users WHERE user_id = @userId",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task<ApiKeyRecord> GetUserSelectedApiKey(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(@"
|
||||
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 });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -391,7 +391,7 @@ public class ToolService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<string> 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<string> 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
<div class="form">
|
||||
<div class="section-header">
|
||||
<h2>API-Key</h2>
|
||||
<button id="apiKeysCheckAllBtn" class="section-add-btn" style="margin-right: var(--badge-spacing); margin-left: auto;"><i class="fa fa-refresh"></i> Alle prüfen</button>
|
||||
<button id="apiKeysNewBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Key</button>
|
||||
</div>
|
||||
<div id="apiKeysList" class="admin-user-list"></div>
|
||||
|
||||
+8
-2
@@ -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) ──
|
||||
|
||||
|
||||
@@ -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 = '<i class="fa fa-toggle-on" style="color:var(--accent)"></i>';
|
||||
deactivateBtn.addEventListener('click', function() { deactivateKey(key); });
|
||||
badgeRow.appendChild(deactivateBtn);
|
||||
} else {
|
||||
var activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'icon-btn';
|
||||
activateBtn.title = 'Aktivieren';
|
||||
activateBtn.innerHTML = '<i class="fa fa-toggle-off"></i>';
|
||||
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
|
||||
? '<i class="fa fa-globe" style="color:var(--accent)"></i>'
|
||||
: '<i class="fa fa-lock"></i>';
|
||||
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;
|
||||
|
||||
@@ -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 = '<i class="fa fa-key"></i> 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 = '<i class="fa fa-key"></i> ' + (key.name || 'API Key');
|
||||
}
|
||||
|
||||
var checkBtn = document.createElement('button');
|
||||
checkBtn.className = 'chat-usage-check icon-btn';
|
||||
checkBtn.innerHTML = '<i class="fa fa-refresh"></i>';
|
||||
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 = '<i class="fa fa-refresh"></i>';
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user