Stufe 1 (#764): Agent bearbeiten, Lizenz und Einstellungen in der WebUI
- PUT /api/agents/{id} und /texts: Stammdaten, Grenzen, Budget, Identity/Soul/
Systemprompt (L6/L7); "Jetzt ausfuehren" in der Agentenansicht (L9)
- Lizenzschluessel ueber die Weboberflaeche (L1): erst vom Lizenzserver
bestaetigt, dann gespeichert; hebt die Sperre ohne Neustart auf
- GET/PUT /api/settings: Instanz- und Anwendungseinstellungen inkl.
OpenRouter-Key (L3/L4/L5), Geheimnisse nur schreibend
- GET/PUT /api/verwaltung/settings: Deploymentcenter und Update-Kanal des Managers
- Einstellungsseite zeigt echte Werte statt fester Texte
- NonInteractiveLicensePrompt verwies auf --license-set-key, den es nie gab
- Sprachpakete werden bei jedem Lauf geprueft (LanguagePackTests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a9ce72abe2
commit
8552755bb6
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user