Merge Stufe 1 (#764): Luecken der WebUI geschlossen
This commit is contained in:
@@ -76,6 +76,25 @@ Für jede Zeile aus Stufe 0 mit „fehlt“: zuerst prüfen, ob die API den Endp
|
||||
(nie im Endpunkt), mit Test in `tests/ClawdDotNet.Web.Tests` oder `App.Tests`.
|
||||
Lizenz und Schlüsselbund sind Sicherheitsstellen → starkes Modell.
|
||||
|
||||
**Stand 2026-10-02: erledigt** (Branch `feat/764-stufe1-webui-luecken`, Suiten grün unter
|
||||
Windows und Linux). L1, L3–L11 gebaut, L2 ist #780. Was dabei entschieden wurde:
|
||||
|
||||
| Lücke | Weg | Bewusst so |
|
||||
|---|---|---|
|
||||
| L1 Lizenz | `PUT /api/verwaltung/license` → `LicenseGate.ApplyKeyAsync` | Gespeichert wird nur ein vom Server bestätigter Schlüssel; abgelehnt oder Server nicht erreichbar → der alte bleibt. Ein bestätigter hebt die Sperre ohne Neustart auf und startet die Autostart-Instanzen |
|
||||
| L3 Manager | `GET/PUT /api/verwaltung/settings` | Nur Deploymentcenter-Adresse, Master-Token, Update-Kanal. Port, Bindung und Web-Token **nicht** — ein falscher Wert sperrte die Seite aus, von der er gesetzt wurde |
|
||||
| L3/L4/L5 Instanz + Anwendung | `GET/PUT /api/settings` (`SettingsAdministration`) | Nur bei laufender Instanz: Die Instanzdatei gehört dem Instanzprozess. Die Antwort nennt, was erst nach einem Neustart greift |
|
||||
| L6/L7 Agent | `PUT /api/agents/{id}`, `GET …/texts` | Lange Texte nicht in der Agentenliste (50–100 Agenten) |
|
||||
| L8 Werkzeuge | `GET/PUT …/tools/{tool}/form` (`ToolSettingsForms`) | Formular aus den typisierten Klassen; ersetzt werden nur geänderte Schlüssel, sonst bleibt die Datei wie sie war |
|
||||
| L10 Zeitplan | über `/api/settings`, in der Sicherungsansicht | wie L4 |
|
||||
| L11 Import | `POST /api/verwaltung/backups/import?name=…`, Datei als Rumpf, bis 4 GB | Nie in ein bestehendes Verzeichnis; Pfade der alten Maschine zurückgesetzt; Kopie neben dem Original bekommt neue ID und neues Melde-Token; Autostart aus |
|
||||
|
||||
Nebenbei behoben: `Database.allowedTables` wurde beim Speichern als roher JSON-Text zerlegt;
|
||||
`api.js` schickte Dateien als `"{}"`; Dialoge blieben beim Seitenwechsel offen; der Log-Hinweis
|
||||
auf `--license-set-key` zeigte auf einen Schalter, den es nie gab. Die Sprachpakete prüft jetzt
|
||||
`LanguagePackTests` bei jedem Lauf (gleiche Schlüssel, gleiche Platzhalter, jeder benutzte
|
||||
Schlüssel vorhanden).
|
||||
|
||||
### Stufe 2 — Die Hülle (Flash: nein)
|
||||
|
||||
Empfehlung (vom Menschen noch zu bestätigen, bevor du anfängst — frage im Roadmap-Eintrag):
|
||||
|
||||
@@ -254,6 +254,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
private readonly Lazy<ServiceAdministration> _serviceAdmin;
|
||||
private readonly Lazy<JobAdministration> _jobAdmin;
|
||||
private readonly Lazy<RunCoordinator> _runs;
|
||||
private readonly Lazy<SettingsAdministration> _settingsAdmin;
|
||||
|
||||
public AppHost()
|
||||
{
|
||||
@@ -261,6 +262,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
_serviceAdmin = new Lazy<ServiceAdministration>(() => new ServiceAdministration(this));
|
||||
_jobAdmin = new Lazy<JobAdministration>(() => new JobAdministration(this));
|
||||
_runs = new Lazy<RunCoordinator>(() => new RunCoordinator(this));
|
||||
_settingsAdmin = new Lazy<SettingsAdministration>(() => new SettingsAdministration(this));
|
||||
}
|
||||
|
||||
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
|
||||
@@ -278,6 +280,9 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// <summary>„Jetzt ausführen" samt Verlauf.</summary>
|
||||
public RunCoordinator Runs => _runs.Value;
|
||||
|
||||
/// <summary>#764: The settings the WebUI reads and writes.</summary>
|
||||
public SettingsAdministration SettingsAdmin => _settingsAdmin.Value;
|
||||
|
||||
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
|
||||
public sealed class Callbacks
|
||||
{
|
||||
|
||||
@@ -220,9 +220,10 @@ public sealed class DatabaseToolSettings
|
||||
AccessLevel = Enum.TryParse<DatabaseAccessLevel>(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level)
|
||||
? level
|
||||
: (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly),
|
||||
AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr
|
||||
? string.Join(",", arr)
|
||||
: config.GetValueOrDefault("allowedTables")?.ToString() ?? ""
|
||||
// #764: GetStringArray, because after loading the list is a JsonElement array. Its
|
||||
// ToString() is the raw JSON - ["a","b"] - which the next save split at the commas into
|
||||
// table names with brackets and quotes in them.
|
||||
AllowedTables = ConfigHelper.GetStringArray(config, "allowedTables")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,26 @@ public sealed record NewAgent(
|
||||
string Description = "",
|
||||
string? UnitId = null);
|
||||
|
||||
/// <summary>
|
||||
/// #764: What can be changed on an existing agent. Every field is optional — <c>null</c> keeps
|
||||
/// the current value, so a form that only knows some of the fields cannot blank the others.
|
||||
/// </summary>
|
||||
public sealed record AgentUpdate(
|
||||
string? DisplayName = null,
|
||||
string? Model = null,
|
||||
string? Description = null,
|
||||
string? SystemPrompt = null,
|
||||
string? Identity = null,
|
||||
string? Soul = null,
|
||||
string? PromptCaching = null,
|
||||
int? MaxToolResultChars = null,
|
||||
int? MaxSteps = null,
|
||||
int? MaxContextTokens = null,
|
||||
int? TimeoutSeconds = null,
|
||||
decimal? DailyCostUsd = null,
|
||||
long? DailyTokens = null,
|
||||
string? WhenExhausted = null);
|
||||
|
||||
/// <summary>
|
||||
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
|
||||
///
|
||||
@@ -191,6 +211,80 @@ public sealed class AgentAdministration(AppHost host)
|
||||
+ $"'{Path.GetFileName(archived)}'.");
|
||||
}
|
||||
|
||||
/// <summary>The values <see cref="AgentConfig.PromptCaching"/> understands.</summary>
|
||||
public static readonly IReadOnlyList<string> PromptCachingModes = ["auto", "on", "off"];
|
||||
|
||||
/// <summary>The values <see cref="AgentBudget.WhenExhausted"/> understands.</summary>
|
||||
public static readonly IReadOnlyList<string> WhenExhaustedModes = ["expire", "catchUpOnce"];
|
||||
|
||||
/// <summary>
|
||||
/// #764: Changes an agent's master data, limits and prompt texts — what the desktop agent page
|
||||
/// edited directly on the configuration objects.
|
||||
///
|
||||
/// <para><b>Everything is checked before anything is written.</b> A request with one bad value
|
||||
/// changes nothing: half an update that says "failed" would leave the agent in a state nobody
|
||||
/// asked for.</para>
|
||||
///
|
||||
/// <para>The display name changes the name only, not the folder: the agent keeps living in
|
||||
/// <see cref="AgentConfig.AgentDir"/>, which is where <see cref="PersistAgent"/> writes.</para>
|
||||
/// </summary>
|
||||
public AdminResult Update(string agentId, AgentUpdate update)
|
||||
{
|
||||
if (Find(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
||||
|
||||
if (Validate(update) is { } problem)
|
||||
return AdminResult.Failure(problem);
|
||||
|
||||
if (update.DisplayName is { } name) agent.DisplayName = name.Trim();
|
||||
if (update.Model is { } model) agent.Model = model.Trim();
|
||||
if (update.Description is { } description) agent.Description = description;
|
||||
if (update.SystemPrompt is { } systemPrompt) agent.SystemPrompt = systemPrompt;
|
||||
if (update.Identity is { } identity) agent.Identity = identity;
|
||||
if (update.Soul is { } soul) agent.Soul = soul;
|
||||
if (update.PromptCaching is { } caching) agent.PromptCaching = caching.Trim().ToLowerInvariant();
|
||||
if (update.MaxToolResultChars is { } chars) agent.MaxToolResultChars = chars;
|
||||
if (update.MaxSteps is { } steps) agent.LoopGuard.MaxSteps = steps;
|
||||
if (update.MaxContextTokens is { } context) agent.LoopGuard.MaxContextTokens = context;
|
||||
if (update.TimeoutSeconds is { } timeout) agent.LoopGuard.TimeoutSeconds = timeout;
|
||||
if (update.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost;
|
||||
if (update.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens;
|
||||
if (update.WhenExhausted is { } when)
|
||||
agent.Budget.WhenExhausted = WhenExhaustedModes.First(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
PersistAgent(agent);
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agent.AgentId));
|
||||
|
||||
return AdminResult.Success($"Agent '{agent.DisplayName}' saved.");
|
||||
}
|
||||
|
||||
private static string? Validate(AgentUpdate update)
|
||||
{
|
||||
if (update.DisplayName is { } name && name.Trim().Length == 0)
|
||||
return "The display name must not be empty.";
|
||||
if (update.Model is { } model && model.Trim().Length == 0)
|
||||
return "The model must not be empty.";
|
||||
if (update.PromptCaching is { } caching
|
||||
&& !PromptCachingModes.Contains(caching.Trim().ToLowerInvariant()))
|
||||
return $"Prompt caching is one of {string.Join(", ", PromptCachingModes)}.";
|
||||
if (update.WhenExhausted is { } when
|
||||
&& !WhenExhaustedModes.Any(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase)))
|
||||
return $"'When exhausted' is one of {string.Join(", ", WhenExhaustedModes)}.";
|
||||
if (update.MaxToolResultChars is < 100)
|
||||
return "A tool result needs at least 100 characters.";
|
||||
if (update.MaxSteps is < 1 or > 500)
|
||||
return "Steps must be between 1 and 500.";
|
||||
if (update.MaxContextTokens is < 1000)
|
||||
return "The context needs at least 1000 tokens.";
|
||||
if (update.TimeoutSeconds is < 10 or > 86_400)
|
||||
return "The time limit must be between 10 seconds and one day.";
|
||||
if (update.DailyCostUsd is < 0)
|
||||
return "A daily budget cannot be negative.";
|
||||
if (update.DailyTokens is < 0)
|
||||
return "A daily token budget cannot be negative.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lässt ein Werkzeug zu oder nimmt es weg.
|
||||
///
|
||||
@@ -394,6 +488,33 @@ public sealed class AgentAdministration(AppHost host)
|
||||
$"Prompt history default: {Core.Transcripts.PromptHistorySettings.Describe(retentionDays)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #764: The settings form of a tool for one agent (<see cref="ToolSettingsForms"/>) —
|
||||
/// also for a tool the agent does not have yet, so one can see what there is to set.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ToolSettingField>? ToolSettingsForm(string agentId, string toolName)
|
||||
=> Find(agentId) is { } agent
|
||||
? ToolSettingsForms.Describe(toolName, agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>())
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// #764: Writes form input from the WebUI into a tool's settings. Secrets left empty keep
|
||||
/// their value, keys the form does not know are kept, and the rights stay (see
|
||||
/// <see cref="SetToolSettings"/>).
|
||||
/// </summary>
|
||||
public AdminResult ApplyToolSettingsForm(string agentId, string toolName, IReadOnlyDictionary<string, string?> input)
|
||||
{
|
||||
if (Find(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"No agent with id '{agentId}'.");
|
||||
|
||||
var current = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>();
|
||||
var (settings, error) = ToolSettingsForms.Apply(toolName, current, input);
|
||||
|
||||
return settings is null
|
||||
? AdminResult.Failure(error ?? "Not saved.")
|
||||
: SetToolSettings(agentId, toolName, settings);
|
||||
}
|
||||
|
||||
public AdminResult SetToolSettings(
|
||||
string agentId, string toolName, Dictionary<string, object?> settings)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: The settings of this instance and of the application, as the WebUI sees them —
|
||||
/// <b>without a single secret</b>. For the OpenRouter key and the Deploymentcenter token there is
|
||||
/// only whether one is set.
|
||||
/// </summary>
|
||||
public sealed record SettingsSnapshot(
|
||||
// Instance — InstanceSettings.json, owned by this process.
|
||||
string InstanceName,
|
||||
string LogDirectory,
|
||||
string EffectiveLogDirectory,
|
||||
bool AutostartByManager,
|
||||
bool OpenRouterApiKeySet,
|
||||
decimal DailyCostUsd,
|
||||
long DailyTokens,
|
||||
bool AutoBackupEnabled,
|
||||
string AutoBackupTime,
|
||||
int BackupKeepCount,
|
||||
// Application — settings.json, shared by every instance on this machine.
|
||||
string MinimumLogLevel,
|
||||
string OpenRouterBaseUrl,
|
||||
int StatusCheckIntervalSeconds,
|
||||
string DeploymentcenterUrl,
|
||||
bool DeploymentcenterTokenSet,
|
||||
string DeploymentcenterEnvironment,
|
||||
bool ErrorReportingEnabled,
|
||||
bool UpdateCheckEnabled,
|
||||
string UpdateChannel);
|
||||
|
||||
/// <summary>
|
||||
/// #764: A change to the settings. <c>null</c> keeps a value. For the two secrets an empty
|
||||
/// value also keeps the one there is: a form never gets them back, so it cannot send them
|
||||
/// back either.
|
||||
/// </summary>
|
||||
public sealed record SettingsUpdate(
|
||||
string? InstanceName = null,
|
||||
string? LogDirectory = null,
|
||||
bool? AutostartByManager = null,
|
||||
string? OpenRouterApiKey = null,
|
||||
decimal? DailyCostUsd = null,
|
||||
long? DailyTokens = null,
|
||||
bool? AutoBackupEnabled = null,
|
||||
string? AutoBackupTime = null,
|
||||
int? BackupKeepCount = null,
|
||||
string? MinimumLogLevel = null,
|
||||
string? OpenRouterBaseUrl = null,
|
||||
int? StatusCheckIntervalSeconds = null,
|
||||
string? DeploymentcenterUrl = null,
|
||||
string? DeploymentcenterToken = null,
|
||||
string? DeploymentcenterEnvironment = null,
|
||||
bool? ErrorReportingEnabled = null,
|
||||
bool? UpdateCheckEnabled = null,
|
||||
string? UpdateChannel = null);
|
||||
|
||||
/// <summary>
|
||||
/// #764: Reads and writes what the desktop settings page edited, so the WebUI can do it.
|
||||
///
|
||||
/// <para><b>Two files, two owners.</b> <c>InstanceSettings.json</c> belongs to this instance
|
||||
/// process — the manager only reads it (autostart, backup schedule), which is why these
|
||||
/// fields are written here and not through the manager. <c>settings.json</c> is the
|
||||
/// application file every instance on the machine reads; a change there reaches the others
|
||||
/// at their next start.</para>
|
||||
///
|
||||
/// <para><b>Several values only take effect at the next start</b>: the logging is set up,
|
||||
/// the agent engine is built (or not, without a key) and the Deploymentcenter connection is
|
||||
/// made while the instance starts. Pretending otherwise would be a save button that seems to
|
||||
/// do nothing — so the answer says which ones.</para>
|
||||
/// </summary>
|
||||
public sealed class SettingsAdministration(AppHost host)
|
||||
{
|
||||
public static readonly IReadOnlyList<string> LogLevels = ["Debug", "Info", "Warn", "Error"];
|
||||
public static readonly IReadOnlyList<string> UpdateChannels = ["prod", "beta", "dev"];
|
||||
public static readonly IReadOnlyList<string> Environments = ["production", "development", "staging"];
|
||||
|
||||
public SettingsSnapshot Snapshot()
|
||||
{
|
||||
var instance = host.Instance;
|
||||
var app = host.Settings.AppSettings;
|
||||
|
||||
return new SettingsSnapshot(
|
||||
instance.InstanceName,
|
||||
instance.LogDirectory,
|
||||
host.LogDirectory,
|
||||
instance.AutostartByManager,
|
||||
!string.IsNullOrWhiteSpace(instance.OpenRouterApiKey),
|
||||
instance.Budget.DailyCostUsd,
|
||||
instance.Budget.DailyTokens,
|
||||
instance.AutoBackupEnabled,
|
||||
instance.AutoBackupTime,
|
||||
instance.BackupKeepCount,
|
||||
app.MinimumLogLevel,
|
||||
app.OpenRouterBaseUrl,
|
||||
app.StatusCheckIntervalSeconds,
|
||||
app.DeploymentcenterUrl,
|
||||
!string.IsNullOrWhiteSpace(app.DeploymentcenterToken),
|
||||
app.DeploymentcenterEnvironment,
|
||||
app.ErrorReportingEnabled,
|
||||
app.UpdateCheckEnabled,
|
||||
app.UpdateChannel);
|
||||
}
|
||||
|
||||
/// <summary>Checks everything first; a request with one bad value changes nothing.</summary>
|
||||
public AdminResult Update(SettingsUpdate update)
|
||||
{
|
||||
if (Validate(update) is { } problem)
|
||||
return AdminResult.Failure(problem);
|
||||
|
||||
var instance = host.Instance;
|
||||
var app = host.Settings.AppSettings;
|
||||
var restart = new List<string>();
|
||||
|
||||
void Note(bool changed, string what)
|
||||
{
|
||||
if (changed) restart.Add(what);
|
||||
}
|
||||
|
||||
// ─── Instance ───
|
||||
if (update.InstanceName is { } name) instance.InstanceName = name.Trim();
|
||||
if (update.LogDirectory is { } logDir)
|
||||
{
|
||||
Note(!string.Equals(instance.LogDirectory, logDir.Trim(), StringComparison.Ordinal), "log directory");
|
||||
instance.LogDirectory = logDir.Trim();
|
||||
}
|
||||
if (update.AutostartByManager is { } autostart) instance.AutostartByManager = autostart;
|
||||
if (!string.IsNullOrWhiteSpace(update.OpenRouterApiKey))
|
||||
{
|
||||
instance.OpenRouterApiKey = update.OpenRouterApiKey.Trim();
|
||||
restart.Add("OpenRouter key");
|
||||
}
|
||||
if (update.DailyCostUsd is { } cost) instance.Budget.DailyCostUsd = cost;
|
||||
if (update.DailyTokens is { } tokens) instance.Budget.DailyTokens = tokens;
|
||||
if (update.AutoBackupEnabled is { } backup) instance.AutoBackupEnabled = backup;
|
||||
if (update.AutoBackupTime is { } time) instance.AutoBackupTime = TimeSpan.Parse(time.Trim()).ToString(@"hh\:mm");
|
||||
if (update.BackupKeepCount is { } keep) instance.BackupKeepCount = keep;
|
||||
|
||||
// ─── Application ───
|
||||
var appChanged = false;
|
||||
|
||||
void ChangeApp(bool differs, Action set, string? restartNote = null)
|
||||
{
|
||||
if (differs && restartNote is not null) restart.Add(restartNote);
|
||||
set();
|
||||
appChanged = true;
|
||||
}
|
||||
|
||||
if (update.MinimumLogLevel is { } level)
|
||||
{
|
||||
var known = LogLevels.First(l => l.Equals(level.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
ChangeApp(app.MinimumLogLevel != known, () => app.MinimumLogLevel = known, "log level");
|
||||
}
|
||||
if (update.OpenRouterBaseUrl?.Trim() is { } baseUrl)
|
||||
ChangeApp(app.OpenRouterBaseUrl != baseUrl, () => app.OpenRouterBaseUrl = baseUrl, "OpenRouter address");
|
||||
if (update.StatusCheckIntervalSeconds is { } interval)
|
||||
ChangeApp(app.StatusCheckIntervalSeconds != interval, () => app.StatusCheckIntervalSeconds = interval, "status check interval");
|
||||
if (update.DeploymentcenterUrl?.Trim() is { } dcUrl)
|
||||
ChangeApp(app.DeploymentcenterUrl != dcUrl, () => app.DeploymentcenterUrl = dcUrl, "Deploymentcenter");
|
||||
if (update.DeploymentcenterEnvironment?.Trim() is { } environment)
|
||||
ChangeApp(app.DeploymentcenterEnvironment != environment, () => app.DeploymentcenterEnvironment = environment, "Deploymentcenter");
|
||||
if (update.ErrorReportingEnabled is { } reporting)
|
||||
ChangeApp(app.ErrorReportingEnabled != reporting, () => app.ErrorReportingEnabled = reporting, "Deploymentcenter");
|
||||
if (update.UpdateCheckEnabled is { } updateCheck)
|
||||
ChangeApp(false, () => app.UpdateCheckEnabled = updateCheck);
|
||||
if (update.UpdateChannel?.Trim() is { } channel)
|
||||
ChangeApp(false, () => app.UpdateChannel = channel);
|
||||
if (!string.IsNullOrWhiteSpace(update.DeploymentcenterToken))
|
||||
ChangeApp(true, () => app.DeploymentcenterToken = update.DeploymentcenterToken.Trim(), "Deploymentcenter");
|
||||
|
||||
if (appChanged)
|
||||
host.Settings.Save();
|
||||
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
|
||||
|
||||
var pending = restart.Distinct().ToList();
|
||||
return AdminResult.Success(pending.Count == 0
|
||||
? "Settings saved."
|
||||
: $"Settings saved. Takes effect after the instance restarts: {string.Join(", ", pending)}.");
|
||||
}
|
||||
|
||||
private static string? Validate(SettingsUpdate update)
|
||||
{
|
||||
if (update.InstanceName is { } name && name.Trim().Length == 0)
|
||||
return "The instance name must not be empty.";
|
||||
if (update.DailyCostUsd is < 0 || update.DailyTokens is < 0)
|
||||
return "A daily budget cannot be negative.";
|
||||
if (update.AutoBackupTime is { } time
|
||||
&& (!TimeSpan.TryParse(time.Trim(), out var at) || at < TimeSpan.Zero || at >= TimeSpan.FromDays(1)))
|
||||
return "The backup time is a time of day, e.g. 03:00.";
|
||||
if (update.BackupKeepCount is < 0 or > 365)
|
||||
return "Keep between 0 (all) and 365 backups.";
|
||||
if (update.MinimumLogLevel is { } level
|
||||
&& !LogLevels.Any(l => l.Equals(level, StringComparison.OrdinalIgnoreCase)))
|
||||
return $"The log level is one of {string.Join(", ", LogLevels)}.";
|
||||
if (update.OpenRouterBaseUrl is { } baseUrl && !IsHttpUrl(baseUrl))
|
||||
return "The OpenRouter address must be an http(s) address.";
|
||||
if (update.DeploymentcenterUrl is { } dcUrl && dcUrl.Trim().Length > 0 && !IsHttpUrl(dcUrl))
|
||||
return "The Deploymentcenter address must be an http(s) address.";
|
||||
if (update.StatusCheckIntervalSeconds is < 10 or > 86_400)
|
||||
return "The status check interval must be between 10 seconds and one day.";
|
||||
if (update.DeploymentcenterEnvironment is { } env && !Environments.Contains(env.Trim()))
|
||||
return $"The environment is one of {string.Join(", ", Environments)}.";
|
||||
if (update.UpdateChannel is { } channel && !UpdateChannels.Contains(channel.Trim()))
|
||||
return $"The update channel is one of {string.Join(", ", UpdateChannels)}.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsHttpUrl(string value)
|
||||
=> Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Models;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>#764: One field of a tool's settings form. A secret carries no value, only whether one is set.</summary>
|
||||
public sealed record ToolSettingField(
|
||||
string Name,
|
||||
string Kind,
|
||||
string? Value,
|
||||
bool IsSet,
|
||||
IReadOnlyList<string> Options);
|
||||
|
||||
/// <summary>
|
||||
/// #764: The tool settings forms of the desktop (<c>ToolSettingsView</c>) for the WebUI — built
|
||||
/// from the typed settings classes in <c>ToolSettingsViewModels.cs</c>, so a new field there
|
||||
/// appears in the form without a second list here.
|
||||
///
|
||||
/// <para><b>Secrets go one way.</b> A field is a secret when its property is marked as a
|
||||
/// password or its name is one <see cref="ConfigSecrets"/> protects (the connection string
|
||||
/// carries a password but no password mark). Its value is never described, and an empty input
|
||||
/// keeps the stored one.</para>
|
||||
///
|
||||
/// <para><b>Only what changed is written.</b> The typed classes do not read every stored form
|
||||
/// back faithfully — a JSON array, an old key like <c>allowWrite</c>, a key no class knows. Writing
|
||||
/// the whole round trip would rewrite all of that on every save. So the form is compared with an
|
||||
/// unedited round trip, and only keys whose value the person actually changed replace the stored
|
||||
/// ones; everything else stays byte for byte as it was.</para>
|
||||
/// </summary>
|
||||
public static class ToolSettingsForms
|
||||
{
|
||||
public const string Text = "text", Number = "number", Bool = "bool", Choice = "choice", Secret = "secret", Info = "info";
|
||||
|
||||
/// <summary>The form of a tool, or <c>null</c> if the tool has no settings form.</summary>
|
||||
public static IReadOnlyList<ToolSettingField>? Describe(string toolName, Dictionary<string, object?> config)
|
||||
{
|
||||
if (ToolSettingsFactory.CreateViewModel(toolName, config) is not { } settings)
|
||||
return null;
|
||||
|
||||
return Properties(settings).Select(p =>
|
||||
{
|
||||
var value = p.GetValue(settings);
|
||||
var kind = KindOf(p);
|
||||
return kind == Secret
|
||||
? new ToolSettingField(p.Name, kind, null, value is string { Length: > 0 }, [])
|
||||
: new ToolSettingField(p.Name, kind, Format(value), false,
|
||||
p.PropertyType.IsEnum ? Enum.GetNames(p.PropertyType) : []);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies form input to a tool's stored settings. Returns the new settings, or an error.
|
||||
/// Keys the form does not know are kept.
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?>? Settings, string? Error) Apply(
|
||||
string toolName, Dictionary<string, object?> current, IReadOnlyDictionary<string, string?> input)
|
||||
{
|
||||
if (ToolSettingsFactory.CreateViewModel(toolName, current) is not { } settings)
|
||||
return (null, $"'{toolName}' has no settings form.");
|
||||
|
||||
var baseline = ToolSettingsFactory.ToConfig(toolName, ToolSettingsFactory.CreateViewModel(toolName, current)) ?? [];
|
||||
var properties = Properties(settings).ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (name, raw) in input)
|
||||
{
|
||||
if (!properties.TryGetValue(name, out var property))
|
||||
return (null, $"'{toolName}' has no setting '{name}'.");
|
||||
|
||||
var kind = KindOf(property);
|
||||
if (kind == Info)
|
||||
continue;
|
||||
if (kind == Secret && string.IsNullOrEmpty(raw))
|
||||
continue; // never shown, so an empty field means "keep it"
|
||||
|
||||
if (Convert(property.PropertyType, raw ?? "") is not { } value)
|
||||
return (null, $"'{raw}' is not a valid value for {name}.");
|
||||
|
||||
property.SetValue(settings, value);
|
||||
}
|
||||
|
||||
var edited = ToolSettingsFactory.ToConfig(toolName, settings) ?? [];
|
||||
var result = new Dictionary<string, object?>(current, StringComparer.Ordinal);
|
||||
|
||||
foreach (var (key, value) in edited)
|
||||
{
|
||||
if (!baseline.TryGetValue(key, out var before) || Json(before) != Json(value))
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return (result, null);
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> Properties(object settings)
|
||||
=> settings.GetType()
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.CanWrite);
|
||||
|
||||
private static string KindOf(PropertyInfo p)
|
||||
{
|
||||
if (p.GetCustomAttribute<ReadOnlyAttribute>()?.IsReadOnly == true) return Info;
|
||||
if (p.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true || ConfigSecrets.IsSecretKey(p.Name)) return Secret;
|
||||
if (p.PropertyType == typeof(bool)) return Bool;
|
||||
if (p.PropertyType == typeof(int)) return Number;
|
||||
if (p.PropertyType.IsEnum) return Choice;
|
||||
return Text;
|
||||
}
|
||||
|
||||
private static string? Format(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
bool b => b ? "true" : "false",
|
||||
_ => value.ToString()
|
||||
};
|
||||
|
||||
private static object? Convert(Type type, string raw)
|
||||
{
|
||||
if (type == typeof(string)) return raw;
|
||||
if (type == typeof(int)) return int.TryParse(raw.Trim(), out var i) ? i : null;
|
||||
if (type == typeof(bool)) return bool.TryParse(raw.Trim(), out var b) ? b : null;
|
||||
if (type.IsEnum) return Enum.TryParse(type, raw.Trim(), ignoreCase: true, out var e) && Enum.IsDefined(type, e!) ? e : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Json(object? value) => JsonSerializer.Serialize(value);
|
||||
}
|
||||
@@ -22,4 +22,8 @@
|
||||
<ProjectReference Include="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Manager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -37,8 +37,13 @@ public interface ILicensePrompt
|
||||
/// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab.
|
||||
///
|
||||
/// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren
|
||||
/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile
|
||||
/// hinterlegt (<c>--license-set-key</c>).
|
||||
/// meldet er, was zu tun ist.
|
||||
///
|
||||
/// <para>#764: Here stood that the key is set beforehand on the command line
|
||||
/// (<c>--license-set-key</c>). That switch was planned and never built — the log pointed
|
||||
/// everyone who needed it at something that does not exist. The way in is the web
|
||||
/// interface (<see cref="LicenseGate.ApplyKeyAsync"/>) or <c>licenseKey</c> in
|
||||
/// <c>manager.json</c>, written by the installer.</para>
|
||||
/// </summary>
|
||||
public sealed class NonInteractiveLicensePrompt(Action<string> log) : ILicensePrompt
|
||||
{
|
||||
@@ -46,7 +51,7 @@ public sealed class NonInteractiveLicensePrompt(Action<string> log) : ILicensePr
|
||||
{
|
||||
log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. "
|
||||
+ (problem is null ? "" : $"Grund: {problem}. ")
|
||||
+ "Schlüssel mit '--license-set-key <SCHLÜSSEL>' hinterlegen.");
|
||||
+ "Enter the key in the web interface (Settings > License) or as licenseKey in manager.json.");
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,21 @@ public sealed class LicenseGate
|
||||
private readonly string _serverUrl;
|
||||
private readonly string _appVersion;
|
||||
|
||||
/// <summary>Test seam: answers in place of the license server.</summary>
|
||||
private readonly Func<string, CancellationToken, Task<LicenseValidationResult>>? _validator;
|
||||
|
||||
private string? _hardwareId;
|
||||
|
||||
/// <summary>For tests: a gate whose server answers are given, not fetched.</summary>
|
||||
internal LicenseGate(
|
||||
ILicenseStore store, ILogger logger, ILicensePrompt prompt, string appVersion,
|
||||
Func<string, CancellationToken, Task<LicenseValidationResult>> validator)
|
||||
: this(store, logger, prompt, appVersion)
|
||||
{
|
||||
_validator = validator;
|
||||
_hardwareId = "test-hardware";
|
||||
}
|
||||
|
||||
/// <param name="store">
|
||||
/// Wo Schlüssel und Serveradresse liegen. Eine Schnittstelle und keine
|
||||
/// Einstellungsklasse, weil zwei Prozesse sie führen: die Anwendung in
|
||||
@@ -179,6 +192,67 @@ public sealed class LicenseGate
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #764: Takes a key entered in the WebUI — the way in for a service that never asks
|
||||
/// (<see cref="NonInteractiveLicensePrompt"/>).
|
||||
///
|
||||
/// <para><b>Only a key the server has confirmed is stored.</b> A refused key leaves the
|
||||
/// current one in place: otherwise a typo would take a working installation down. A
|
||||
/// check that could not be made (server unreachable) stores nothing either — the same as
|
||||
/// the startup check, which also keeps an unconfirmed key only for the running
|
||||
/// session.</para>
|
||||
/// </summary>
|
||||
public async Task<LicenseApplyResult> ApplyKeyAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
key = key.Trim();
|
||||
|
||||
if (key.Length == 0)
|
||||
return new LicenseApplyResult(false, false, "Enter a license key.");
|
||||
|
||||
var result = await ValidateAsync(key, ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"License key not checkable ({Status}) - not stored.", result.Status);
|
||||
return new LicenseApplyResult(false, true,
|
||||
"The license server could not be reached. The key was not stored; try again later.");
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"License key refused: {Status} - {Message}", result.Status, result.Message);
|
||||
|
||||
// As in the startup check: no details on a clock rollback, a precise text would
|
||||
// be a recipe.
|
||||
return new LicenseApplyResult(false, false, result.Status == "clock_rollback"
|
||||
? "The license check could not be completed. Please contact support."
|
||||
: DescribeProblem(result));
|
||||
}
|
||||
|
||||
_store.LicenseKey = key;
|
||||
|
||||
try
|
||||
{
|
||||
_store.Save();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Valid, and in force for this session - but the next start would not know it.
|
||||
// That has to be said, not logged away.
|
||||
_logger.LogWarning(ex, "License key could not be saved");
|
||||
return new LicenseApplyResult(true, false,
|
||||
$"The license is valid but could not be saved ({ex.Message}). It applies until the next restart.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("License key replaced via the web interface.");
|
||||
|
||||
return new LicenseApplyResult(true, false, result.IsCached
|
||||
? $"License stored. The server was not reachable; valid offline until {Describe(result.CacheExpiresAt)}."
|
||||
: "License stored and confirmed.");
|
||||
}
|
||||
|
||||
private async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
@@ -193,6 +267,9 @@ public sealed class LicenseGate
|
||||
|
||||
try
|
||||
{
|
||||
if (_validator is not null)
|
||||
return await _validator(key, ct).ConfigureAwait(false);
|
||||
|
||||
return await _client.ValidateAsync(
|
||||
LicenseInfo.ProductSlug, key, _serverUrl, _appVersion, ct);
|
||||
}
|
||||
@@ -246,3 +323,9 @@ public sealed class LicenseGate
|
||||
: result.Message
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>#764: The outcome of <see cref="LicenseGate.ApplyKeyAsync"/>.</summary>
|
||||
/// <param name="Applied">The key is now the installation's key.</param>
|
||||
/// <param name="Transient">Nothing was decided: the server could not be asked.</param>
|
||||
/// <param name="Message">For the person who entered the key.</param>
|
||||
public sealed record LicenseApplyResult(bool Applied, bool Transient, string Message);
|
||||
|
||||
@@ -74,6 +74,20 @@ public sealed record FtpSettingsUpdateDto(
|
||||
bool Enabled, string Host, int Port, string Username, string? Password,
|
||||
string RemoteDirectory, bool RequireTls, int KeepCount);
|
||||
|
||||
/// <summary>#764: The license as the web interface sees it — never the key, only whether one is set.</summary>
|
||||
public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId);
|
||||
|
||||
/// <summary>#764: A license key entered in the web interface.</summary>
|
||||
public sealed record LicenseKeyDto(string Key);
|
||||
|
||||
/// <summary>#764: The manager's own settings — without the master token, only whether one is set.</summary>
|
||||
public sealed record ManagerSettingsDto(
|
||||
string ConfigFile, string InstancesDirectory, int WebUiPort, string WebUiBinding,
|
||||
string DeploymentcenterUrl, bool MasterTokenSet, string UpdateChannel);
|
||||
|
||||
/// <summary>#764: A change to the manager's settings. <c>null</c> keeps a value, an empty token too.</summary>
|
||||
public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, string? MasterToken, string? UpdateChannel);
|
||||
|
||||
/// <summary>
|
||||
/// Die HTTP-Fläche des Managers.
|
||||
///
|
||||
@@ -88,6 +102,12 @@ public sealed record FtpSettingsUpdateDto(
|
||||
/// </summary>
|
||||
internal static class ManagerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// #764: Upper limit for an uploaded backup. Kestrel's default of 30 MB is far below a
|
||||
/// backup with chat histories and state.db; without a limit, an upload could fill the disk.
|
||||
/// </summary>
|
||||
private const long MaxImportBytes = 4L * 1024 * 1024 * 1024;
|
||||
|
||||
// ─── Was den Manager selbst angeht ───
|
||||
|
||||
public static void MapVerwaltung(this WebApplication app)
|
||||
@@ -368,6 +388,88 @@ internal static class ManagerEndpoints
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Import a backup as a new instance (#764) ───
|
||||
//
|
||||
// The file comes as the raw request body, not as a form: the access token is a header,
|
||||
// not a cookie, so there is no cross-site form to guard against, and a raw body needs
|
||||
// no antiforgery exception. The source license key travels in a header and never in
|
||||
// the address, where it would end up in logs.
|
||||
verwaltung.MapPost("/backups/import", async (
|
||||
HttpContext context, ManagerBackups backups, CancellationToken ct) =>
|
||||
{
|
||||
var name = context.Request.Query["name"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Results.BadRequest(new OutcomeDto(false, "The new instance needs a name."));
|
||||
|
||||
if (context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
|
||||
limit.MaxRequestBodySize = MaxImportBytes;
|
||||
|
||||
var upload = Path.Combine(Path.GetTempPath(), "clawd-import-" + Guid.NewGuid().ToString("N") + ".bin");
|
||||
try
|
||||
{
|
||||
await using (var file = File.Create(upload))
|
||||
await context.Request.Body.CopyToAsync(file, ct);
|
||||
|
||||
if (new FileInfo(upload).Length == 0)
|
||||
return Results.BadRequest(new OutcomeDto(false, "The upload was empty."));
|
||||
|
||||
var result = await backups.ImportAsNewInstanceAsync(
|
||||
upload, name,
|
||||
context.Request.Headers["X-Source-License-Key"].ToString(),
|
||||
context.Request.Headers["X-Backup-Passphrase"].ToString(),
|
||||
CancellationToken.None);
|
||||
|
||||
if (result.KeyNeeded)
|
||||
return Results.Json(new KeyNeededDto(false, result.Message, "fremde_lizenz"),
|
||||
statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
return result.Ok
|
||||
? Results.Ok(new OutcomeDto(true, result.Message))
|
||||
: Results.BadRequest(new OutcomeDto(false, result.Message));
|
||||
}
|
||||
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge)
|
||||
{
|
||||
return Results.Json(new OutcomeDto(false, $"The backup is larger than {MaxImportBytes / (1024 * 1024 * 1024)} GB."),
|
||||
statusCode: StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(upload); }
|
||||
catch (IOException) { /* temp file; the next cleanup of the temp folder takes it */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ─── License and manager settings (#764) ───
|
||||
|
||||
verwaltung.MapGet("/license", (ManagerCore manager) =>
|
||||
Results.Ok(new LicenseStatusDto(
|
||||
manager.LicenseEnforced, manager.LicenseHolds,
|
||||
!string.IsNullOrWhiteSpace(manager.Settings.LicenseKey), manager.LicenseHardwareId)));
|
||||
|
||||
// The key goes to the license server before it is stored; a refused key leaves the
|
||||
// current one in place (LicenseGate.ApplyKeyAsync).
|
||||
verwaltung.MapPut("/license", async (LicenseKeyDto input, ManagerCore manager, CancellationToken ct) =>
|
||||
{
|
||||
var result = await manager.ApplyLicenseKeyAsync(input.Key ?? "", ct);
|
||||
return result.Applied
|
||||
? Results.Ok(new OutcomeDto(true, result.Message))
|
||||
: Results.BadRequest(new OutcomeDto(false, result.Message));
|
||||
});
|
||||
|
||||
verwaltung.MapGet("/settings", (ManagerCore manager) =>
|
||||
{
|
||||
var s = manager.Settings;
|
||||
return Results.Ok(new ManagerSettingsDto(
|
||||
manager.SettingsPath, s.InstancesDirectory, s.WebUiPort, s.WebUiBinding,
|
||||
s.DeploymentcenterUrl, !string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken), s.UpdateChannel));
|
||||
});
|
||||
|
||||
verwaltung.MapPut("/settings", (ManagerSettingsUpdateDto input, ManagerCore manager) =>
|
||||
{
|
||||
var (ok, message) = manager.UpdateSettings(input.DeploymentcenterUrl, input.MasterToken, input.UpdateChannel);
|
||||
return ok ? Results.Ok(new OutcomeDto(true, message)) : Results.BadRequest(new OutcomeDto(false, message));
|
||||
});
|
||||
|
||||
// ─── FTP-Upload der Sicherungen ───
|
||||
|
||||
verwaltung.MapGet("/sicherung/ftp", (ManagerBackups backups) =>
|
||||
|
||||
@@ -1517,6 +1517,22 @@ button.klein {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* #764: Settings cards side by side where there is room, stacked on a phone. */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-grid .karte {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-grid code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ─── Responsive Optimierung (Handy / 375px) ─── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -389,8 +389,8 @@
|
||||
"agents.deleteFailed": "Löschen fehlgeschlagen: {error}",
|
||||
"agents.settingsTitle": "Einstellungen: {tool} ({agent})",
|
||||
"agents.loadingSettings": "Lade Konfiguration…",
|
||||
"agents.secretHint": "Geheime Werte (Passwörter, Schlüssel) werden vom Server maskiert ausgegeben.",
|
||||
"agents.noSettings": "Keine individuellen Einstellungen hinterlegt.",
|
||||
"agents.secretHint": "Passwörter und Schlüssel werden nie angezeigt. Ein leeres Feld behält den gespeicherten Wert.",
|
||||
"agents.noSettings": "Dieses Werkzeug hat keine Einstellungen.",
|
||||
"agents.loadFailed": "Laden fehlgeschlagen: {error}",
|
||||
"agents.rights": "Rechte",
|
||||
"agents.noOperations": "Dieses Werkzeug kennt keine einzelnen Operationen. Es gilt die Standardregel.",
|
||||
@@ -417,6 +417,102 @@
|
||||
"agents.treatAsForeignReader": "Immer als Leser fremder Inhalte behandeln",
|
||||
"agents.treatAsForeignReaderHint": "Kann nur verschärfen. Die Stufe folgt aus den Werkzeugen. Diese Einstellung setzt sie für einen Agenten, dessen Eingaben auf anderem Weg von außen kommen.",
|
||||
"agents.foreignReaderSaved": "Berichtsweg von {name} gespeichert.",
|
||||
"toolSettings.FileRW.PersonalAllowedExtensions": "Erlaubte Endungen (eigener Arbeitsbereich)",
|
||||
"toolSettings.FileRW.PersonalAllowedExtensions.hint": "Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)",
|
||||
"toolSettings.FileRW.SharedAccessLevel": "Zugriffslevel (geteilter Arbeitsbereich)",
|
||||
"toolSettings.FileRW.SharedAccessLevel.hint": "Legt fest, welche Operationen im SharedWorkspace erlaubt sind",
|
||||
"toolSettings.FileRW.SharedAllowedExtensions": "Erlaubte Endungen (geteilter Arbeitsbereich)",
|
||||
"toolSettings.FileRW.SharedAllowedExtensions.hint": "Dateiendungen für den geteilten Workspace",
|
||||
"toolSettings.FileRW.ProtectedPaths": "Geschützte Pfade",
|
||||
"toolSettings.FileRW.ProtectedPaths.hint": "Komma-getrennte Pfade im SharedWorkspace, die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.",
|
||||
"toolSettings.Mail.Username": "Benutzername",
|
||||
"toolSettings.Mail.Password": "Passwort",
|
||||
"toolSettings.Mail.ImapHost": "IMAP-Host",
|
||||
"toolSettings.Mail.ImapPort": "IMAP-Port",
|
||||
"toolSettings.Mail.SmtpHost": "SMTP-Host",
|
||||
"toolSettings.Mail.SmtpPort": "SMTP-Port",
|
||||
"toolSettings.Mail.AllowedRecipients": "Erlaubte Empfänger",
|
||||
"toolSettings.Mail.AllowedRecipients.hint": "Komma-getrennte Liste erlaubter E-Mail-Adressen",
|
||||
"toolSettings.Database.Type": "Typ",
|
||||
"toolSettings.Database.Type.hint": "Der zu verwendende Datenbanktyp",
|
||||
"toolSettings.Database.ConnectionString": "Connection-String",
|
||||
"toolSettings.Database.AccessLevel": "Zugriffsebene",
|
||||
"toolSettings.Database.AccessLevel.hint": "Legt fest, welche Operationen der Agent ausführen darf",
|
||||
"toolSettings.Database.AllowedTables": "Erlaubte Tabellen",
|
||||
"toolSettings.Database.AllowedTables.hint": "Komma-getrennte Liste erlaubter Tabellen/Collections",
|
||||
"toolSettings.FTP.Host": "Host",
|
||||
"toolSettings.FTP.Port": "Port",
|
||||
"toolSettings.FTP.Username": "Benutzername",
|
||||
"toolSettings.FTP.Password": "Passwort",
|
||||
"toolSettings.FTP.RootPath": "Root-Pfad",
|
||||
"toolSettings.FTP.RootPath.hint": "Basisverzeichnis für Dateiübertragungen",
|
||||
"toolSettings.Telegram.BotToken": "Bot-Token",
|
||||
"toolSettings.Telegram.DefaultChatId": "Standard Chat-ID",
|
||||
"toolSettings.Telegram.DefaultChatId.hint": "Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.",
|
||||
"toolSettings.Telegram.AllowedChatIds": "Erlaubte Chat-IDs",
|
||||
"toolSettings.Telegram.AllowedChatIds.hint": "Komma-getrennte Liste erlaubter Chat-IDs",
|
||||
"toolSettings.DirectAPI.DefaultProvider": "Standard-Provider",
|
||||
"toolSettings.DirectAPI.CacheTtlSeconds": "Cache TTL (Sekunden)",
|
||||
"toolSettings.DirectAPI.TwelveDataKey": "Twelve Data Key",
|
||||
"toolSettings.DirectAPI.AlphaVantageKey": "Alpha Vantage Key",
|
||||
"toolSettings.WebFetch.AllowedDomains": "Erlaubte Domains",
|
||||
"toolSettings.WebFetch.AllowedDomains.hint": "Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)",
|
||||
"toolSettings.WebFetch.MaxResponseKb": "Max. Antwort (KB)",
|
||||
"toolSettings.WebFetch.UserAgent": "User Agent",
|
||||
"toolSettings.WebMonitor.MonitorsJson": "Monitore (JSON)",
|
||||
"toolSettings.WebMonitor.MonitorsJson.hint": "JSON-Konfiguration der Monitore",
|
||||
"toolSettings.AgentComm.Status.hint": "Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.",
|
||||
"toolSettings.SocialMediaManager.XApiKey": "Bearer Token",
|
||||
"toolSettings.SocialMediaManager.XApiKey.hint": "X API v2 Bearer Token für die Authentifizierung",
|
||||
"toolSettings.SocialMediaManager.XWatchAccounts": "Überwachte Accounts",
|
||||
"toolSettings.SocialMediaManager.XWatchAccounts.hint": "Komma-getrennte Liste von X-Accounts, die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone",
|
||||
"toolSettings.SocialMediaManager.RedditWatchSubreddits": "Überwachte Subreddits",
|
||||
"toolSettings.SocialMediaManager.RedditWatchSubreddits.hint": "Komma-getrennte Liste von Subreddits, die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options",
|
||||
"toolSettings.SocialMediaManager.RedditPostLimit": "Posts pro Subreddit",
|
||||
"toolSettings.SocialMediaManager.RedditPostLimit.hint": "Maximale Anzahl Posts, die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)",
|
||||
"toolSettings.SocialMediaManager.OpenRouterApiKey": "OpenRouter API Key",
|
||||
"toolSettings.SocialMediaManager.OpenRouterApiKey.hint": "API Key für Speech-to-Text-Transkription über OpenRouter",
|
||||
"toolSettings.SocialMediaManager.STTModel": "STT-Modell",
|
||||
"toolSettings.SocialMediaManager.STTModel.hint": "OpenRouter Modell-ID für die Transkription",
|
||||
"toolSettings.SocialMediaManager.YoutubeChannels": "YouTube-Kanäle",
|
||||
"toolSettings.SocialMediaManager.YoutubeChannels.hint": "Komma-getrennte Liste von YouTube-Kanal-URLs für automatische Überwachung",
|
||||
"toolSettings.AgentEditor.Status.hint": "Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen und Änderungen vorzuschlagen. Änderungen und neue Agenten gehen in die Freigabeliste.",
|
||||
"toolSettings.AgentEditor.AllowSelfEdit": "Eigene Identity/Soul bearbeiten",
|
||||
"toolSettings.AgentEditor.AllowSelfEdit.hint": "Darf der Agent Änderungen an seiner eigenen Identity und Soul vorschlagen? Standard: nein — sonst könnte ein Agent sich die Grenzen wegschreiben, an denen er scheitert (#205).",
|
||||
"toolSettings.AgentSpawn.Status.hint": "Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.",
|
||||
"agents.edit": "Bearbeiten",
|
||||
"agents.editTooltip": "Name, Modell, Grenzen, Budget und Prompttexte bearbeiten",
|
||||
"agents.editTitle": "Agent bearbeiten — {name}",
|
||||
"agents.saved": "Agent gespeichert.",
|
||||
"agents.runNow": "Jetzt ausführen",
|
||||
"agents.runNowTooltip": "Agent von Hand wecken, wie es ein geplanter Lauf täte",
|
||||
"agents.runStarted": "{name} läuft.",
|
||||
"agents.runFailed": "Lauf fehlgeschlagen: {error}",
|
||||
"agents.limits": "Grenzen",
|
||||
"agents.limitsValue": "{steps} Schritte · {context} Kontext-Tokens · {seconds} s",
|
||||
"agents.budget": "Tagesbudget",
|
||||
"agents.budgetValue": "{cost} · {tokens} Tokens",
|
||||
"agents.unlimited": "unbegrenzt",
|
||||
"agents.promptTextsHint": "Identity, Soul und Systemprompt liest das Modell vor jedem Lauf. Wer sie ändert, ändert, wie der Agent arbeitet.",
|
||||
"agents.field.displayName": "Anzeigename",
|
||||
"agents.field.model": "Modell",
|
||||
"agents.field.description": "Beschreibung",
|
||||
"agents.field.identity": "Identity",
|
||||
"agents.field.soul": "Soul",
|
||||
"agents.field.systemPrompt": "Systemprompt",
|
||||
"agents.field.promptCaching": "Prompt-Caching",
|
||||
"agents.field.maxSteps": "Max. Schritte je Lauf",
|
||||
"agents.field.maxContextTokens": "Max. Kontext-Tokens",
|
||||
"agents.field.maxToolResultChars": "Max. Zeichen je Werkzeugergebnis",
|
||||
"agents.field.timeoutSeconds": "Zeitlimit je Lauf (Sekunden)",
|
||||
"agents.field.dailyCostUsd": "Tagesbudget (USD, 0 = unbegrenzt)",
|
||||
"agents.field.dailyTokens": "Tages-Tokens (0 = unbegrenzt)",
|
||||
"agents.field.whenExhausted": "Wenn das Budget erschöpft ist",
|
||||
"agents.option.auto": "Automatisch",
|
||||
"agents.option.on": "An",
|
||||
"agents.option.off": "Aus",
|
||||
"agents.option.expire": "Lauf entfällt",
|
||||
"agents.option.catchUpOnce": "Einmal nachholen",
|
||||
|
||||
"tasks.title": "Aufgaben",
|
||||
"tasks.newTask": "Neue Aufgabe",
|
||||
@@ -523,6 +619,21 @@
|
||||
"backup.sourceLicensePlaceholder": "Lizenzschlüssel der Installation, von der die Sicherung stammt…",
|
||||
"backup.restoreWithKey": "Mit diesem Schlüssel wiederherstellen",
|
||||
"backup.keyMismatch": "Dieser Lizenzschlüssel passt nicht zu der Sicherung.",
|
||||
"backup.scheduleTitle": "Automatische Sicherung",
|
||||
"backup.scheduleNeedsRunning": "Der Zeitplan steht in der Instanzdatei, die nur die laufende Instanz schreibt. Instanz starten, um ihn zu ändern.",
|
||||
"backup.scheduleEnabled": "Diese Instanz täglich sichern",
|
||||
"backup.scheduleTime": "Uhrzeit",
|
||||
"backup.scheduleKeep": "Sicherungen behalten",
|
||||
"backup.scheduleKeepHint": "Die neuesten bleiben, ältere werden entfernt. 0 behält alle.",
|
||||
"backup.importTitle": "Sicherung als neue Instanz einspielen",
|
||||
"backup.importHint": "Für den Umzug auf diese Maschine: Die Sicherung wird zu einer neuen Instanz. Bestehende Instanzen bleiben unberührt. Die neue Instanz startet nicht von selbst.",
|
||||
"backup.importFile": "Sicherungsdatei",
|
||||
"backup.importName": "Name der neuen Instanz",
|
||||
"backup.importNamePlaceholder": "z. B. Produktion",
|
||||
"backup.importButton": "Einspielen",
|
||||
"backup.importing": "Wird eingespielt…",
|
||||
"backup.imported": "Sicherung eingespielt.",
|
||||
"backup.importFailed": "Einspielen fehlgeschlagen: {error}",
|
||||
"backup.deleteTitle": "Sicherung löschen",
|
||||
"backup.deleteConfirm": "Soll die Sicherungsdatei '{file}' wirklich unwiderruflich gelöscht werden?",
|
||||
"backup.deleted": "Sicherung gelöscht.",
|
||||
@@ -558,34 +669,51 @@
|
||||
"logs.empty": "Noch keine Protokolleinträge vorhanden.",
|
||||
|
||||
"settings.title": "Einstellungen & Systemarchitektur",
|
||||
"settings.license": "Lizenz",
|
||||
"settings.licenseState": "Zustand",
|
||||
"settings.licenseValid": "Gültig",
|
||||
"settings.licenseInvalid": "Nicht gültig",
|
||||
"settings.licenseMissing": "Kein Schlüssel",
|
||||
"settings.licenseNoCheck": "Keine Lizenzprüfung",
|
||||
"settings.licenseUnavailable": "Der Lizenzzustand ließ sich nicht lesen.",
|
||||
"settings.licenseKeyHint": "Der Schlüssel wird vor dem Speichern beim Lizenzserver geprüft. Ein abgelehnter Schlüssel lässt den bisherigen stehen. Eine Lizenz gilt für alle Instanzen dieser Installation.",
|
||||
"settings.hardwareId": "Hardware-ID",
|
||||
"settings.secretSet": "Hinterlegt — leer lassen, um ihn zu behalten",
|
||||
"settings.secretNotSet": "Nicht gesetzt",
|
||||
"settings.managerCard": "Instanzmanager",
|
||||
"settings.instancesDirectory": "Instanzverzeichnis",
|
||||
"settings.webUi": "Weboberfläche",
|
||||
"settings.dcUrl": "Deploymentcenter-Adresse",
|
||||
"settings.dcMasterTokenHint": "Damit stellt der Manager jeder Instanz ein eigenes Melde-Token aus. Nur ein leeres Feld behält das gespeicherte Token.",
|
||||
"settings.managerRestartHint": "Änderungen hier greifen, wenn der Instanzmanager neu startet. Port, Bindung und Zugangstoken der Weboberfläche stehen in manager.json — ein falscher Wert hier sperrte diese Seite aus.",
|
||||
"settings.instanceStopped": "Instanz läuft nicht",
|
||||
"settings.instanceStoppedHint": "Die Instanzdatei gehört dem Instanzprozess und wird nur bearbeitet, solange er läuft. Instanz starten, um ihre Einstellungen zu ändern.",
|
||||
"settings.instanceCard": "Instanz",
|
||||
"settings.logDirectory": "Protokollverzeichnis",
|
||||
"settings.logDirectoryHint": "Leer nimmt die Vorgabe. Derzeit: {path}. Greift nach einem Neustart.",
|
||||
"settings.instanceDailyCost": "Tagesbudget der Instanz (USD, 0 = unbegrenzt)",
|
||||
"settings.instanceDailyTokens": "Tages-Tokens der Instanz (0 = unbegrenzt)",
|
||||
"settings.budgetHint": "Über alle Agenten dieser Instanz. Agenten haben zusätzlich eigene Budgets.",
|
||||
"settings.apiKeyHint": "Ohne Schlüssel startet die Agent-Engine nicht. Ein neuer Schlüssel greift nach einem Neustart der Instanz.",
|
||||
"settings.applicationCard": "Anwendung (alle Instanzen)",
|
||||
"settings.applicationHint": "settings.json auf dieser Maschine, von jeder Instanz beim Start gelesen.",
|
||||
"settings.minimumLogLevel": "Mindest-Protokollstufe",
|
||||
"settings.openRouterBaseUrl": "OpenRouter-Adresse",
|
||||
"settings.statusCheckInterval": "Statusprüfung alle (Sekunden)",
|
||||
"settings.dcToken": "Deploymentcenter-Token",
|
||||
"settings.dcEnvironment": "Umgebung",
|
||||
"settings.errorReporting": "Fehler an das Deploymentcenter melden",
|
||||
"settings.on": "An",
|
||||
"settings.off": "Aus",
|
||||
"settings.intro": "Übersicht der Konfiguration nach dem Instanzmanager-Konzept: Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene.",
|
||||
"settings.managerLevel": "Instanzmanager",
|
||||
"settings.managerBadge": "Manager-Ebene · Global",
|
||||
"settings.managerIntro": "Gilt für alle Instanzen und die Weboberfläche. Gespeichert in manager.json im Anwendungs-Hauptverzeichnis.",
|
||||
"settings.webUiAndNet": "Weboberfläche & Netzwerk",
|
||||
"settings.configFile": "Konfigurationsdatei",
|
||||
"settings.webUiPort": "WebUI-Port",
|
||||
"settings.webUiPortDefault": "7000 (Vorgabe, webUiPort)",
|
||||
"settings.networkBinding": "Netzwerk-Bindung",
|
||||
"settings.networkBindingValue": "loopback (127.0.0.1, webUiBinding)",
|
||||
"settings.accessProtection": "Zugangsschutz",
|
||||
"settings.tokenActive": "Token aktiv (≥ 20 Zeichen)",
|
||||
"settings.licenseAndUpdates": "Lizenz & Updates",
|
||||
"settings.licenseScope": "Lizenz-Geltung",
|
||||
"settings.licenseScopeValue": "Installationsebene (E-M3: Beliebig viele Instanzen)",
|
||||
"settings.licenseKey": "Lizenzschlüssel",
|
||||
"settings.licenseEncrypted": "●●●●-●●●●-●●●●-●●●● (verschlüsselt)",
|
||||
"settings.updateChannel": "Update-Kanal",
|
||||
"settings.updateChannelValue": "prod (Offizielle Release-Versionen)",
|
||||
"settings.updateCheck": "Update-Prüfung",
|
||||
"settings.updateCheckValue": "Zentral auf Manager-Ebene",
|
||||
"settings.dcAndOversight": "Deploymentcenter & Aufsicht",
|
||||
"settings.dcMasterToken": "Master-Token",
|
||||
"settings.dcMasterTokenValue": "●●●●●●●● (erzeugt Sub-Token je Instanz)",
|
||||
"settings.desktopWindows": "Desktop-Fenster",
|
||||
"settings.desktopWindowsValue": "Aktiv (jede Instanz hat ein eigenes Fenster)",
|
||||
"settings.crashProtection": "Absturz-Schutz",
|
||||
"settings.crashProtectionValue": "Max. 5 Neustarts / 10 Minuten",
|
||||
"settings.instanceSectionTitle": "Aktive Instanz: {name}",
|
||||
"settings.instanceSectionTitleNone": "Instanzbezogene Einstellungen",
|
||||
"settings.instanceBadge": "Instanz-Ebene · Isoliert",
|
||||
@@ -594,41 +722,20 @@
|
||||
"settings.noInstanceSelected": "Keine Instanz ausgewählt",
|
||||
"settings.noInstancePrompt": "Bitte wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten oder auszuwählen.",
|
||||
"settings.toInstancesOverview": "Zur Instanzen-Übersicht",
|
||||
"settings.identityAndProcess": "Identität & Prozess",
|
||||
"settings.instanceName": "Instanzname",
|
||||
"settings.instanceId": "Instanz-ID",
|
||||
"settings.autostartByManager": "Autostart durch Manager",
|
||||
"settings.autostartActive": "Aktiv (Am Laufen halten)",
|
||||
"settings.autostartInactive": "Inaktiv (Manueller Start)",
|
||||
"settings.communication": "Kommunikation",
|
||||
"settings.communicationValue": "HTTP über Named Pipe (Windows) / Socket (Linux)",
|
||||
"settings.isolatedPaths": "Isolierte Verzeichnisse",
|
||||
"settings.logsPath": "Protokolle (Logs)",
|
||||
"settings.backupsPath": "Datensicherungen",
|
||||
"settings.stateDbPath": "Zustandsdatenbank",
|
||||
"settings.stateDbValue": "Instances/{name}/state.db (exklusiv)",
|
||||
"settings.workspacePath": "Arbeitsbereich (Shared)",
|
||||
"settings.openRouterCard": "Modellanbindung (OpenRouter)",
|
||||
"settings.openRouterStatus": "OpenRouter-Status",
|
||||
"settings.connected": "Verbunden",
|
||||
"settings.offline": "Offline",
|
||||
"settings.statusMessage": "Statusmeldung",
|
||||
"settings.credits": "Guthaben / Credits",
|
||||
"settings.apiKey": "API-Schlüssel",
|
||||
"settings.apiKeyMasked": "In InstanceSettings.json dieser Instanz maskiert hinterlegt",
|
||||
"settings.openRouterNotRunning": "Instanz läuft nicht. Daten werden nach dem Start geladen.",
|
||||
"settings.openRouterNoKey": "Kein Schlüssel hinterlegt oder Dienst nicht erreichbar.",
|
||||
"settings.runtimeAndSubsystems": "Laufzeit & Teilsysteme",
|
||||
"settings.agentEngine": "Agent Engine",
|
||||
"settings.scheduler": "Scanner / Scheduler",
|
||||
"settings.stagingService": "Freigabestelle (Staging)",
|
||||
"settings.dcSubToken": "Deploymentcenter Sub-Token",
|
||||
"settings.subsystemsLoading": "Laufzeitdaten werden abgefragt…",
|
||||
"settings.subsystemsStopped": "Instanz ist beendet — Teilsysteme ruhen.",
|
||||
"settings.save": "Einstellungen speichern",
|
||||
"settings.discard": "Verwerfen",
|
||||
"settings.saved": "Einstellungen erfolgreich gespeichert.",
|
||||
"settings.saveError": "Fehler beim Speichern der Einstellungen: {error}",
|
||||
|
||||
"info.title": "System & Status",
|
||||
"info.noInstanceSelected": "Keine Instanz ausgewählt",
|
||||
|
||||
@@ -389,8 +389,8 @@
|
||||
"agents.deleteFailed": "Deletion failed: {error}",
|
||||
"agents.settingsTitle": "Settings: {tool} ({agent})",
|
||||
"agents.loadingSettings": "Loading configuration…",
|
||||
"agents.secretHint": "Secret values (passwords, keys) are masked by the server.",
|
||||
"agents.noSettings": "No custom settings configured.",
|
||||
"agents.secretHint": "Passwords and keys are never shown. Leave a field empty to keep the stored value.",
|
||||
"agents.noSettings": "This tool has no settings.",
|
||||
"agents.loadFailed": "Loading failed: {error}",
|
||||
"agents.rights": "Permissions",
|
||||
"agents.noOperations": "This tool has no individual operations. Default rule applies.",
|
||||
@@ -417,6 +417,102 @@
|
||||
"agents.treatAsForeignReader": "Always treat as reading outside content",
|
||||
"agents.treatAsForeignReaderHint": "Can only tighten. The tools decide the tier; this adds it for an agent whose input comes from outside some other way.",
|
||||
"agents.foreignReaderSaved": "Reporting line of {name} saved.",
|
||||
"toolSettings.FileRW.PersonalAllowedExtensions": "Allowed extensions (own workspace)",
|
||||
"toolSettings.FileRW.PersonalAllowedExtensions.hint": "File extensions for the agent’s own workspace (e.g. .txt,.json,.md)",
|
||||
"toolSettings.FileRW.SharedAccessLevel": "Access level (shared workspace)",
|
||||
"toolSettings.FileRW.SharedAccessLevel.hint": "Which operations are allowed in the SharedWorkspace",
|
||||
"toolSettings.FileRW.SharedAllowedExtensions": "Allowed extensions (shared workspace)",
|
||||
"toolSettings.FileRW.SharedAllowedExtensions.hint": "File extensions for the shared workspace",
|
||||
"toolSettings.FileRW.ProtectedPaths": "Protected paths",
|
||||
"toolSettings.FileRW.ProtectedPaths.hint": "Comma-separated paths in the SharedWorkspace that are append-only (e.g. stocks/,archives/). Files there can be created, not overwritten or deleted. The Admin level bypasses the protection.",
|
||||
"toolSettings.Mail.Username": "Username",
|
||||
"toolSettings.Mail.Password": "Password",
|
||||
"toolSettings.Mail.ImapHost": "IMAP host",
|
||||
"toolSettings.Mail.ImapPort": "IMAP port",
|
||||
"toolSettings.Mail.SmtpHost": "SMTP host",
|
||||
"toolSettings.Mail.SmtpPort": "SMTP port",
|
||||
"toolSettings.Mail.AllowedRecipients": "Allowed recipients",
|
||||
"toolSettings.Mail.AllowedRecipients.hint": "Comma-separated list of allowed e-mail addresses",
|
||||
"toolSettings.Database.Type": "Type",
|
||||
"toolSettings.Database.Type.hint": "The database type to use",
|
||||
"toolSettings.Database.ConnectionString": "Connection string",
|
||||
"toolSettings.Database.AccessLevel": "Access level",
|
||||
"toolSettings.Database.AccessLevel.hint": "Which operations the agent may perform",
|
||||
"toolSettings.Database.AllowedTables": "Allowed tables",
|
||||
"toolSettings.Database.AllowedTables.hint": "Comma-separated list of allowed tables/collections",
|
||||
"toolSettings.FTP.Host": "Host",
|
||||
"toolSettings.FTP.Port": "Port",
|
||||
"toolSettings.FTP.Username": "Username",
|
||||
"toolSettings.FTP.Password": "Password",
|
||||
"toolSettings.FTP.RootPath": "Root path",
|
||||
"toolSettings.FTP.RootPath.hint": "Base directory for file transfers",
|
||||
"toolSettings.Telegram.BotToken": "Bot token",
|
||||
"toolSettings.Telegram.DefaultChatId": "Default chat ID",
|
||||
"toolSettings.Telegram.DefaultChatId.hint": "The ID messages go to when no other is given.",
|
||||
"toolSettings.Telegram.AllowedChatIds": "Allowed chat IDs",
|
||||
"toolSettings.Telegram.AllowedChatIds.hint": "Comma-separated list of allowed chat IDs",
|
||||
"toolSettings.DirectAPI.DefaultProvider": "Default provider",
|
||||
"toolSettings.DirectAPI.CacheTtlSeconds": "Cache TTL (seconds)",
|
||||
"toolSettings.DirectAPI.TwelveDataKey": "Twelve Data key",
|
||||
"toolSettings.DirectAPI.AlphaVantageKey": "Alpha Vantage key",
|
||||
"toolSettings.WebFetch.AllowedDomains": "Allowed domains",
|
||||
"toolSettings.WebFetch.AllowedDomains.hint": "Comma-separated list (e.g. reuters.com,bloomberg.com)",
|
||||
"toolSettings.WebFetch.MaxResponseKb": "Max. response (KB)",
|
||||
"toolSettings.WebFetch.UserAgent": "User agent",
|
||||
"toolSettings.WebMonitor.MonitorsJson": "Monitors (JSON)",
|
||||
"toolSettings.WebMonitor.MonitorsJson.hint": "JSON configuration of the monitors",
|
||||
"toolSettings.AgentComm.Status.hint": "This tool needs no configuration. It lets agents talk to other agents in the same instance.",
|
||||
"toolSettings.SocialMediaManager.XApiKey": "Bearer token",
|
||||
"toolSettings.SocialMediaManager.XApiKey.hint": "X API v2 bearer token for authentication",
|
||||
"toolSettings.SocialMediaManager.XWatchAccounts": "Watched accounts",
|
||||
"toolSettings.SocialMediaManager.XWatchAccounts.hint": "Comma-separated list of X accounts to watch (without @). Example: elonmusk,unusual_whales,DeItaone",
|
||||
"toolSettings.SocialMediaManager.RedditWatchSubreddits": "Watched subreddits",
|
||||
"toolSettings.SocialMediaManager.RedditWatchSubreddits.hint": "Comma-separated list of subreddits to watch (without r/). Example: wallstreetbets,stocks,options",
|
||||
"toolSettings.SocialMediaManager.RedditPostLimit": "Posts per subreddit",
|
||||
"toolSettings.SocialMediaManager.RedditPostLimit.hint": "Maximum number of posts fetched per subreddit on each check (default: 15)",
|
||||
"toolSettings.SocialMediaManager.OpenRouterApiKey": "OpenRouter API key",
|
||||
"toolSettings.SocialMediaManager.OpenRouterApiKey.hint": "API key for speech-to-text transcription via OpenRouter",
|
||||
"toolSettings.SocialMediaManager.STTModel": "STT model",
|
||||
"toolSettings.SocialMediaManager.STTModel.hint": "OpenRouter model ID for transcription",
|
||||
"toolSettings.SocialMediaManager.YoutubeChannels": "YouTube channels",
|
||||
"toolSettings.SocialMediaManager.YoutubeChannels.hint": "Comma-separated list of YouTube channel URLs to watch automatically",
|
||||
"toolSettings.AgentEditor.Status.hint": "Lets the agent read the Identity and Soul of other agents and propose changes. Changes and new agents go to the approval list.",
|
||||
"toolSettings.AgentEditor.AllowSelfEdit": "Edit own Identity/Soul",
|
||||
"toolSettings.AgentEditor.AllowSelfEdit.hint": "May the agent propose changes to its own Identity and Soul? Default: no — otherwise an agent could write away the limits it fails at (#205).",
|
||||
"toolSettings.AgentSpawn.Status.hint": "This tool needs no configuration. It lets agents start other agents and give them tasks.",
|
||||
"agents.edit": "Edit",
|
||||
"agents.editTooltip": "Edit name, model, limits, budget and prompt texts",
|
||||
"agents.editTitle": "Edit agent — {name}",
|
||||
"agents.saved": "Agent saved.",
|
||||
"agents.runNow": "Run now",
|
||||
"agents.runNowTooltip": "Wake the agent by hand, as a scheduled run would",
|
||||
"agents.runStarted": "{name} is running.",
|
||||
"agents.runFailed": "Run failed: {error}",
|
||||
"agents.limits": "Limits",
|
||||
"agents.limitsValue": "{steps} steps · {context} context tokens · {seconds} s",
|
||||
"agents.budget": "Daily budget",
|
||||
"agents.budgetValue": "{cost} · {tokens} tokens",
|
||||
"agents.unlimited": "unlimited",
|
||||
"agents.promptTextsHint": "Identity, Soul and the system prompt are what the model reads before every run. Changing them changes how the agent works.",
|
||||
"agents.field.displayName": "Display name",
|
||||
"agents.field.model": "Model",
|
||||
"agents.field.description": "Description",
|
||||
"agents.field.identity": "Identity",
|
||||
"agents.field.soul": "Soul",
|
||||
"agents.field.systemPrompt": "System prompt",
|
||||
"agents.field.promptCaching": "Prompt caching",
|
||||
"agents.field.maxSteps": "Max. steps per run",
|
||||
"agents.field.maxContextTokens": "Max. context tokens",
|
||||
"agents.field.maxToolResultChars": "Max. characters per tool result",
|
||||
"agents.field.timeoutSeconds": "Time limit per run (seconds)",
|
||||
"agents.field.dailyCostUsd": "Daily budget (USD, 0 = unlimited)",
|
||||
"agents.field.dailyTokens": "Daily tokens (0 = unlimited)",
|
||||
"agents.field.whenExhausted": "When the budget is exhausted",
|
||||
"agents.option.auto": "Automatic",
|
||||
"agents.option.on": "On",
|
||||
"agents.option.off": "Off",
|
||||
"agents.option.expire": "Skip the run",
|
||||
"agents.option.catchUpOnce": "Catch up once",
|
||||
|
||||
"tasks.title": "Tasks",
|
||||
"tasks.newTask": "New Task",
|
||||
@@ -523,6 +619,21 @@
|
||||
"backup.sourceLicensePlaceholder": "License key of the installation where the backup originated…",
|
||||
"backup.restoreWithKey": "Restore with this Key",
|
||||
"backup.keyMismatch": "This license key does not match the backup.",
|
||||
"backup.scheduleTitle": "Automatic backup",
|
||||
"backup.scheduleNeedsRunning": "The schedule is part of the instance file, which only the running instance writes. Start the instance to change it.",
|
||||
"backup.scheduleEnabled": "Back up this instance every day",
|
||||
"backup.scheduleTime": "Time of day",
|
||||
"backup.scheduleKeep": "Keep backups",
|
||||
"backup.scheduleKeepHint": "The newest ones are kept, older ones removed. 0 keeps all.",
|
||||
"backup.importTitle": "Import backup as new instance",
|
||||
"backup.importHint": "For a move to this machine: the backup becomes a new instance. Existing instances are not touched. The new instance does not start by itself.",
|
||||
"backup.importFile": "Backup file",
|
||||
"backup.importName": "Name of the new instance",
|
||||
"backup.importNamePlaceholder": "e.g. Production",
|
||||
"backup.importButton": "Import",
|
||||
"backup.importing": "Importing…",
|
||||
"backup.imported": "Backup imported.",
|
||||
"backup.importFailed": "Import failed: {error}",
|
||||
"backup.deleteTitle": "Delete Backup",
|
||||
"backup.deleteConfirm": "Really permanently delete backup file '{file}'?",
|
||||
"backup.deleted": "Backup deleted.",
|
||||
@@ -558,34 +669,51 @@
|
||||
"logs.empty": "No log messages yet.",
|
||||
|
||||
"settings.title": "Settings & System Architecture",
|
||||
"settings.license": "License",
|
||||
"settings.licenseState": "State",
|
||||
"settings.licenseValid": "Valid",
|
||||
"settings.licenseInvalid": "Not valid",
|
||||
"settings.licenseMissing": "No key",
|
||||
"settings.licenseNoCheck": "No license check",
|
||||
"settings.licenseUnavailable": "The license state could not be read.",
|
||||
"settings.licenseKeyHint": "The key is checked with the license server before it is stored. A refused key leaves the current one in place. One license covers every instance of this installation.",
|
||||
"settings.hardwareId": "Hardware ID",
|
||||
"settings.secretSet": "Stored — leave empty to keep",
|
||||
"settings.secretNotSet": "Not set",
|
||||
"settings.managerCard": "Instance manager",
|
||||
"settings.instancesDirectory": "Instances directory",
|
||||
"settings.webUi": "Web interface",
|
||||
"settings.dcUrl": "Deploymentcenter address",
|
||||
"settings.dcMasterTokenHint": "Used to issue each instance its own reporting token. Only an empty field keeps the stored token.",
|
||||
"settings.managerRestartHint": "Changes here take effect when the instance manager restarts. Port, binding and access token of the web interface are changed in manager.json — a wrong value here would lock this page out.",
|
||||
"settings.instanceStopped": "Instance not running",
|
||||
"settings.instanceStoppedHint": "The instance file belongs to the instance process and is only edited while it runs. Start the instance to change its settings.",
|
||||
"settings.instanceCard": "Instance",
|
||||
"settings.logDirectory": "Log directory",
|
||||
"settings.logDirectoryHint": "Empty uses the default. Currently: {path}. Takes effect after a restart.",
|
||||
"settings.instanceDailyCost": "Daily budget of the instance (USD, 0 = unlimited)",
|
||||
"settings.instanceDailyTokens": "Daily tokens of the instance (0 = unlimited)",
|
||||
"settings.budgetHint": "Across all agents of this instance. Agents also have their own budgets.",
|
||||
"settings.apiKeyHint": "Without a key the agent engine does not start. A new key takes effect after the instance restarts.",
|
||||
"settings.applicationCard": "Application (all instances)",
|
||||
"settings.applicationHint": "settings.json on this machine, read by every instance when it starts.",
|
||||
"settings.minimumLogLevel": "Minimum log level",
|
||||
"settings.openRouterBaseUrl": "OpenRouter address",
|
||||
"settings.statusCheckInterval": "Status check interval (seconds)",
|
||||
"settings.dcToken": "Deploymentcenter token",
|
||||
"settings.dcEnvironment": "Environment",
|
||||
"settings.errorReporting": "Report errors to the Deploymentcenter",
|
||||
"settings.on": "On",
|
||||
"settings.off": "Off",
|
||||
"settings.intro": "Configuration overview following the instance manager concept: Strict separation between global manager level (installation) and isolated instance level.",
|
||||
"settings.managerLevel": "Instance Manager",
|
||||
"settings.managerBadge": "Manager Level · Global",
|
||||
"settings.managerIntro": "Applies to all instances and the WebUI. Stored in manager.json in the application root directory.",
|
||||
"settings.webUiAndNet": "WebUI & Network",
|
||||
"settings.configFile": "Configuration File",
|
||||
"settings.webUiPort": "WebUI Port",
|
||||
"settings.webUiPortDefault": "7000 (default, webUiPort)",
|
||||
"settings.networkBinding": "Network Binding",
|
||||
"settings.networkBindingValue": "loopback (127.0.0.1, webUiBinding)",
|
||||
"settings.accessProtection": "Access Protection",
|
||||
"settings.tokenActive": "Token active (≥ 20 characters)",
|
||||
"settings.licenseAndUpdates": "License & Updates",
|
||||
"settings.licenseScope": "License Scope",
|
||||
"settings.licenseScopeValue": "Installation level (E-M3: Unlimited instances)",
|
||||
"settings.licenseKey": "License Key",
|
||||
"settings.licenseEncrypted": "●●●●-●●●●-●●●●-●●●● (encrypted)",
|
||||
"settings.updateChannel": "Update Channel",
|
||||
"settings.updateChannelValue": "prod (Official release versions)",
|
||||
"settings.updateCheck": "Update Check",
|
||||
"settings.updateCheckValue": "Central at manager level",
|
||||
"settings.dcAndOversight": "Deploymentcenter & Oversight",
|
||||
"settings.dcMasterToken": "Master Token",
|
||||
"settings.dcMasterTokenValue": "●●●●●●●● (generates sub-tokens per instance)",
|
||||
"settings.desktopWindows": "Desktop Windows",
|
||||
"settings.desktopWindowsValue": "Active (each instance has its own window)",
|
||||
"settings.crashProtection": "Crash Protection",
|
||||
"settings.crashProtectionValue": "Max 5 restarts / 10 minutes",
|
||||
"settings.instanceSectionTitle": "Active Instance: {name}",
|
||||
"settings.instanceSectionTitleNone": "Instance-Related Settings",
|
||||
"settings.instanceBadge": "Instance Level · Isolated",
|
||||
@@ -594,41 +722,20 @@
|
||||
"settings.noInstanceSelected": "No instance selected",
|
||||
"settings.noInstancePrompt": "Please select an active instance in the header above or switch to 'Instances' to start or select an instance.",
|
||||
"settings.toInstancesOverview": "To Instances Overview",
|
||||
"settings.identityAndProcess": "Identity & Process",
|
||||
"settings.instanceName": "Instance Name",
|
||||
"settings.instanceId": "Instance ID",
|
||||
"settings.autostartByManager": "Autostart by Manager",
|
||||
"settings.autostartActive": "Active (keep running)",
|
||||
"settings.autostartInactive": "Inactive (manual start)",
|
||||
"settings.communication": "Communication",
|
||||
"settings.communicationValue": "HTTP over Named Pipe (Windows) / Socket (Linux)",
|
||||
"settings.isolatedPaths": "Isolated Directories",
|
||||
"settings.logsPath": "Logs",
|
||||
"settings.backupsPath": "Backups",
|
||||
"settings.stateDbPath": "State Database",
|
||||
"settings.stateDbValue": "Instances/{name}/state.db (exclusive)",
|
||||
"settings.workspacePath": "Shared Workspace",
|
||||
"settings.openRouterCard": "Model Integration (OpenRouter)",
|
||||
"settings.openRouterStatus": "OpenRouter Status",
|
||||
"settings.connected": "Connected",
|
||||
"settings.offline": "Offline",
|
||||
"settings.statusMessage": "Status Message",
|
||||
"settings.credits": "Credits",
|
||||
"settings.apiKey": "API Key",
|
||||
"settings.apiKeyMasked": "Masked in InstanceSettings.json of this instance",
|
||||
"settings.openRouterNotRunning": "Instance is not running. Data will be loaded after start.",
|
||||
"settings.openRouterNoKey": "No key configured or service unreachable.",
|
||||
"settings.runtimeAndSubsystems": "Runtime & Subsystems",
|
||||
"settings.agentEngine": "Agent Engine",
|
||||
"settings.scheduler": "Scanner / Scheduler",
|
||||
"settings.stagingService": "Approvals (Staging)",
|
||||
"settings.dcSubToken": "Deploymentcenter Sub-Token",
|
||||
"settings.subsystemsLoading": "Querying runtime data…",
|
||||
"settings.subsystemsStopped": "Instance stopped — subsystems idle.",
|
||||
"settings.save": "Save Settings",
|
||||
"settings.discard": "Discard Changes",
|
||||
"settings.saved": "Settings saved successfully.",
|
||||
"settings.saveError": "Failed to save settings: {error}",
|
||||
|
||||
"info.title": "System & Status",
|
||||
"info.noInstanceSelected": "No instance selected",
|
||||
|
||||
@@ -131,7 +131,9 @@ export async function api(pfad, optionen = {}) {
|
||||
kopfzeilen["X-Instanz"] = aktiveInstanz;
|
||||
}
|
||||
|
||||
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
|
||||
// A file (Blob) goes as it is — #764: backup import. JSON.stringify would turn it into "{}".
|
||||
if (optionen.body && typeof optionen.body === "object"
|
||||
&& !(optionen.body instanceof FormData) && !(optionen.body instanceof Blob)) {
|
||||
kopfzeilen["Content-Type"] = "application/json";
|
||||
optionen.body = JSON.stringify(optionen.body);
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ export function erstelleModal(titel, inhalt, aktionen = [], optionen = {}) {
|
||||
overlay.hidden = true;
|
||||
overlay.classList.remove("sichtbar");
|
||||
document.removeEventListener("keydown", tasteHandler);
|
||||
window.removeEventListener("hashchange", schliessen);
|
||||
if (typeof optionen.onSchliessen === "function") {
|
||||
optionen.onSchliessen();
|
||||
}
|
||||
@@ -232,6 +233,8 @@ export function erstelleModal(titel, inhalt, aktionen = [], optionen = {}) {
|
||||
overlay.hidden = false;
|
||||
overlay.classList.add("sichtbar");
|
||||
document.addEventListener("keydown", tasteHandler);
|
||||
// #764: A dialog belongs to the view it was opened from; navigating away closes it.
|
||||
window.addEventListener("hashchange", schliessen);
|
||||
const erstesInput = box.querySelector("input, select, textarea, button:not(.schaltflaeche-icon)");
|
||||
erstesInput?.focus();
|
||||
};
|
||||
|
||||
@@ -186,7 +186,31 @@ async function rendereDetails(agentId) {
|
||||
erstelleElement("span", { text: ` ${t("agents.delete")}` })
|
||||
);
|
||||
|
||||
aktionen.append(zumChatBtn, loeschenBtn);
|
||||
// #764: What the desktop agent page could do — edit the agent and wake it by hand.
|
||||
const editButton = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: t("agents.editTooltip"),
|
||||
onclick: () => openEditDialog(agent)
|
||||
},
|
||||
erstelleIcon(PFADE.einstellungen, 14),
|
||||
erstelleElement("span", { text: ` ${t("agents.edit")}` })
|
||||
);
|
||||
|
||||
const runButton = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: t("agents.runNowTooltip"),
|
||||
disabled: Boolean(agent.isRunning),
|
||||
onclick: () => runAgentNow(agent, runButton)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: ` ${t("agents.runNow")}` })
|
||||
);
|
||||
|
||||
aktionen.append(editButton, runButton, zumChatBtn, loeschenBtn);
|
||||
|
||||
// Stammdaten-Karte
|
||||
const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" });
|
||||
@@ -194,7 +218,13 @@ async function rendereDetails(agentId) {
|
||||
[t("agents.name"), agent.displayName],
|
||||
[t("agents.id"), agent.agentId],
|
||||
[t("agents.model"), agent.model],
|
||||
[t("agents.description"), agent.description || "—"]
|
||||
[t("agents.description"), agent.description || "—"],
|
||||
[t("agents.limits"), t("agents.limitsValue", {
|
||||
steps: agent.maxSteps,
|
||||
context: agent.maxContextTokens,
|
||||
seconds: agent.timeoutSeconds
|
||||
})],
|
||||
[t("agents.budget"), formatBudget(agent)]
|
||||
];
|
||||
|
||||
for (const [bez, wert] of felder) {
|
||||
@@ -433,6 +463,127 @@ function oeffneNeuerAgentDialog() {
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
function formatBudget(agent) {
|
||||
const cost = Number(agent.dailyCostUsd) > 0 ? `$${Number(agent.dailyCostUsd).toFixed(2)}` : t("agents.unlimited");
|
||||
const tokens = Number(agent.dailyTokens) > 0 ? Number(agent.dailyTokens).toLocaleString() : t("agents.unlimited");
|
||||
return t("agents.budgetValue", { cost, tokens });
|
||||
}
|
||||
|
||||
async function runAgentNow(agent, button) {
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await api(`/agents/${encodeURIComponent(agent.agentId)}/run`, { method: "POST" });
|
||||
zeigeMeldung(result?.message ?? t("agents.runStarted", { name: agent.displayName }), "info");
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("agents.runFailed", { error: err.message }), "gefahr");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #764: Edits an agent — master data, limits, budget and the three prompt texts. The long
|
||||
* texts are fetched for this agent only; the agent list does not carry them.
|
||||
*/
|
||||
async function openEditDialog(agent) {
|
||||
let texts;
|
||||
try {
|
||||
texts = await api(`/agents/${encodeURIComponent(agent.agentId)}/texts`);
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("agents.loadFailed", { error: err.message }), "gefahr");
|
||||
return;
|
||||
}
|
||||
|
||||
const form = erstelleElement("form", { klasse: "formular-raster", id: "agent-edit-form" });
|
||||
|
||||
const textInput = (value) => erstelleElement("input", { type: "text", value: value ?? "" });
|
||||
const numberInput = (value, min, step = "1") =>
|
||||
erstelleElement("input", { type: "number", min: String(min), step, value: String(value ?? "") });
|
||||
const textArea = (value, rows) => {
|
||||
const area = erstelleElement("textarea", { rows: String(rows) });
|
||||
area.value = value ?? "";
|
||||
return area;
|
||||
};
|
||||
const select = (options, value) => {
|
||||
const el = erstelleElement("select", {},
|
||||
...options.map((o) => erstelleElement("option", { value: o, text: t(`agents.option.${o}`) })));
|
||||
el.value = value;
|
||||
return el;
|
||||
};
|
||||
|
||||
const fields = {
|
||||
displayName: textInput(agent.displayName),
|
||||
model: textInput(agent.model),
|
||||
description: textArea(agent.description, 2),
|
||||
identity: textArea(texts.identity, 8),
|
||||
soul: textArea(texts.soul, 8),
|
||||
systemPrompt: textArea(texts.systemPrompt, 6),
|
||||
promptCaching: select(["auto", "on", "off"], agent.promptCaching || "auto"),
|
||||
maxSteps: numberInput(agent.maxSteps, 1),
|
||||
maxContextTokens: numberInput(agent.maxContextTokens, 1000),
|
||||
maxToolResultChars: numberInput(agent.maxToolResultChars, 100),
|
||||
timeoutSeconds: numberInput(agent.timeoutSeconds, 10),
|
||||
dailyCostUsd: numberInput(agent.dailyCostUsd, 0, "0.01"),
|
||||
dailyTokens: numberInput(agent.dailyTokens, 0),
|
||||
whenExhausted: select(["expire", "catchUpOnce"], agent.whenExhausted || "expire")
|
||||
};
|
||||
fields.displayName.required = true;
|
||||
fields.model.required = true;
|
||||
|
||||
for (const [key, input] of Object.entries(fields)) {
|
||||
form.append(erstelleElement("label", { text: t(`agents.field.${key}`) }), input);
|
||||
}
|
||||
form.append(erstelleElement("p", { klasse: "hinweis", text: t("agents.promptTextsHint") }));
|
||||
|
||||
const cancelButton = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
text: t("common.cancel"),
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
const saveButton = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: t("common.save")
|
||||
});
|
||||
|
||||
const modal = erstelleModal(t("agents.editTitle", { name: agent.displayName }), form, [cancelButton, saveButton], { klasse: "modal-breit" });
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
displayName: fields.displayName.value.trim(),
|
||||
model: fields.model.value.trim(),
|
||||
description: fields.description.value,
|
||||
identity: fields.identity.value,
|
||||
soul: fields.soul.value,
|
||||
systemPrompt: fields.systemPrompt.value,
|
||||
promptCaching: fields.promptCaching.value,
|
||||
maxSteps: Number(fields.maxSteps.value),
|
||||
maxContextTokens: Number(fields.maxContextTokens.value),
|
||||
maxToolResultChars: Number(fields.maxToolResultChars.value),
|
||||
timeoutSeconds: Number(fields.timeoutSeconds.value),
|
||||
dailyCostUsd: Number(fields.dailyCostUsd.value),
|
||||
dailyTokens: Number(fields.dailyTokens.value),
|
||||
whenExhausted: fields.whenExhausted.value
|
||||
};
|
||||
|
||||
saveButton.disabled = true;
|
||||
try {
|
||||
const result = await api(`/agents/${encodeURIComponent(agent.agentId)}`, { method: "PUT", body });
|
||||
modal.schliessen();
|
||||
zeigeMeldung(result?.message ?? t("agents.saved"), "erfolg");
|
||||
await ladeUndRendereListe(agent.agentId);
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("agents.saveFailed", { error: err.message }), "gefahr");
|
||||
} finally {
|
||||
saveButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
async function loescheAgent(agentId, name) {
|
||||
if (!confirm(t("agents.deleteConfirm", { name, id: agentId }))) return;
|
||||
|
||||
@@ -464,29 +615,18 @@ async function zeigeWerkzeugEinstellungen(agentId, toolName) {
|
||||
modal.oeffnen();
|
||||
|
||||
try {
|
||||
const daten = await api(
|
||||
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/settings`
|
||||
const fields = await api(
|
||||
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/form`
|
||||
);
|
||||
box.replaceChildren();
|
||||
|
||||
const hinweis = erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: t("agents.secretHint")
|
||||
});
|
||||
box.append(hinweis);
|
||||
|
||||
if (!daten || Object.keys(daten).length === 0) {
|
||||
box.append(
|
||||
erstelleElement("p", { klasse: "gedaempft", text: t("agents.noSettings") })
|
||||
);
|
||||
if (!fields || fields.length === 0) {
|
||||
box.append(erstelleElement("p", { klasse: "gedaempft", text: t("agents.noSettings") }));
|
||||
} else {
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
for (const [schluessel, wert] of Object.entries(daten)) {
|
||||
const dt = erstelleElement("dt", { text: schluessel });
|
||||
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
box.append(dl);
|
||||
box.append(
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("agents.secretHint") }),
|
||||
toolSettingsForm(agentId, toolName, fields)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
box.replaceChildren(
|
||||
@@ -499,6 +639,92 @@ async function zeigeWerkzeugEinstellungen(agentId, toolName) {
|
||||
await zeigeWerkzeugrechte(agentId, toolName, rechteBox);
|
||||
}
|
||||
|
||||
/** Label of a tool setting; the field name if the language pack has none. */
|
||||
function fieldLabel(toolName, field) {
|
||||
const key = `toolSettings.${toolName}.${field.name}`;
|
||||
const text = t(key);
|
||||
return text === key ? field.name : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* #764: The settings form of a tool — built from the field list the API gives, so a new field
|
||||
* in a tool's settings class appears without a change here. Secrets are never filled in: an
|
||||
* empty password field keeps the stored value.
|
||||
*/
|
||||
function toolSettingsForm(agentId, toolName, fields) {
|
||||
const form = erstelleElement("form", { klasse: "formular-raster" });
|
||||
const inputs = new Map();
|
||||
|
||||
for (const field of fields) {
|
||||
const label = fieldLabel(toolName, field);
|
||||
const hintKey = `toolSettings.${toolName}.${field.name}.hint`;
|
||||
const hint = t(hintKey) === hintKey ? "" : t(hintKey);
|
||||
let input;
|
||||
|
||||
switch (field.kind) {
|
||||
case "info":
|
||||
form.append(erstelleElement("p", { klasse: "gedaempft", text: hint || field.value || "" }));
|
||||
continue;
|
||||
case "bool":
|
||||
input = erstelleElement("input", { type: "checkbox", checked: field.value === "true" });
|
||||
form.append(erstelleElement("label", { klasse: "gruppe-horizontal" }, input, erstelleElement("span", { text: label })));
|
||||
break;
|
||||
case "choice":
|
||||
input = erstelleElement("select", {}, ...field.options.map((o) => erstelleElement("option", { value: o, text: o })));
|
||||
input.value = field.value ?? "";
|
||||
form.append(erstelleElement("label", { text: label }), input);
|
||||
break;
|
||||
case "secret":
|
||||
input = erstelleElement("input", {
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
placeholder: field.isSet ? t("settings.secretSet") : t("settings.secretNotSet")
|
||||
});
|
||||
form.append(erstelleElement("label", { text: label }), input);
|
||||
break;
|
||||
case "number":
|
||||
input = erstelleElement("input", { type: "number", value: field.value ?? "" });
|
||||
form.append(erstelleElement("label", { text: label }), input);
|
||||
break;
|
||||
default:
|
||||
input = field.name.toLowerCase().endsWith("json")
|
||||
? erstelleElement("textarea", { rows: "6" })
|
||||
: erstelleElement("input", { type: "text" });
|
||||
input.value = field.value ?? "";
|
||||
form.append(erstelleElement("label", { text: label }), input);
|
||||
}
|
||||
|
||||
if (hint) form.append(erstelleElement("div", { klasse: "gedaempft text-klein", text: hint }));
|
||||
inputs.set(field.name, { field, input });
|
||||
}
|
||||
|
||||
const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("common.save") });
|
||||
form.append(erstelleElement("div", {}, save));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const values = {};
|
||||
for (const [name, { field, input }] of inputs) {
|
||||
values[name] = field.kind === "bool" ? String(input.checked) : input.value;
|
||||
}
|
||||
save.disabled = true;
|
||||
try {
|
||||
const result = await api(
|
||||
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/form`,
|
||||
{ method: "PUT", body: { values } });
|
||||
zeigeMeldung(result?.message ?? t("common.saved"), "erfolg");
|
||||
// Saved secrets show as "stored" now; reload the fields rather than trusting the form.
|
||||
const fresh = await api(`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/form`);
|
||||
form.replaceWith(toolSettingsForm(agentId, toolName, fresh));
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("agents.saveFailed", { error: err.message }), "gefahr");
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
function getWirkung(effective) {
|
||||
const map = {
|
||||
auto: [t("agents.effectAuto"), "erfolg"],
|
||||
|
||||
@@ -97,11 +97,17 @@ export async function rendereSicherung() {
|
||||
);
|
||||
}
|
||||
|
||||
layout.append(infoBereich, inhaltBereich, ftpBereich);
|
||||
// #764: the schedule of the active instance, and importing a backup as a new instance.
|
||||
const scheduleArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" });
|
||||
const importArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" });
|
||||
|
||||
layout.append(infoBereich, inhaltBereich, scheduleArea, ftpBereich, importArea);
|
||||
container.append(layout);
|
||||
|
||||
// Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da.
|
||||
rendereFtp(ftpBereich);
|
||||
renderImport(importArea);
|
||||
if (instanzId) renderSchedule(scheduleArea);
|
||||
|
||||
if (!instanzId) return;
|
||||
|
||||
@@ -505,3 +511,104 @@ async function rendereFtp(bereich) {
|
||||
|
||||
bereich.replaceChildren(erstelleKarte(t("backup.ftpTitle"), [form]));
|
||||
}
|
||||
|
||||
// ─── Automatic backup of the active instance (#764) ───
|
||||
//
|
||||
// The manager makes the daily backup and reads switch, time and retention from the instance
|
||||
// file. That file belongs to the instance process, so it is written through the instance
|
||||
// (/api/settings) — which is why the instance has to be running to change it.
|
||||
|
||||
async function renderSchedule(area) {
|
||||
let settings;
|
||||
try {
|
||||
settings = await api("/settings");
|
||||
} catch {
|
||||
area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("backup.scheduleNeedsRunning") })
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
const form = erstelleElement("form", { klasse: "formular" });
|
||||
const enabled = checkbox("backup-auto", t("backup.scheduleEnabled"), settings.autoBackupEnabled);
|
||||
const time = erstelleElement("input", { type: "time", value: settings.autoBackupTime || "03:00" });
|
||||
const keep = erstelleElement("input", { type: "number", min: "0", max: "365", value: String(settings.backupKeepCount ?? 14) });
|
||||
const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") });
|
||||
|
||||
form.append(
|
||||
enabled.gruppe,
|
||||
feld(t("backup.scheduleTime"), time),
|
||||
feld(t("backup.scheduleKeep"), keep, t("backup.scheduleKeepHint")),
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" }, save));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
save.disabled = true;
|
||||
try {
|
||||
const result = await api("/settings", {
|
||||
method: "PUT",
|
||||
body: { autoBackupEnabled: enabled.box.checked, autoBackupTime: time.value, backupKeepCount: Number(keep.value) }
|
||||
});
|
||||
zeigeMeldung(result?.message ?? t("common.saved"), "erfolg");
|
||||
} catch (err) {
|
||||
zeigeMeldung(err.message, "gefahr");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [form]));
|
||||
}
|
||||
|
||||
// ─── Import a backup as a new instance (#764) ───
|
||||
//
|
||||
// The move to a fresh machine: there is no instance yet, so no backup list to pick from.
|
||||
// A backup from another installation needs that installation's license key; the server says
|
||||
// so (code "fremde_lizenz") and the field appears.
|
||||
|
||||
function renderImport(area) {
|
||||
const form = erstelleElement("form", { klasse: "formular" });
|
||||
const file = erstelleElement("input", { type: "file", accept: ".clawdbak,.zip", required: true });
|
||||
const name = erstelleElement("input", { type: "text", required: true, placeholder: t("backup.importNamePlaceholder") });
|
||||
const key = erstelleElement("input", { type: "password", autocomplete: "off", placeholder: t("backup.sourceLicensePlaceholder") });
|
||||
const keyField = feld(t("backup.sourceLicenseLabel"), key, t("backup.sourceLicenseHint"));
|
||||
keyField.hidden = true;
|
||||
const submit = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("backup.importButton") });
|
||||
|
||||
form.append(
|
||||
erstelleElement("p", { klasse: "gedaempft", text: t("backup.importHint") }),
|
||||
feld(t("backup.importFile"), file),
|
||||
feld(t("backup.importName"), name),
|
||||
keyField,
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" }, submit));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const chosen = file.files?.[0];
|
||||
if (!chosen) return;
|
||||
|
||||
const headers = { "Content-Type": "application/octet-stream" };
|
||||
if (key.value.trim()) headers["X-Source-License-Key"] = key.value.trim();
|
||||
|
||||
submit.disabled = true;
|
||||
submit.textContent = t("backup.importing");
|
||||
try {
|
||||
const result = await api(`/verwaltung/backups/import?name=${encodeURIComponent(name.value.trim())}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: chosen
|
||||
});
|
||||
zeigeMeldung(result?.meldung ?? result?.message ?? t("backup.imported"), "erfolg", 8000);
|
||||
form.reset();
|
||||
keyField.hidden = true;
|
||||
} catch (err) {
|
||||
if (err.code === "fremde_lizenz") keyField.hidden = false;
|
||||
zeigeMeldung(t("backup.importFailed", { error: err.message }), "gefahr", 8000);
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = t("backup.importButton");
|
||||
}
|
||||
});
|
||||
|
||||
area.replaceChildren(erstelleKarte(t("backup.importTitle"), [form]));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// ClawdDotNet — Einstellungen & Systemarchitektur
|
||||
// ClawdDotNet — Settings (#764)
|
||||
//
|
||||
// Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene.
|
||||
// Two levels, kept apart on purpose:
|
||||
// - the instance manager (manager.json): license, Deploymentcenter master token, update channel;
|
||||
// - the active instance (InstanceSettings.json, owned by the instance process) and the
|
||||
// application file settings.json that every instance on this machine reads.
|
||||
//
|
||||
// Until #764 this page only showed fixed text — the values could be edited in the desktop window
|
||||
// alone. Secrets (license key, tokens, OpenRouter key) are written here and never shown: the API
|
||||
// returns only whether one is set, and an empty field keeps the stored value.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
@@ -10,6 +17,7 @@ import {
|
||||
erstellePlakette,
|
||||
instanzZustandInfo,
|
||||
erstelleIcon,
|
||||
zeigeMeldung,
|
||||
PFADE
|
||||
} from "../modules/dom.js";
|
||||
import { erstelleMcpServerKarte } from "./mcp-servers.js";
|
||||
@@ -21,240 +29,306 @@ export async function rendereEinstellungen() {
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "einstellungen-ansicht" });
|
||||
|
||||
// Kopfbereich
|
||||
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const kopfText = erstelleElement("div");
|
||||
kopfText.append(
|
||||
const head = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
head.append(erstelleElement("div", {},
|
||||
erstelleElement("h1", { text: t("settings.title") }),
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: t("settings.intro")
|
||||
})
|
||||
);
|
||||
kopf.append(kopfText);
|
||||
layout.append(kopf);
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("settings.intro") })));
|
||||
layout.append(head);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SEKTION 1: Instanzmanager (Globale Ebene / Installation)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const managerSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-manager" });
|
||||
|
||||
const managerKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" });
|
||||
const managerTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
managerTitelLinks.append(
|
||||
erstelleIcon(PFADE.server, 20),
|
||||
erstelleElement("h2", { text: t("settings.managerLevel") }),
|
||||
erstellePlakette(t("settings.managerBadge"), "warnung")
|
||||
);
|
||||
managerKopf.append(managerTitelLinks);
|
||||
|
||||
const managerIntro = erstelleElement("p", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: t("settings.managerIntro")
|
||||
});
|
||||
|
||||
const managerGitter = erstelleElement("div", { klasse: "info-gitter" });
|
||||
|
||||
// Karte 1: Weboberfläche & Netzwerk
|
||||
const dlNetz = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlNetz.append(
|
||||
erstelleElement("dt", { text: t("settings.configFile") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: "manager.json" })),
|
||||
erstelleElement("dt", { text: t("settings.webUiPort") }),
|
||||
erstelleElement("dd", { text: t("settings.webUiPortDefault") }),
|
||||
erstelleElement("dt", { text: t("settings.networkBinding") }),
|
||||
erstelleElement("dd", { text: t("settings.networkBindingValue") }),
|
||||
erstelleElement("dt", { text: t("settings.accessProtection") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(t("settings.tokenActive"), "erfolg"))
|
||||
);
|
||||
managerGitter.append(erstelleKarte(t("settings.webUiAndNet"), [dlNetz]));
|
||||
|
||||
// Karte 2: Lizenz & Programm-Updates
|
||||
const dlLizenz = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlLizenz.append(
|
||||
erstelleElement("dt", { text: t("settings.licenseScope") }),
|
||||
erstelleElement("dd", { text: t("settings.licenseScopeValue") }),
|
||||
erstelleElement("dt", { text: t("settings.licenseKey") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: t("settings.licenseEncrypted") })),
|
||||
erstelleElement("dt", { text: t("settings.updateChannel") }),
|
||||
erstelleElement("dd", { text: t("settings.updateChannelValue") }),
|
||||
erstelleElement("dt", { text: t("settings.updateCheck") }),
|
||||
erstelleElement("dd", { text: t("settings.updateCheckValue") })
|
||||
);
|
||||
managerGitter.append(erstelleKarte(t("settings.licenseAndUpdates"), [dlLizenz]));
|
||||
|
||||
// Karte 3: Deploymentcenter & Aufsicht
|
||||
const dlAufsicht = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlAufsicht.append(
|
||||
erstelleElement("dt", { text: "Deploymentcenter" }),
|
||||
erstelleElement("dd", { text: "https://dc.mhdf.de" }),
|
||||
erstelleElement("dt", { text: t("settings.dcMasterToken") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: t("settings.dcMasterTokenValue") })),
|
||||
erstelleElement("dt", { text: t("settings.desktopWindows") }),
|
||||
erstelleElement("dd", { text: t("settings.desktopWindowsValue") }),
|
||||
erstelleElement("dt", { text: t("settings.crashProtection") }),
|
||||
erstelleElement("dd", { text: t("settings.crashProtectionValue") })
|
||||
);
|
||||
managerGitter.append(erstelleKarte(t("settings.dcAndOversight"), [dlAufsicht]));
|
||||
|
||||
managerSektion.append(managerKopf, managerIntro, managerGitter);
|
||||
layout.append(managerSektion);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SEKTION 2: Aktive Instanz (Instanz-Ebene)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const instanzSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-instanz" });
|
||||
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
let instanzenListe = [];
|
||||
try {
|
||||
instanzenListe = await api("/verwaltung/instanzen");
|
||||
} catch {
|
||||
// Wenn Liste nicht abrufbar, weiter
|
||||
}
|
||||
|
||||
const aktiveInstanz = instanzenListe.find((i) => i.id === aktiveId);
|
||||
const zInfo = aktiveInstanz ? instanzZustandInfo(aktiveInstanz.zustand) : { text: t("common.none"), art: "neutral" };
|
||||
|
||||
const instanzKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" });
|
||||
const instanzTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
instanzTitelLinks.append(
|
||||
erstelleIcon(PFADE.fenster, 20),
|
||||
erstelleElement("h2", {
|
||||
text: aktiveInstanz
|
||||
? t("settings.instanceSectionTitle", { name: aktiveInstanz.name })
|
||||
: t("settings.instanceSectionTitleNone")
|
||||
}),
|
||||
erstellePlakette(t("settings.instanceBadge"), "erfolg"),
|
||||
aktiveInstanz ? erstellePlakette(zInfo.text, zInfo.art) : erstellePlakette(t("settings.noInstanceActive"), "neutral")
|
||||
);
|
||||
instanzKopf.append(instanzTitelLinks);
|
||||
|
||||
const instanzIntro = erstelleElement("p", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: t("settings.instanceIntro")
|
||||
});
|
||||
|
||||
instanzSektion.append(instanzKopf, instanzIntro);
|
||||
|
||||
if (!aktiveInstanz) {
|
||||
const leerKarte = erstelleKarte(t("settings.noInstanceSelected"), [
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: t("settings.noInstancePrompt")
|
||||
}),
|
||||
erstelleElement("a", {
|
||||
href: "#/instanzen",
|
||||
klasse: "schaltflaeche primaer klein",
|
||||
text: t("settings.toInstancesOverview")
|
||||
})
|
||||
]);
|
||||
instanzSektion.append(leerKarte);
|
||||
} else {
|
||||
// Daten von der laufenden Instanz abfragen (sofern sie läuft)
|
||||
const [instanzInfo, instanzStatus] = await Promise.all([
|
||||
api("/info").catch(() => null),
|
||||
api("/status").catch(() => null)
|
||||
]);
|
||||
|
||||
const instanzGitter = erstelleElement("div", { klasse: "info-gitter" });
|
||||
|
||||
// Karte 1: Instanz-Identität & Autostart
|
||||
const dlIdent = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlIdent.append(
|
||||
erstelleElement("dt", { text: t("settings.instanceName") }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: aktiveInstanz.name })),
|
||||
erstelleElement("dt", { text: t("settings.instanceId") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: aktiveInstanz.id })),
|
||||
erstelleElement("dt", { text: t("settings.configFile") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/InstanceSettings.json` })),
|
||||
erstelleElement("dt", { text: t("settings.autostartByManager") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(
|
||||
aktiveInstanz.autostart ? t("settings.autostartActive") : t("settings.autostartInactive"),
|
||||
aktiveInstanz.autostart ? "erfolg" : "neutral"
|
||||
)),
|
||||
erstelleElement("dt", { text: t("settings.communication") }),
|
||||
erstelleElement("dd", { text: t("settings.communicationValue") })
|
||||
);
|
||||
instanzGitter.append(erstelleKarte(t("settings.identityAndProcess"), [dlIdent]));
|
||||
|
||||
// Karte 2: Isolierte Verzeichnisse & Speicher
|
||||
const dlPfade = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlPfade.append(
|
||||
erstelleElement("dt", { text: t("settings.logsPath") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Logs/` })),
|
||||
erstelleElement("dt", { text: t("settings.backupsPath") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Backups/` })),
|
||||
erstelleElement("dt", { text: t("settings.stateDbPath") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/state.db (exklusiv)` })),
|
||||
erstelleElement("dt", { text: t("settings.workspacePath") }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Agents/SharedWorkspace/` }))
|
||||
);
|
||||
instanzGitter.append(erstelleKarte(t("settings.isolatedPaths"), [dlPfade]));
|
||||
|
||||
// Karte 3: Modellanbindung & Budget (OpenRouter)
|
||||
const dlModell = erstelleElement("dl", { klasse: "datenliste" });
|
||||
if (instanzStatus) {
|
||||
dlModell.append(
|
||||
erstelleElement("dt", { text: t("settings.openRouterStatus") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(
|
||||
instanzStatus.isApiReachable ? t("settings.connected") : t("settings.offline"),
|
||||
instanzStatus.isApiReachable ? "erfolg" : "gefahr"
|
||||
)),
|
||||
erstelleElement("dt", { text: t("settings.statusMessage") }),
|
||||
erstelleElement("dd", { text: instanzStatus.statusText || "—" }),
|
||||
erstelleElement("dt", { text: t("settings.credits") }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: instanzStatus.creditsText || "—" })),
|
||||
erstelleElement("dt", { text: t("settings.apiKey") }),
|
||||
erstelleElement("dd", { text: t("settings.apiKeyMasked") })
|
||||
);
|
||||
} else {
|
||||
dlModell.append(
|
||||
erstelleElement("dt", { text: "OpenRouter" }),
|
||||
erstelleElement("dd", {
|
||||
klasse: "gedaempft",
|
||||
text: aktiveInstanz.zustand === "Running"
|
||||
? t("settings.openRouterNoKey")
|
||||
: t("settings.openRouterNotRunning")
|
||||
})
|
||||
);
|
||||
}
|
||||
instanzGitter.append(erstelleKarte(t("settings.openRouterCard"), [dlModell]));
|
||||
|
||||
// Karte 4: Teilsysteme der Instanz
|
||||
const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
|
||||
if (instanzInfo) {
|
||||
dlSysteme.append(
|
||||
erstelleElement("dt", { text: t("settings.agentEngine") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.engineActive ? "Aktiv" : "Aus", instanzInfo.engineActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: t("settings.scheduler") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.schedulerActive ? "Aktiv" : "Aus", instanzInfo.schedulerActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: t("settings.stagingService") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.stagingActive ? "Aktiv" : "Aus", instanzInfo.stagingActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: t("settings.dcSubToken") }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.deploymentcenterActive ? "Aktiv" : "Aus", instanzInfo.deploymentcenterActive ? "erfolg" : "neutral"))
|
||||
);
|
||||
} else {
|
||||
dlSysteme.append(
|
||||
erstelleElement("dt", { text: t("info.subsystems") }),
|
||||
erstelleElement("dd", {
|
||||
klasse: "gedaempft",
|
||||
text: aktiveInstanz.zustand === "Running"
|
||||
? t("settings.subsystemsLoading")
|
||||
: t("settings.subsystemsStopped")
|
||||
})
|
||||
);
|
||||
}
|
||||
instanzGitter.append(erstelleKarte(t("settings.runtimeAndSubsystems"), [dlSysteme]));
|
||||
|
||||
instanzSektion.append(instanzGitter);
|
||||
|
||||
// #197: external MCP servers — only a running instance knows them.
|
||||
if (instanzInfo) instanzSektion.append(erstelleMcpServerKarte());
|
||||
}
|
||||
|
||||
layout.append(instanzSektion);
|
||||
layout.append(await managerSection(), await instanceSection());
|
||||
container.append(layout);
|
||||
}
|
||||
|
||||
// ─── Building blocks ───
|
||||
|
||||
function section(kind, icon, title, badges, intro) {
|
||||
const el = erstelleElement("section", { klasse: `einstellungen-sektion ${kind}` });
|
||||
el.append(
|
||||
erstelleElement("div", { klasse: "sektion-titel-zeile" },
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleIcon(icon, 20), erstelleElement("h2", { text: title }), ...badges)),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: intro }));
|
||||
return el;
|
||||
}
|
||||
|
||||
function textInput(value, type = "text") {
|
||||
return erstelleElement("input", { type, value: value ?? "" });
|
||||
}
|
||||
|
||||
function numberInput(value, min, step = "1") {
|
||||
return erstelleElement("input", { type: "number", min: String(min), step, value: String(value ?? 0) });
|
||||
}
|
||||
|
||||
/** A secret is never shown; the placeholder says whether one is stored. */
|
||||
function secretInput(isSet) {
|
||||
return erstelleElement("input", {
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
placeholder: isSet ? t("settings.secretSet") : t("settings.secretNotSet")
|
||||
});
|
||||
}
|
||||
|
||||
function selectInput(options, value) {
|
||||
const el = erstelleElement("select", {}, ...options.map((o) => erstelleElement("option", { value: o, text: o })));
|
||||
el.value = value;
|
||||
return el;
|
||||
}
|
||||
|
||||
function checkbox(checked) {
|
||||
return erstelleElement("input", { type: "checkbox", checked: Boolean(checked) });
|
||||
}
|
||||
|
||||
/** A form of labelled fields with a save button that sends what `collect` returns. */
|
||||
function settingsForm(rows, path, collect, afterSave) {
|
||||
const form = erstelleElement("form", { klasse: "formular-raster" });
|
||||
for (const [label, input, hint] of rows) {
|
||||
if (input.type === "checkbox") {
|
||||
form.append(erstelleElement("label", { klasse: "gruppe-horizontal" }, input, erstelleElement("span", { text: label })));
|
||||
} else {
|
||||
form.append(erstelleElement("label", { text: label }), input);
|
||||
}
|
||||
if (hint) form.append(erstelleElement("div", { klasse: "gedaempft text-klein", text: hint }));
|
||||
}
|
||||
|
||||
const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("common.save") });
|
||||
form.append(erstelleElement("div", {}, save));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
save.disabled = true;
|
||||
try {
|
||||
const result = await api(path, { method: "PUT", body: collect() });
|
||||
zeigeMeldung(result?.message ?? result?.meldung ?? t("common.saved"), "erfolg", 6000);
|
||||
await afterSave?.();
|
||||
} catch (err) {
|
||||
zeigeMeldung(err.message, "gefahr", 8000);
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
function dataList(pairs) {
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
for (const [label, value] of pairs) {
|
||||
dl.append(erstelleElement("dt", { text: label }),
|
||||
value instanceof Node ? erstelleElement("dd", {}, value) : erstelleElement("dd", { text: value ?? "—" }));
|
||||
}
|
||||
return dl;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await rendereEinstellungen();
|
||||
}
|
||||
|
||||
// ─── Instance manager ───
|
||||
|
||||
async function managerSection() {
|
||||
const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"),
|
||||
[erstellePlakette(t("settings.managerBadge"), "warnung")], t("settings.managerIntro"));
|
||||
|
||||
const [license, manager] = await Promise.all([
|
||||
api("/verwaltung/license").catch(() => null),
|
||||
api("/verwaltung/settings").catch(() => null)
|
||||
]);
|
||||
|
||||
const grid = erstelleElement("div", { klasse: "settings-grid" });
|
||||
grid.append(licenseCard(license));
|
||||
if (manager) grid.append(managerCard(manager));
|
||||
el.append(grid);
|
||||
return el;
|
||||
}
|
||||
|
||||
function licenseCard(license) {
|
||||
if (!license) {
|
||||
return erstelleKarte(t("settings.license"), [erstelleElement("p", { klasse: "gedaempft", text: t("settings.licenseUnavailable") })]);
|
||||
}
|
||||
|
||||
const state = !license.enforced
|
||||
? erstellePlakette(t("settings.licenseNoCheck"), "neutral")
|
||||
: license.holds
|
||||
? erstellePlakette(t("settings.licenseValid"), "erfolg")
|
||||
: erstellePlakette(license.keySet ? t("settings.licenseInvalid") : t("settings.licenseMissing"), "gefahr");
|
||||
|
||||
const key = secretInput(license.keySet);
|
||||
const form = settingsForm(
|
||||
[[t("settings.licenseKey"), key, t("settings.licenseKeyHint")]],
|
||||
"/verwaltung/license",
|
||||
() => ({ key: key.value }),
|
||||
refresh);
|
||||
|
||||
return erstelleKarte(t("settings.license"), [
|
||||
dataList([
|
||||
[t("settings.licenseState"), state],
|
||||
[t("settings.hardwareId"), erstelleElement("code", { text: license.hardwareId ?? "—" })]
|
||||
]),
|
||||
form
|
||||
]);
|
||||
}
|
||||
|
||||
function managerCard(manager) {
|
||||
const dcUrl = textInput(manager.deploymentcenterUrl, "url");
|
||||
const masterToken = secretInput(manager.masterTokenSet);
|
||||
const channel = selectInput(["prod", "beta", "dev"], manager.updateChannel);
|
||||
|
||||
return erstelleKarte(t("settings.managerCard"), [
|
||||
dataList([
|
||||
[t("settings.configFile"), erstelleElement("code", { text: manager.configFile })],
|
||||
[t("settings.instancesDirectory"), erstelleElement("code", { text: manager.instancesDirectory })],
|
||||
[t("settings.webUi"), `${manager.webUiBinding} · ${manager.webUiPort}`]
|
||||
]),
|
||||
settingsForm(
|
||||
[
|
||||
[t("settings.dcUrl"), dcUrl],
|
||||
[t("settings.dcMasterToken"), masterToken, t("settings.dcMasterTokenHint")],
|
||||
[t("settings.updateChannel"), channel]
|
||||
],
|
||||
"/verwaltung/settings",
|
||||
() => ({ deploymentcenterUrl: dcUrl.value.trim(), masterToken: masterToken.value, updateChannel: channel.value }),
|
||||
refresh),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("settings.managerRestartHint") })
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Active instance ───
|
||||
|
||||
async function instanceSection() {
|
||||
const activeId = holeAktiveInstanz();
|
||||
const instances = await api("/verwaltung/instanzen").catch(() => []);
|
||||
const active = instances.find((i) => i.id === activeId);
|
||||
const state = active ? instanzZustandInfo(active.zustand) : null;
|
||||
|
||||
const el = section("sektion-instanz", PFADE.fenster,
|
||||
active ? t("settings.instanceSectionTitle", { name: active.name }) : t("settings.instanceSectionTitleNone"),
|
||||
[erstellePlakette(t("settings.instanceBadge"), "erfolg"),
|
||||
state ? erstellePlakette(state.text, state.art) : erstellePlakette(t("settings.noInstanceActive"), "neutral")],
|
||||
t("settings.instanceIntro"));
|
||||
|
||||
if (!active) {
|
||||
el.append(erstelleKarte(t("settings.noInstanceSelected"), [
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("settings.noInstancePrompt") }),
|
||||
erstelleElement("a", { href: "#/instanzen", klasse: "schaltflaeche primaer klein", text: t("settings.toInstancesOverview") })
|
||||
]));
|
||||
return el;
|
||||
}
|
||||
|
||||
const [settings, status, info] = await Promise.all([
|
||||
api("/settings").catch(() => null),
|
||||
api("/status").catch(() => null),
|
||||
api("/info").catch(() => null)
|
||||
]);
|
||||
|
||||
// The instance file belongs to the instance process: it is only written while it runs.
|
||||
if (!settings) {
|
||||
el.append(erstelleKarte(t("settings.instanceStopped"), [
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("settings.instanceStoppedHint") })
|
||||
]));
|
||||
return el;
|
||||
}
|
||||
|
||||
const grid = erstelleElement("div", { klasse: "settings-grid" });
|
||||
grid.append(instanceCard(settings), openRouterCard(settings, status), applicationCard(settings));
|
||||
if (info) grid.append(subsystemsCard(info));
|
||||
el.append(grid);
|
||||
|
||||
// #197: external MCP servers — only a running instance knows them.
|
||||
if (info) el.append(erstelleMcpServerKarte());
|
||||
return el;
|
||||
}
|
||||
|
||||
function instanceCard(s) {
|
||||
const name = textInput(s.instanceName);
|
||||
const logDirectory = textInput(s.logDirectory);
|
||||
const autostart = checkbox(s.autostartByManager);
|
||||
const cost = numberInput(s.dailyCostUsd, 0, "0.01");
|
||||
const tokens = numberInput(s.dailyTokens, 0);
|
||||
|
||||
return erstelleKarte(t("settings.instanceCard"), [settingsForm(
|
||||
[
|
||||
[t("settings.instanceName"), name],
|
||||
[t("settings.logDirectory"), logDirectory, t("settings.logDirectoryHint", { path: s.effectiveLogDirectory })],
|
||||
[t("settings.autostartByManager"), autostart],
|
||||
[t("settings.instanceDailyCost"), cost],
|
||||
[t("settings.instanceDailyTokens"), tokens, t("settings.budgetHint")]
|
||||
],
|
||||
"/settings",
|
||||
() => ({
|
||||
instanceName: name.value.trim(),
|
||||
logDirectory: logDirectory.value.trim(),
|
||||
autostartByManager: autostart.checked,
|
||||
dailyCostUsd: Number(cost.value),
|
||||
dailyTokens: Number(tokens.value)
|
||||
}),
|
||||
refresh)]);
|
||||
}
|
||||
|
||||
function openRouterCard(s, status) {
|
||||
const key = secretInput(s.openRouterApiKeySet);
|
||||
const statusList = status
|
||||
? dataList([
|
||||
[t("settings.openRouterStatus"), erstellePlakette(
|
||||
status.apiReachable ? t("settings.connected") : t("settings.offline"),
|
||||
status.apiReachable ? "erfolg" : "gefahr")],
|
||||
[t("settings.credits"), status.creditsText || "—"]
|
||||
])
|
||||
: erstelleElement("p", { klasse: "gedaempft", text: t("settings.openRouterNoKey") });
|
||||
|
||||
return erstelleKarte(t("settings.openRouterCard"), [
|
||||
statusList,
|
||||
settingsForm([[t("settings.apiKey"), key, t("settings.apiKeyHint")]], "/settings",
|
||||
() => ({ openRouterApiKey: key.value }), refresh)
|
||||
]);
|
||||
}
|
||||
|
||||
function applicationCard(s) {
|
||||
const level = selectInput(["Debug", "Info", "Warn", "Error"], s.minimumLogLevel);
|
||||
const baseUrl = textInput(s.openRouterBaseUrl, "url");
|
||||
const interval = numberInput(s.statusCheckIntervalSeconds, 10);
|
||||
const dcUrl = textInput(s.deploymentcenterUrl, "url");
|
||||
const dcToken = secretInput(s.deploymentcenterTokenSet);
|
||||
const environment = selectInput(["production", "development", "staging"], s.deploymentcenterEnvironment);
|
||||
const reporting = checkbox(s.errorReportingEnabled);
|
||||
const updateCheck = checkbox(s.updateCheckEnabled);
|
||||
const channel = selectInput(["prod", "beta", "dev"], s.updateChannel);
|
||||
|
||||
return erstelleKarte(t("settings.applicationCard"), [
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("settings.applicationHint") }),
|
||||
settingsForm(
|
||||
[
|
||||
[t("settings.minimumLogLevel"), level],
|
||||
[t("settings.openRouterBaseUrl"), baseUrl],
|
||||
[t("settings.statusCheckInterval"), interval],
|
||||
[t("settings.dcUrl"), dcUrl],
|
||||
[t("settings.dcToken"), dcToken],
|
||||
[t("settings.dcEnvironment"), environment],
|
||||
[t("settings.errorReporting"), reporting],
|
||||
[t("settings.updateCheck"), updateCheck],
|
||||
[t("settings.updateChannel"), channel]
|
||||
],
|
||||
"/settings",
|
||||
() => ({
|
||||
minimumLogLevel: level.value,
|
||||
openRouterBaseUrl: baseUrl.value.trim(),
|
||||
statusCheckIntervalSeconds: Number(interval.value),
|
||||
deploymentcenterUrl: dcUrl.value.trim(),
|
||||
deploymentcenterToken: dcToken.value,
|
||||
deploymentcenterEnvironment: environment.value,
|
||||
errorReportingEnabled: reporting.checked,
|
||||
updateCheckEnabled: updateCheck.checked,
|
||||
updateChannel: channel.value
|
||||
}),
|
||||
refresh)
|
||||
]);
|
||||
}
|
||||
|
||||
function subsystemsCard(info) {
|
||||
const badge = (on) => erstellePlakette(on ? t("settings.on") : t("settings.off"), on ? "erfolg" : "neutral");
|
||||
return erstelleKarte(t("settings.runtimeAndSubsystems"), [dataList([
|
||||
[t("settings.agentEngine"), badge(info.engineActive)],
|
||||
[t("settings.scheduler"), badge(info.schedulerActive)],
|
||||
[t("settings.stagingService"), badge(info.stagingActive)],
|
||||
[t("settings.dcSubToken"), badge(info.deploymentcenterActive)]
|
||||
])]);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ public sealed class InstanceRegistry : IAsyncDisposable
|
||||
/// nach einem Neustart der Maschine auch das starten, was jemand mit Absicht
|
||||
/// angehalten hat.</para>
|
||||
/// </summary>
|
||||
/// <summary>#764: Where the instances live — for importing a backup as a new one.</summary>
|
||||
public InstanceDirectoryManager Directories => _directories;
|
||||
|
||||
public async Task RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
// ListInstances liest den Namen aus InstanceSettings.json gleich mit - und tut
|
||||
|
||||
@@ -12,6 +12,10 @@ namespace ClawdDotNet.Manager;
|
||||
public sealed record BackupOutcome(
|
||||
string Path, string FileName, long SizeBytes, bool Uploaded, string? UploadProblem);
|
||||
|
||||
/// <summary>#764: What importing a backup as a new instance came to.</summary>
|
||||
/// <param name="KeyNeeded">The backup is from another installation; its license key is needed.</param>
|
||||
public sealed record ImportOutcome(bool Ok, string Message, bool KeyNeeded = false, string? Directory = null);
|
||||
|
||||
/// <summary>
|
||||
/// Die Sicherungen aller Instanzen, im Manager.
|
||||
///
|
||||
@@ -179,6 +183,134 @@ public sealed class ManagerBackups : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #764: Restores an uploaded backup as a <b>new</b> instance — the move to a fresh machine,
|
||||
/// which the desktop restore window could do and the WebUI could not: there, no instance and
|
||||
/// therefore no backup list exists yet.
|
||||
///
|
||||
/// <para><b>Never into an existing directory.</b> Restoring over an instance is the other
|
||||
/// endpoint, which stops it first. Here the target must not exist, and if anything fails it
|
||||
/// is removed again — it did not exist before, so nothing of value goes with it.</para>
|
||||
///
|
||||
/// <para><b>The restored instance file is from another place.</b> Its working directory, and
|
||||
/// absolute log and backup directories, point at the old machine — or, for a copy on this
|
||||
/// machine, into the original instance, which would then get the copy's logs and lose
|
||||
/// backups to its rotation. They are reset to the new directory. A copy whose id already
|
||||
/// exists here gets a new id and loses the reporting token, so the two do not report as one.
|
||||
/// Autostart is switched off: a just-imported instance starts when someone starts it.</para>
|
||||
/// </summary>
|
||||
public async Task<ImportOutcome> ImportAsNewInstanceAsync(
|
||||
string archivePath, string name, string? sourceLicenseKey, string? passphrase, CancellationToken ct = default)
|
||||
{
|
||||
name = name.Trim();
|
||||
if (name.Length == 0)
|
||||
return new ImportOutcome(false, "The new instance needs a name.");
|
||||
|
||||
var directories = _registry.Directories;
|
||||
var target = directories.GetInstancePath(name);
|
||||
if (Directory.Exists(target))
|
||||
return new ImportOutcome(false, $"An instance '{name}' already exists.");
|
||||
|
||||
string? key = null;
|
||||
if (LicenseBackupCipher.IsEncrypted(archivePath))
|
||||
{
|
||||
var given = !string.IsNullOrWhiteSpace(sourceLicenseKey);
|
||||
key = given ? sourceLicenseKey : LicenseKey;
|
||||
|
||||
if (key is null || !LicenseBackupCipher.MatchesKey(archivePath, key))
|
||||
{
|
||||
return new ImportOutcome(false, given
|
||||
? "The license key given does not match this backup."
|
||||
: "This backup comes from another installation. Its license key is needed to restore it.",
|
||||
KeyNeeded: true);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _service.RestoreAsync(archivePath, target, new RestoreOptions
|
||||
{
|
||||
Passphrase = string.IsNullOrEmpty(passphrase) ? null : passphrase,
|
||||
LicenseKey = key,
|
||||
Overwrite = false
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
var notes = AdoptImportedConfig(directories, target, name);
|
||||
await _registry.RefreshAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Backup imported as new instance {Name}: {Path}", name, target);
|
||||
|
||||
return new ImportOutcome(true,
|
||||
$"Imported as new instance '{name}'." + (notes.Count == 0 ? "" : " " + string.Join(" ", notes)),
|
||||
Directory: target);
|
||||
}
|
||||
catch (Exception ex) when (ex is BackupException or IOException or InvalidDataException
|
||||
or UnauthorizedAccessException or InvalidOperationException
|
||||
or JsonException or FileNotFoundException)
|
||||
{
|
||||
TryRemove(target);
|
||||
_logger.LogWarning(ex, "Import of a backup as instance {Name} failed", name);
|
||||
return new ImportOutcome(false, $"Import failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> AdoptImportedConfig(InstanceDirectoryManager directories, string target, string name)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var config = directories.LoadInstanceConfig(target);
|
||||
|
||||
config.InstanceName = name;
|
||||
config.WorkingDirectory = target;
|
||||
config.AutostartByManager = false;
|
||||
|
||||
if (Path.IsPathRooted(config.LogDirectory))
|
||||
{
|
||||
config.LogDirectory = "";
|
||||
notes.Add("The log directory pointed outside the instance and now uses the default.");
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(config.BackupDirectory))
|
||||
{
|
||||
config.BackupDirectory = "";
|
||||
notes.Add("The backup directory pointed outside the instance and now uses the default.");
|
||||
}
|
||||
|
||||
var taken = directories.ListInstances()
|
||||
.Where(i => !PathBoundary.Comparer.Equals(Path.GetFullPath(i.FolderPath), Path.GetFullPath(target)))
|
||||
.Select(i => TryReadId(directories, i.FolderPath))
|
||||
.Where(id => id is not null)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.InstanceId) || taken.Contains(config.InstanceId))
|
||||
{
|
||||
config.InstanceId = Guid.NewGuid().ToString("N")[..8];
|
||||
config.Watchdog.AgentToken = "";
|
||||
notes.Add("An instance with the same id exists here, so the copy got a new id and will get its own reporting token.");
|
||||
}
|
||||
|
||||
directories.SaveInstanceConfig(target, config);
|
||||
return notes;
|
||||
}
|
||||
|
||||
private static string? TryReadId(InstanceDirectoryManager directories, string folder)
|
||||
{
|
||||
try { return directories.LoadInstanceConfig(folder).InstanceId; }
|
||||
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException) { return null; }
|
||||
}
|
||||
|
||||
private void TryRemove(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Half-imported directory {Path} could not be removed", directory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Behält die neuesten <paramref name="keep"/> Sicherungen dieser Instanz.</summary>
|
||||
public void Rotate(string ordner, string instanceName, int keep)
|
||||
{
|
||||
|
||||
@@ -118,6 +118,9 @@ public sealed class ManagerCore : IAsyncDisposable
|
||||
|
||||
public ManagerSettings Settings => _settings.Settings;
|
||||
|
||||
/// <summary>Where <c>manager.json</c> lives — shown in the web interface.</summary>
|
||||
public string SettingsPath => _settings.SettingsPath;
|
||||
|
||||
// ─── Hochfahren ───
|
||||
|
||||
/// <summary>
|
||||
@@ -313,6 +316,73 @@ public sealed class ManagerCore : IAsyncDisposable
|
||||
/// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer
|
||||
/// ihn ausgelöst hat.</para>
|
||||
/// </summary>
|
||||
/// <summary>Whether a license check is configured at all — without one nothing is locked.</summary>
|
||||
public bool LicenseEnforced => _license is not null;
|
||||
|
||||
/// <summary>The hardware id the license server sees, for support; <c>null</c> without a license check.</summary>
|
||||
public string? LicenseHardwareId => _license?.HardwareId;
|
||||
|
||||
/// <summary>
|
||||
/// #764: A license key from the web interface. The first install of a service has no other
|
||||
/// way to get one: the manager never asks (<c>NonInteractiveLicensePrompt</c>).
|
||||
///
|
||||
/// <para><b>A confirmed key lifts the lock at once</b> — the watch starts and the autostart
|
||||
/// instances come up, as they would have at startup. Without that, a freshly licensed
|
||||
/// manager would still refuse every start until someone restarted the service.</para>
|
||||
///
|
||||
/// <para>The autostart runs without the request's token: whoever closes the browser right
|
||||
/// after saving should not leave half the instances started.</para>
|
||||
/// </summary>
|
||||
public async Task<LicenseApplyResult> ApplyLicenseKeyAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
if (_license is null)
|
||||
return new LicenseApplyResult(false, false, "This instance manager has no license check configured.");
|
||||
|
||||
var result = await _license.ApplyKeyAsync(key, ct).ConfigureAwait(false);
|
||||
if (!result.Applied)
|
||||
return result;
|
||||
|
||||
var wasLocked = !LicenseHolds;
|
||||
LicenseHolds = true;
|
||||
_watch?.Start();
|
||||
|
||||
if (wasLocked)
|
||||
{
|
||||
_logger.LogInformation("License confirmed - instances may start again.");
|
||||
await StartAutostartInstancesAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #764: Changes the manager's own settings in <c>manager.json</c> that the web interface
|
||||
/// offers. <c>null</c> keeps a value; an empty master token keeps the one there is — it is
|
||||
/// never sent out, so a form cannot send it back.
|
||||
///
|
||||
/// <para>Everything here is read once, when the manager starts (license server, token
|
||||
/// source, update check). The answer says so instead of pretending the change is live.</para>
|
||||
///
|
||||
/// <para>Port, binding and the access token of the web interface are deliberately not
|
||||
/// offered: a wrong value there locks out the very page it was set from.</para>
|
||||
/// </summary>
|
||||
public (bool Ok, string Message) UpdateSettings(string? deploymentcenterUrl, string? masterToken, string? updateChannel)
|
||||
{
|
||||
if (deploymentcenterUrl is { } url && url.Trim().Length > 0
|
||||
&& !(Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)))
|
||||
return (false, "The Deploymentcenter address must be an http(s) address.");
|
||||
if (updateChannel is { } channel && channel.Trim() is not ("prod" or "beta" or "dev"))
|
||||
return (false, "The update channel is one of prod, beta, dev.");
|
||||
|
||||
var settings = _settings.Settings;
|
||||
if (deploymentcenterUrl is not null) settings.DeploymentcenterUrl = deploymentcenterUrl.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(masterToken)) settings.DeploymentcenterMasterToken = masterToken.Trim();
|
||||
if (updateChannel is not null) settings.UpdateChannel = updateChannel.Trim();
|
||||
_settings.Save();
|
||||
|
||||
return (true, "Saved. Takes effect when the instance manager restarts.");
|
||||
}
|
||||
|
||||
public async Task HandleLicenseRevokedAsync(string grund)
|
||||
{
|
||||
LicenseHolds = false;
|
||||
|
||||
@@ -57,7 +57,9 @@ public sealed record AgentDto(
|
||||
IReadOnlyList<string> ForeignContentSources,
|
||||
bool TreatAsForeignReader,
|
||||
// #712
|
||||
AgentRuntimeDto Runtime);
|
||||
AgentRuntimeDto Runtime,
|
||||
// #764
|
||||
string WhenExhausted);
|
||||
|
||||
/// <summary>#712: Where an agent's runs happen.</summary>
|
||||
public sealed record AgentRuntimeDto(
|
||||
@@ -132,6 +134,29 @@ public sealed record ServiceInputDto(string Name, string Type, int Port, string
|
||||
/// <summary>Ein neuer Agent.</summary>
|
||||
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null);
|
||||
|
||||
/// <summary>#764: A change to an agent. Fields left out (<c>null</c>) keep their value.</summary>
|
||||
public sealed record AgentUpdateDto(
|
||||
string? DisplayName = null,
|
||||
string? Model = null,
|
||||
string? Description = null,
|
||||
string? SystemPrompt = null,
|
||||
string? Identity = null,
|
||||
string? Soul = null,
|
||||
string? PromptCaching = null,
|
||||
int? MaxToolResultChars = null,
|
||||
int? MaxSteps = null,
|
||||
int? MaxContextTokens = null,
|
||||
int? TimeoutSeconds = null,
|
||||
decimal? DailyCostUsd = null,
|
||||
long? DailyTokens = null,
|
||||
string? WhenExhausted = null);
|
||||
|
||||
/// <summary>
|
||||
/// #764: The long texts of an agent — fetched for one agent at a time, not in the agent list:
|
||||
/// with fifty agents the list would carry every role description in the instance.
|
||||
/// </summary>
|
||||
public sealed record AgentTextsDto(string SystemPrompt, string Identity, string Soul);
|
||||
|
||||
/// <summary>Eine Aufgabe vom Taskboard.</summary>
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
@@ -419,3 +444,30 @@ public sealed record UnitCostDto(
|
||||
|
||||
/// <summary>#699: A cost alert as it was raised.</summary>
|
||||
public sealed record CostAlertDto(string Period, string Scope, string Kind, string Message, DateTime RaisedAt);
|
||||
|
||||
/// <summary>
|
||||
/// #764: A change to the instance and application settings. <c>null</c> keeps a value; for
|
||||
/// the OpenRouter key and the Deploymentcenter token an empty value does as well.
|
||||
/// </summary>
|
||||
public sealed record SettingsUpdateDto(
|
||||
string? InstanceName = null,
|
||||
string? LogDirectory = null,
|
||||
bool? AutostartByManager = null,
|
||||
string? OpenRouterApiKey = null,
|
||||
decimal? DailyCostUsd = null,
|
||||
long? DailyTokens = null,
|
||||
bool? AutoBackupEnabled = null,
|
||||
string? AutoBackupTime = null,
|
||||
int? BackupKeepCount = null,
|
||||
string? MinimumLogLevel = null,
|
||||
string? OpenRouterBaseUrl = null,
|
||||
int? StatusCheckIntervalSeconds = null,
|
||||
string? DeploymentcenterUrl = null,
|
||||
string? DeploymentcenterToken = null,
|
||||
string? DeploymentcenterEnvironment = null,
|
||||
bool? ErrorReportingEnabled = null,
|
||||
bool? UpdateCheckEnabled = null,
|
||||
string? UpdateChannel = null);
|
||||
|
||||
/// <summary>#764: Input of a tool settings form, by field name. An empty secret keeps the stored one.</summary>
|
||||
public sealed record ToolSettingsFormInputDto(Dictionary<string, string?>? Values);
|
||||
|
||||
@@ -45,7 +45,8 @@ internal static class Mapping
|
||||
.ToList(),
|
||||
tools is null ? [] : Core.Organisation.ReportingLine.TrustOf(agent, tools).Sources,
|
||||
agent.TreatAsForeignReader,
|
||||
agent.Runtime.ToDto());
|
||||
agent.Runtime.ToDto(),
|
||||
agent.Budget.MissPolicy == Core.Tasks.BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire");
|
||||
|
||||
public static AgentRuntimeDto ToDto(this Core.Runtimes.AgentRuntimeConfig runtime)
|
||||
=> new(Core.Runtimes.RuntimeKinds.Normalize(runtime.Kind), runtime.AllowedTools, runtime.MaxTurns,
|
||||
|
||||
@@ -34,6 +34,7 @@ internal static class ApiEndpoints
|
||||
api.MapOrganisation();
|
||||
api.MapCases();
|
||||
api.MapFileHistory();
|
||||
api.MapSettings();
|
||||
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||
|
||||
app.MapEvents();
|
||||
@@ -62,6 +63,26 @@ internal static class ApiEndpoints
|
||||
public static IResult Unavailable(string reason)
|
||||
=> Results.Json(new OutcomeDto(false, reason), statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
// ─── Settings (#764) ───
|
||||
|
||||
/// <summary>
|
||||
/// What the desktop settings page edited. The snapshot carries no secret — only whether
|
||||
/// the OpenRouter key and the Deploymentcenter token are set (<see cref="SettingsSnapshot"/>).
|
||||
/// </summary>
|
||||
private static void MapSettings(this RouteGroupBuilder api)
|
||||
{
|
||||
api.MapGet("/settings", (AppHost host) => Results.Ok(host.SettingsAdmin.Snapshot()));
|
||||
|
||||
api.MapPut("/settings", (AppHost host, SettingsUpdateDto input) =>
|
||||
host.SettingsAdmin.Update(new SettingsUpdate(
|
||||
input.InstanceName, input.LogDirectory, input.AutostartByManager, input.OpenRouterApiKey,
|
||||
input.DailyCostUsd, input.DailyTokens, input.AutoBackupEnabled, input.AutoBackupTime,
|
||||
input.BackupKeepCount, input.MinimumLogLevel, input.OpenRouterBaseUrl,
|
||||
input.StatusCheckIntervalSeconds, input.DeploymentcenterUrl, input.DeploymentcenterToken,
|
||||
input.DeploymentcenterEnvironment, input.ErrorReportingEnabled, input.UpdateCheckEnabled,
|
||||
input.UpdateChannel)).ToResponse());
|
||||
}
|
||||
|
||||
// ─── Info und Zustand ───
|
||||
|
||||
private static void MapInfo(this RouteGroupBuilder api)
|
||||
@@ -136,6 +157,19 @@ internal static class ApiEndpoints
|
||||
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
|
||||
host.AgentAdmin.Remove(agentId).ToResponse());
|
||||
|
||||
// #764: What the desktop agent page edited — master data, limits, budget, prompt texts.
|
||||
agents.MapPut("/{agentId}", (AppHost host, string agentId, AgentUpdateDto input) =>
|
||||
host.AgentAdmin.Update(agentId, new AgentUpdate(
|
||||
input.DisplayName, input.Model, input.Description, input.SystemPrompt,
|
||||
input.Identity, input.Soul, input.PromptCaching, input.MaxToolResultChars,
|
||||
input.MaxSteps, input.MaxContextTokens, input.TimeoutSeconds,
|
||||
input.DailyCostUsd, input.DailyTokens, input.WhenExhausted)).ToResponse());
|
||||
|
||||
agents.MapGet("/{agentId}/texts", (AppHost host, string agentId) =>
|
||||
host.AgentAdmin.Find(agentId) is { } agent
|
||||
? Results.Ok(new AgentTextsDto(agent.SystemPrompt, agent.Identity, agent.Soul))
|
||||
: Results.NotFound(new OutcomeDto(false, $"No agent with id '{agentId}'.")));
|
||||
|
||||
// #712: the runtime — native, or foreign in the sandbox.
|
||||
agents.MapPut("/{agentId}/runtime", (AppHost host, string agentId, AgentRuntimeDto input) =>
|
||||
host.AgentAdmin.SetRuntime(agentId, input.ToConfig()).ToResponse());
|
||||
@@ -179,6 +213,19 @@ internal static class ApiEndpoints
|
||||
return Results.Ok(Mapping.ToDto(toolName, settings));
|
||||
});
|
||||
|
||||
// #764: The settings form — what the desktop ToolSettingsView offered. Secrets are
|
||||
// described only as "set" or not, and written, never read back.
|
||||
agents.MapGet("/{agentId}/tools/{toolName}/form", (AppHost host, string agentId, string toolName) =>
|
||||
host.AgentAdmin.Find(agentId) is null
|
||||
? Results.NotFound(new OutcomeDto(false, $"No agent with id '{agentId}'."))
|
||||
: host.AgentAdmin.ToolSettingsForm(agentId, toolName) is { } form
|
||||
? Results.Ok(form)
|
||||
: Results.Ok(Array.Empty<ToolSettingField>()));
|
||||
|
||||
agents.MapPut("/{agentId}/tools/{toolName}/form", (
|
||||
AppHost host, string agentId, string toolName, ToolSettingsFormInputDto input) =>
|
||||
host.AgentAdmin.ApplyToolSettingsForm(agentId, toolName, input.Values ?? new Dictionary<string, string?>()).ToResponse());
|
||||
|
||||
// #641: Rechte je Werkzeug - was laeuft, was vorgelegt wird, was verboten ist.
|
||||
agents.MapGet("/{agentId}/tools/{toolName}/rights", async (
|
||||
AppHost host, string agentId, string toolName, CancellationToken ct) =>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: Editing an agent from the WebUI — what the desktop agent page did directly on the
|
||||
/// configuration objects, now one checked call.
|
||||
/// </summary>
|
||||
public sealed class AgentUpdateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Changes_survive_a_restart_including_identity_and_soul()
|
||||
{
|
||||
// Identity and Soul live in their own files; written only into AgentSettings.json they
|
||||
// would be overwritten by the next load.
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "old-model")).Ok.ShouldBeTrue();
|
||||
|
||||
var result = host.Host.AgentAdmin.Update("scout", new AgentUpdate(
|
||||
DisplayName: "Scout II", Model: "new-model", SystemPrompt: "Be brief.",
|
||||
Identity: "# Identity: changed", Soul: "# Soul: changed", PromptCaching: "OFF",
|
||||
MaxSteps: 42, DailyTokens: 12_345, DailyCostUsd: 1.5m, WhenExhausted: "catchupOnce"));
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
|
||||
var reloaded = host.Host.Directories
|
||||
.LoadInstanceConfig(host.Host.InstancePath).Agents
|
||||
.Single(a => a.AgentId == "scout");
|
||||
|
||||
reloaded.DisplayName.ShouldBe("Scout II");
|
||||
reloaded.Model.ShouldBe("new-model");
|
||||
reloaded.SystemPrompt.ShouldBe("Be brief.");
|
||||
reloaded.Identity.ShouldBe("# Identity: changed");
|
||||
reloaded.Soul.ShouldBe("# Soul: changed");
|
||||
reloaded.PromptCaching.ShouldBe("off");
|
||||
reloaded.LoopGuard.MaxSteps.ShouldBe(42);
|
||||
reloaded.Budget.DailyTokens.ShouldBe(12_345);
|
||||
reloaded.Budget.DailyCostUsd.ShouldBe(1.5m);
|
||||
reloaded.Budget.WhenExhausted.ShouldBe("catchUpOnce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fields_left_out_keep_their_value()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model", "Watches the market")).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Identity: "keep me")).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "other")).Ok.ShouldBeTrue();
|
||||
|
||||
var agent = host.Host.AgentAdmin.Find("scout")!;
|
||||
agent.Model.ShouldBe("other");
|
||||
agent.Description.ShouldBe("Watches the market");
|
||||
agent.Identity.ShouldBe("keep me");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("display")]
|
||||
[InlineData("caching")]
|
||||
[InlineData("exhausted")]
|
||||
[InlineData("steps")]
|
||||
[InlineData("budget")]
|
||||
public void One_bad_value_changes_nothing(string bad)
|
||||
{
|
||||
using var host = new TestHost(TestHost.Agent("scout", "Scout"));
|
||||
var update = bad switch
|
||||
{
|
||||
"display" => new AgentUpdate(Model: "new-model", DisplayName: " "),
|
||||
"caching" => new AgentUpdate(Model: "new-model", PromptCaching: "sometimes"),
|
||||
"exhausted" => new AgentUpdate(Model: "new-model", WhenExhausted: "panic"),
|
||||
"steps" => new AgentUpdate(Model: "new-model", MaxSteps: 0),
|
||||
_ => new AgentUpdate(Model: "new-model", DailyCostUsd: -1m)
|
||||
};
|
||||
|
||||
host.Host.AgentAdmin.Update("scout", update).Ok.ShouldBeFalse();
|
||||
|
||||
host.Host.AgentAdmin.Find("scout")!.Model.ShouldBe("anthropic/claude-sonnet-4-5");
|
||||
host.Changes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_agent_is_refused()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.AgentAdmin.Update("ghost", new AgentUpdate(Model: "x")).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_is_announced()
|
||||
{
|
||||
// Without the event the other views (and a second browser) would keep the old values.
|
||||
using var host = new TestHost(TestHost.Agent("scout"));
|
||||
|
||||
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "x")).Ok.ShouldBeTrue();
|
||||
|
||||
host.Changes.ShouldHaveSingleItem().Kind.ShouldBe(InstanceChangeKind.Agents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: The settings the WebUI reads and writes — what the desktop settings page edited.
|
||||
/// </summary>
|
||||
public sealed class SettingsAdministrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_snapshot_never_carries_a_secret()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.Instance.OpenRouterApiKey = "sk-or-secret";
|
||||
host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-secret";
|
||||
|
||||
var snapshot = host.Host.SettingsAdmin.Snapshot();
|
||||
|
||||
snapshot.OpenRouterApiKeySet.ShouldBeTrue();
|
||||
snapshot.DeploymentcenterTokenSet.ShouldBeTrue();
|
||||
System.Text.Json.JsonSerializer.Serialize(snapshot).ShouldNotContain("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_settings_survive_a_restart()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.Directories.SaveInstanceConfig(host.Host.InstancePath, host.Host.Instance);
|
||||
|
||||
var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(
|
||||
InstanceName: "Renamed", AutostartByManager: true, DailyCostUsd: 2.5m, DailyTokens: 900,
|
||||
AutoBackupEnabled: true, AutoBackupTime: "4:30", BackupKeepCount: 7));
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
|
||||
var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
|
||||
reloaded.InstanceName.ShouldBe("Renamed");
|
||||
reloaded.AutostartByManager.ShouldBeTrue();
|
||||
reloaded.Budget.DailyCostUsd.ShouldBe(2.5m);
|
||||
reloaded.Budget.DailyTokens.ShouldBe(900);
|
||||
reloaded.AutoBackupEnabled.ShouldBeTrue();
|
||||
reloaded.AutoBackupTime.ShouldBe("04:30");
|
||||
reloaded.BackupKeepCount.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_secret_keeps_the_one_there_is()
|
||||
{
|
||||
// The form never gets the key back, so it sends an empty field. That must not wipe it.
|
||||
using var host = new TestHost();
|
||||
host.Host.Instance.OpenRouterApiKey = "sk-or-keep";
|
||||
host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-keep";
|
||||
|
||||
host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "", DeploymentcenterToken: " ")).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-keep");
|
||||
host.Host.Settings.AppSettings.DeploymentcenterToken.ShouldBe("dc-keep");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_key_says_it_needs_a_restart()
|
||||
{
|
||||
// The engine is built at start; without saying so the save button would seem to do nothing.
|
||||
using var host = new TestHost();
|
||||
|
||||
var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "sk-or-new"));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Message.ShouldContain("restart");
|
||||
host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Application_settings_are_written_to_settings_json()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
|
||||
host.Host.SettingsAdmin.Update(new SettingsUpdate(
|
||||
MinimumLogLevel: "warn", UpdateChannel: "beta", ErrorReportingEnabled: false)).Ok.ShouldBeTrue();
|
||||
|
||||
var root = Path.GetDirectoryName(host.Host.Settings.SettingsPath)!;
|
||||
var reloaded = new SettingsManager(root);
|
||||
reloaded.Load();
|
||||
reloaded.AppSettings.MinimumLogLevel.ShouldBe("Warn");
|
||||
reloaded.AppSettings.UpdateChannel.ShouldBe("beta");
|
||||
reloaded.AppSettings.ErrorReportingEnabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("time")]
|
||||
[InlineData("level")]
|
||||
[InlineData("url")]
|
||||
[InlineData("channel")]
|
||||
[InlineData("name")]
|
||||
[InlineData("keep")]
|
||||
public void One_bad_value_changes_nothing(string bad)
|
||||
{
|
||||
using var host = new TestHost();
|
||||
var update = bad switch
|
||||
{
|
||||
"time" => new SettingsUpdate(DailyTokens: 5, AutoBackupTime: "25:00"),
|
||||
"level" => new SettingsUpdate(DailyTokens: 5, MinimumLogLevel: "Chatty"),
|
||||
"url" => new SettingsUpdate(DailyTokens: 5, OpenRouterBaseUrl: "file:///etc/passwd"),
|
||||
"channel" => new SettingsUpdate(DailyTokens: 5, UpdateChannel: "nightly"),
|
||||
"name" => new SettingsUpdate(DailyTokens: 5, InstanceName: " "),
|
||||
_ => new SettingsUpdate(DailyTokens: 5, BackupKeepCount: -1)
|
||||
};
|
||||
|
||||
host.Host.SettingsAdmin.Update(update).Ok.ShouldBeFalse();
|
||||
|
||||
host.Host.Instance.Budget.DailyTokens.ShouldBe(0);
|
||||
host.Changes.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #764: Tool settings written from the WebUI — the desktop ToolSettingsView, without a
|
||||
/// window. The secrets go one way, and nothing the form does not touch may change.
|
||||
/// </summary>
|
||||
public sealed class ToolSettingsFormTests
|
||||
{
|
||||
/// <summary>Settings as they are after loading: values as JsonElement, as from the file.</summary>
|
||||
private static Dictionary<string, object?> Loaded(string json)
|
||||
=> JsonSerializer.Deserialize<Dictionary<string, object?>>(json)!;
|
||||
|
||||
[Fact]
|
||||
public void The_form_shows_no_secret()
|
||||
{
|
||||
var form = ToolSettingsForms.Describe("FTP", Loaded("""{"host":"ftp.example.org","password":"hunter2"}"""))!;
|
||||
|
||||
var password = form.Single(f => f.Name == "Password");
|
||||
password.Kind.ShouldBe(ToolSettingsForms.Secret);
|
||||
password.Value.ShouldBeNull();
|
||||
password.IsSet.ShouldBeTrue();
|
||||
JsonSerializer.Serialize(form).ShouldNotContain("hunter2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_connection_string_is_a_secret_even_without_a_password_mark()
|
||||
{
|
||||
// It carries the database password; ConfigSecrets protects it by name.
|
||||
var form = ToolSettingsForms.Describe("Database", Loaded("""{"connectionString":"Server=db;Password=x"}"""))!;
|
||||
|
||||
form.Single(f => f.Name == "ConnectionString").Kind.ShouldBe(ToolSettingsForms.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_secret_keeps_the_stored_one()
|
||||
{
|
||||
var (settings, error) = ToolSettingsForms.Apply("FTP", Loaded("""{"host":"old","password":"hunter2"}"""),
|
||||
new Dictionary<string, string?> { ["Host"] = "new", ["Password"] = "" });
|
||||
|
||||
error.ShouldBeNull();
|
||||
settings!["host"].ShouldBe("new");
|
||||
settings["password"]!.ToString().ShouldBe("hunter2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untouched_fields_stay_exactly_as_stored()
|
||||
{
|
||||
// allowedTables is a JSON array in the file, allowWrite an old key the class only reads,
|
||||
// "extra" a key no class knows. Saving another field must leave all three alone.
|
||||
var stored = Loaded("""{"type":"mysql","connectionString":"cs","allowedTables":["orders","stock"],"allowWrite":true,"extra":42}""");
|
||||
|
||||
var (settings, _) = ToolSettingsForms.Apply("Database", stored,
|
||||
new Dictionary<string, string?> { ["Type"] = "Postgres" });
|
||||
|
||||
settings!["type"].ShouldBe("postgres");
|
||||
JsonSerializer.Serialize(settings["allowedTables"]).ShouldBe("""["orders","stock"]""");
|
||||
JsonSerializer.Serialize(settings["allowWrite"]).ShouldBe("true");
|
||||
JsonSerializer.Serialize(settings["extra"]).ShouldBe("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_table_list_reads_back_as_names_not_as_json()
|
||||
{
|
||||
var form = ToolSettingsForms.Describe("Database", Loaded("""{"allowedTables":["orders","stock"]}"""))!;
|
||||
|
||||
form.Single(f => f.Name == "AllowedTables").Value.ShouldBe("orders,stock");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Port", "twenty-one")]
|
||||
[InlineData("Nonsense", "x")]
|
||||
public void A_bad_input_is_refused(string field, string value)
|
||||
{
|
||||
var (settings, error) = ToolSettingsForms.Apply("FTP", Loaded("{}"),
|
||||
new Dictionary<string, string?> { [field] = value });
|
||||
|
||||
settings.ShouldBeNull();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_enum_outside_its_values_is_refused()
|
||||
{
|
||||
var (settings, _) = ToolSettingsForms.Apply("Database", Loaded("{}"),
|
||||
new Dictionary<string, string?> { ["AccessLevel"] = "99" });
|
||||
|
||||
settings.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_through_the_agent_keeps_the_rights()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model")).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.SetToolEnabled("scout", "FTP", true).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.SetToolRights("scout", "FTP", ["list"], ["delete"]).Ok.ShouldBeTrue();
|
||||
|
||||
host.Host.AgentAdmin.ApplyToolSettingsForm("scout", "FTP",
|
||||
new Dictionary<string, string?> { ["Host"] = "ftp.example.org" }).Ok.ShouldBeTrue();
|
||||
|
||||
var tool = host.Host.AgentAdmin.Find("scout")!.Tools["FTP"];
|
||||
tool["host"].ShouldBe("ftp.example.org");
|
||||
tool.ShouldContainKey(Werkzeugrechte.ErlaubtSchluessel);
|
||||
tool.ShouldContainKey(Werkzeugrechte.VerbotenSchluessel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tool_without_a_form_has_none()
|
||||
=> ToolSettingsForms.Describe("Memory", []).ShouldBeNull();
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #764: A backup uploaded in the WebUI becomes a new instance — the move to a fresh machine,
|
||||
/// which only the desktop restore window could do before.
|
||||
/// </summary>
|
||||
public sealed class BackupImportTests : IAsyncDisposable
|
||||
{
|
||||
private const string License = "CLAWD-TEST-0000-1111-2222";
|
||||
private const string ForeignLicense = "CLAWD-TEST-9999-8888-7777";
|
||||
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "mimp-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private readonly InstanceDirectoryManager _directories;
|
||||
private readonly ManagerSettingsStore _settings;
|
||||
|
||||
public BackupImportTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
_settings = new ManagerSettingsStore(_root);
|
||||
_settings.Settings.LicenseKey = License;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private async Task<(ManagerBackups Backups, InstanceRegistry Registry)> ServiceAsync()
|
||||
{
|
||||
var registry = new InstanceRegistry(
|
||||
_directories, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
|
||||
SupervisionOptions.Default);
|
||||
await registry.RefreshAsync();
|
||||
return (new ManagerBackups(_settings, registry, null, NullLogger.Instance), registry);
|
||||
}
|
||||
|
||||
/// <summary>An instance with paths of "another machine" and autostart on, backed up and then removed.</summary>
|
||||
private async Task<(string Archive, string InstanceId)> BackupOfAnInstanceAsync(string name, bool removeOriginal = true)
|
||||
{
|
||||
var path = _directories.CreateInstance(name);
|
||||
var file = Path.Combine(path, "InstanceSettings.json");
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(file))!;
|
||||
config.AutostartByManager = true;
|
||||
config.LogDirectory = Path.Combine(_root, "elsewhere", "logs");
|
||||
config.BackupDirectory = Path.Combine(_root, "elsewhere", "backups");
|
||||
File.WriteAllText(file, JsonSerializer.Serialize(config));
|
||||
|
||||
var (backups, registry) = await ServiceAsync();
|
||||
await using (backups)
|
||||
await using (registry)
|
||||
{
|
||||
var instance = registry.Instances.Single(i => PathEquals(i.Directory, path));
|
||||
var outcome = await backups.CreateAsync(instance, false, true, CancellationToken.None);
|
||||
|
||||
var archive = Path.Combine(_root, "upload-" + Guid.NewGuid().ToString("N") + ".bak");
|
||||
File.Copy(outcome.Path, archive);
|
||||
|
||||
if (removeOriginal)
|
||||
{
|
||||
await registry.DisposeAsync();
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
|
||||
return (archive, config.InstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PathEquals(string a, string b)
|
||||
=> ClawdDotNet.Core.Storage.PathBoundary.Comparer.Equals(Path.GetFullPath(a), Path.GetFullPath(b));
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_becomes_a_new_instance_with_its_own_paths()
|
||||
{
|
||||
var (archive, id) = await BackupOfAnInstanceAsync("Alpha");
|
||||
|
||||
var (backups, registry) = await ServiceAsync();
|
||||
await using var _ = backups;
|
||||
await using var __ = registry;
|
||||
|
||||
var result = await backups.ImportAsNewInstanceAsync(archive, "Moved", null, null);
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
var config = _directories.LoadInstanceConfig(result.Directory!);
|
||||
config.InstanceName.ShouldBe("Moved");
|
||||
config.InstanceId.ShouldBe(id); // a move keeps its identity
|
||||
PathEquals(config.WorkingDirectory, result.Directory!).ShouldBeTrue();
|
||||
config.LogDirectory.ShouldBeEmpty();
|
||||
config.BackupDirectory.ShouldBeEmpty();
|
||||
config.AutostartByManager.ShouldBeFalse();
|
||||
registry.Instances.ShouldContain(i => PathEquals(i.Directory, result.Directory!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_copy_next_to_its_original_gets_a_new_id()
|
||||
{
|
||||
// Two instances with one id would report as one and share one reporting token.
|
||||
var (archive, id) = await BackupOfAnInstanceAsync("Alpha", removeOriginal: false);
|
||||
|
||||
var (backups, registry) = await ServiceAsync();
|
||||
await using var _ = backups;
|
||||
await using var __ = registry;
|
||||
|
||||
var result = await backups.ImportAsNewInstanceAsync(archive, "Alpha-Copy", null, null);
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
var config = _directories.LoadInstanceConfig(result.Directory!);
|
||||
config.InstanceId.ShouldNotBe(id);
|
||||
config.Watchdog.AgentToken.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_existing_instance_is_never_overwritten()
|
||||
{
|
||||
var (archive, _) = await BackupOfAnInstanceAsync("Alpha");
|
||||
_directories.CreateInstance("Taken");
|
||||
|
||||
var (backups, registry) = await ServiceAsync();
|
||||
await using var a = backups;
|
||||
await using var b = registry;
|
||||
|
||||
(await backups.ImportAsNewInstanceAsync(archive, "Taken", null, null)).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_foreign_backup_asks_for_its_key_and_leaves_nothing_behind()
|
||||
{
|
||||
var (archive, _) = await BackupOfAnInstanceAsync("Alpha");
|
||||
_settings.Settings.LicenseKey = ForeignLicense; // now this is "another installation"
|
||||
|
||||
var (backups, registry) = await ServiceAsync();
|
||||
await using var a = backups;
|
||||
await using var b = registry;
|
||||
|
||||
var refused = await backups.ImportAsNewInstanceAsync(archive, "Moved", null, null);
|
||||
refused.KeyNeeded.ShouldBeTrue();
|
||||
Directory.Exists(_directories.GetInstancePath("Moved")).ShouldBeFalse();
|
||||
|
||||
var accepted = await backups.ImportAsNewInstanceAsync(archive, "Moved", License, null);
|
||||
accepted.Ok.ShouldBeTrue(accepted.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_broken_file_leaves_no_half_instance()
|
||||
{
|
||||
var junk = Path.Combine(_root, "junk.bak");
|
||||
await File.WriteAllBytesAsync(junk, [1, 2, 3, 4, 5]);
|
||||
|
||||
var (backups, registry) = await ServiceAsync();
|
||||
await using var a = backups;
|
||||
await using var b = registry;
|
||||
|
||||
(await backups.ImportAsNewInstanceAsync(junk, "Broken", null, null)).Ok.ShouldBeFalse();
|
||||
Directory.Exists(_directories.GetInstancePath("Broken")).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using ClawdDotNet.Licensing;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #764: The license key entered in the web interface — the only way in for a manager that
|
||||
/// runs as a service and never asks (<see cref="NonInteractiveLicensePrompt"/>).
|
||||
///
|
||||
/// <para>The license server is replaced by given answers. The promise checked here is not
|
||||
/// "the server judges correctly" — that is the SDK's job — but "only a confirmed key is
|
||||
/// stored, and a confirmed key lifts the lock without a restart".</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseKeyEntryTests : IAsyncDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "lic-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private readonly FakeLauncher _launcher = new();
|
||||
private readonly FakeChannel _channel = new();
|
||||
private readonly InstanceDirectoryManager _directories;
|
||||
|
||||
/// <summary>What the "server" says, per key.</summary>
|
||||
private readonly Dictionary<string, LicenseValidationResult> _answers = new();
|
||||
|
||||
private static readonly SupervisionOptions Options = new(
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public LicenseKeyEntryTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
_channel.Launcher = _launcher;
|
||||
|
||||
_answers["GOOD-KEY"] = new LicenseValidationResult { IsValid = true, Status = "valid" };
|
||||
_answers["REVOKED-KEY"] = new LicenseValidationResult { IsValid = false, Status = "revoked" };
|
||||
_answers["OFFLINE-KEY"] = new LicenseValidationResult { IsValid = false, Status = "server_unavailable", IsTransient = true };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private ManagerCore Manager()
|
||||
{
|
||||
var store = new ManagerSettingsStore(_root);
|
||||
store.Load();
|
||||
|
||||
var gate = new LicenseGate(
|
||||
new ManagerLicenseStore(store), NullLogger.Instance,
|
||||
new NonInteractiveLicensePrompt(_ => { }), "0.0.0-test",
|
||||
(key, _) => Task.FromResult(_answers.TryGetValue(key, out var answer)
|
||||
? answer
|
||||
: new LicenseValidationResult { IsValid = false, Status = "not_found" }));
|
||||
|
||||
var registry = new InstanceRegistry(
|
||||
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
|
||||
|
||||
return new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance, license: gate);
|
||||
}
|
||||
|
||||
private string StoredKey()
|
||||
{
|
||||
var store = new ManagerSettingsStore(_root);
|
||||
return store.Load().LicenseKey;
|
||||
}
|
||||
|
||||
private void AutostartInstance(string name)
|
||||
{
|
||||
var path = _directories.CreateInstance(name);
|
||||
var file = Path.Combine(path, "InstanceSettings.json");
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(file))!;
|
||||
config.AutostartByManager = true;
|
||||
File.WriteAllText(file, JsonSerializer.Serialize(config));
|
||||
_channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-" + name, name, "0.1.2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_confirmed_key_is_stored_and_lifts_the_lock_at_once()
|
||||
{
|
||||
AutostartInstance("Alpha");
|
||||
|
||||
await using var manager = Manager();
|
||||
(await manager.StartAsync()).ShouldBeFalse(); // no key yet: locked
|
||||
_launcher.Starts.ShouldBe(0);
|
||||
|
||||
var result = await manager.ApplyLicenseKeyAsync(" GOOD-KEY ");
|
||||
|
||||
result.Applied.ShouldBeTrue(result.Message);
|
||||
manager.LicenseHolds.ShouldBeTrue();
|
||||
StoredKey().ShouldBe("GOOD-KEY");
|
||||
_launcher.Starts.ShouldBe(1); // the autostart instance came up
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_key_leaves_the_current_one_in_place()
|
||||
{
|
||||
await using (var first = Manager())
|
||||
(await first.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeTrue();
|
||||
|
||||
await using var manager = Manager();
|
||||
var result = await manager.ApplyLicenseKeyAsync("REVOKED-KEY");
|
||||
|
||||
result.Applied.ShouldBeFalse();
|
||||
result.Transient.ShouldBeFalse();
|
||||
StoredKey().ShouldBe("GOOD-KEY");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unreachable_server_stores_nothing()
|
||||
{
|
||||
await using var manager = Manager();
|
||||
await manager.StartAsync();
|
||||
|
||||
var result = await manager.ApplyLicenseKeyAsync("OFFLINE-KEY");
|
||||
|
||||
result.Applied.ShouldBeFalse();
|
||||
result.Transient.ShouldBeTrue();
|
||||
manager.LicenseHolds.ShouldBeFalse();
|
||||
StoredKey().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_key_is_not_sent_anywhere()
|
||||
{
|
||||
await using var manager = Manager();
|
||||
|
||||
(await manager.ApplyLicenseKeyAsync(" ")).Applied.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_license_check_nothing_is_stored()
|
||||
{
|
||||
var registry = new InstanceRegistry(
|
||||
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
|
||||
await using var manager = new ManagerCore(
|
||||
new ManagerSettingsStore(_root), registry, new FakeTokenSource(), NullLoggerFactory.Instance);
|
||||
|
||||
(await manager.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeFalse();
|
||||
StoredKey().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #764 / #661: The WebUI texts come from language packs (<c>wwwroot/i18n/en.json</c> and
|
||||
/// <c>de.json</c>). A key missing in one pack shows up as the bare key on screen — in the
|
||||
/// language nobody tested in. So every run checks the packs, not a person after each change.
|
||||
/// </summary>
|
||||
public sealed partial class LanguagePackTests
|
||||
{
|
||||
private static readonly string WwwRoot = Path.Combine(
|
||||
FindRepositoryRoot(), "src", "ClawdDotNet.Manager.Web", "wwwroot");
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "ClawdDotNet.slnx")))
|
||||
return dir.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Repository root not found above " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Pack(string language)
|
||||
=> JsonSerializer.Deserialize<Dictionary<string, string>>(
|
||||
File.ReadAllText(Path.Combine(WwwRoot, "i18n", language + ".json")))!;
|
||||
|
||||
[GeneratedRegex(@"\{(\w+)\}")]
|
||||
private static partial Regex Placeholder();
|
||||
|
||||
// t("key" … — but not erstelleElement("div": the t must stand on its own.
|
||||
[GeneratedRegex(@"(?<![\w.])t\(\s*[""']([\w.]+)[""']")]
|
||||
private static partial Regex TranslateCall();
|
||||
|
||||
[Fact]
|
||||
public void Both_packs_have_the_same_keys()
|
||||
{
|
||||
var en = Pack("en").Keys.ToHashSet();
|
||||
var de = Pack("de").Keys.ToHashSet();
|
||||
|
||||
en.Except(de).ShouldBeEmpty("missing in de.json");
|
||||
de.Except(en).ShouldBeEmpty("missing in en.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_text_keeps_its_placeholders_in_both_languages()
|
||||
{
|
||||
// "{name} saved" in English and "Gespeichert" in German would drop the name silently.
|
||||
var en = Pack("en");
|
||||
var de = Pack("de");
|
||||
|
||||
var mismatches = en.Keys.Intersect(de.Keys)
|
||||
// Shows the trigger syntax itself ("{field}" / "{feld}" stands for any field name) and
|
||||
// is called without arguments — the braces are text for the reader, not placeholders.
|
||||
.Where(k => k != "trigger.instructionHint")
|
||||
.Where(k => !Placeholder().Matches(en[k]).Select(m => m.Value).ToHashSet()
|
||||
.SetEquals(Placeholder().Matches(de[k]).Select(m => m.Value)))
|
||||
.ToList();
|
||||
|
||||
mismatches.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_key_the_code_asks_for_exists()
|
||||
{
|
||||
var en = Pack("en");
|
||||
|
||||
var missing = Directory.EnumerateFiles(WwwRoot, "*.js", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains(Path.DirectorySeparatorChar + "vendor" + Path.DirectorySeparatorChar))
|
||||
.SelectMany(f => TranslateCall().Matches(File.ReadAllText(f))
|
||||
.Select(m => (File: Path.GetFileName(f), Key: m.Groups[1].Value)))
|
||||
.Where(x => !en.ContainsKey(x.Key))
|
||||
// The example in the doc comment of i18n.js.
|
||||
.Where(x => x.Key != "runs.count")
|
||||
.Select(x => $"{x.File}: {x.Key}")
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_text_is_empty()
|
||||
{
|
||||
foreach (var language in new[] { "en", "de" })
|
||||
Pack(language).Where(p => string.IsNullOrWhiteSpace(p.Value)).Select(p => p.Key).ShouldBeEmpty(language);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user