UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4

Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-23 12:16:05 +02:00
co-authored by Claude Opus 5
parent b5bf97ae74
commit 95b91f54d9
88 changed files with 11665 additions and 1029 deletions
+3
View File
@@ -48,3 +48,6 @@ appsettings.*.local.json
# (siehe J:\Softwareprojekte\GITEA-EINWEISUNG.md) — dieser Eintrag ist die
# Ruecksicherung, falls doch einmal eine Token-Datei im Repo landet.
.gitea-token
# FTP-Zugangsdaten und Publish-Token fuer pack-and-deploy. Versioniert wird
# ausschliesslich packager.config.example.json ohne Werte.
deploy/packager.config.json
+1
View File
@@ -18,6 +18,7 @@
<Project Path="src/ClawdDotNet.Tools.SocialMediaManager/ClawdDotNet.Tools.SocialMediaManager.csproj" />
<Project Path="src/ClawdDotNet.Tools.Memory/ClawdDotNet.Tools.Memory.csproj" />
<Project Path="src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj" />
<Project Path="src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
+16 -1
View File
@@ -18,7 +18,22 @@
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
-->
<Version>0.1.0</Version>
<Version>0.1.2</Version>
<!--
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
Grund ist der Fehler-Stream: Ohne Symbole auf dem Zielsystem tragen die
gemeldeten Stacktraces keine Zeilennummern, und ein Bugtracker-Eintrag
"irgendwo in AgentEngine" ist die halbe Miete wert. Mitliefern liesse sich
das auch als .pdb - die stehen aber auf der Ausschlussliste des Packagers,
und dort gehoeren sie auch hin: Die nativen Symbole von SkiaSharp und
HarfBuzz allein sind 100 MB.
Eingebettet kostet es ein paar hundert Kilobyte in unseren eigenen
Assemblies und nichts an Betriebsaufwand.
-->
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
{
"plugins": [
"react",
"import"
],
"rules": {
"react/forbid-elements": [
"warn",
{
"forbid": []
}
],
"no-restricted-imports": [
"warn",
{
"patterns": []
}
],
"no-restricted-syntax": [
"warn",
{
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
"message": "Raw hex color — use a design-system color token via var()."
},
{
"selector": "Literal[value=/\\b\\d+px\\b/]",
"message": "Raw px value — use a design-system spacing token via var()."
},
{
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Barlow|Barlow Condensed))/i]",
"message": "Font not provided by the design system. Available: Barlow, Barlow Condensed."
}
]
},
"overrides": [
{
"files": [
"**/index.js"
],
"rules": {
"no-restricted-imports": "off"
}
}
],
"x-omelette": {
"components": {},
"tokens": [
"--color-accent",
"--color-accent-100",
"--color-accent-2",
"--color-accent-2-100",
"--color-accent-2-200",
"--color-accent-2-300",
"--color-accent-2-400",
"--color-accent-2-500",
"--color-accent-2-600",
"--color-accent-2-700",
"--color-accent-2-800",
"--color-accent-2-900",
"--color-accent-200",
"--color-accent-300",
"--color-accent-400",
"--color-accent-500",
"--color-accent-600",
"--color-accent-700",
"--color-accent-800",
"--color-accent-900",
"--color-bg",
"--color-divider",
"--color-neutral-100",
"--color-neutral-200",
"--color-neutral-300",
"--color-neutral-400",
"--color-neutral-500",
"--color-neutral-600",
"--color-neutral-700",
"--color-neutral-800",
"--color-neutral-900",
"--color-surface",
"--color-text",
"--font-body",
"--font-heading",
"--font-heading-weight",
"--radius-lg",
"--radius-md",
"--radius-sm",
"--shadow-lg",
"--shadow-md",
"--shadow-sm",
"--space-1",
"--space-2",
"--space-3",
"--space-4",
"--space-6",
"--space-8"
],
"tokenKinds": {
"--color-bg": "color",
"--color-surface": "color",
"--color-text": "font",
"--color-accent": "color",
"--color-accent-2": "color",
"--color-divider": "color",
"--color-neutral-100": "color",
"--color-neutral-200": "color",
"--color-neutral-300": "color",
"--color-neutral-400": "color",
"--color-neutral-500": "color",
"--color-neutral-600": "color",
"--color-neutral-700": "color",
"--color-neutral-800": "color",
"--color-neutral-900": "color",
"--color-accent-100": "color",
"--color-accent-200": "color",
"--color-accent-300": "color",
"--color-accent-400": "color",
"--color-accent-500": "color",
"--color-accent-600": "color",
"--color-accent-700": "color",
"--color-accent-800": "color",
"--color-accent-900": "color",
"--color-accent-2-100": "color",
"--color-accent-2-200": "color",
"--color-accent-2-300": "color",
"--color-accent-2-400": "color",
"--color-accent-2-500": "color",
"--color-accent-2-600": "color",
"--color-accent-2-700": "color",
"--color-accent-2-800": "color",
"--color-accent-2-900": "color",
"--font-heading": "font",
"--font-heading-weight": "font",
"--font-body": "font",
"--space-1": "spacing",
"--space-2": "spacing",
"--space-3": "spacing",
"--space-4": "spacing",
"--space-6": "spacing",
"--space-8": "spacing",
"--radius-sm": "radius",
"--radius-md": "radius",
"--radius-lg": "radius",
"--shadow-sm": "shadow",
"--shadow-md": "shadow",
"--shadow-lg": "shadow"
},
"fontFamilies": [
"Barlow",
"Barlow Condensed"
]
}
}
@@ -0,0 +1,11 @@
/* @ds-bundle: {"format":4,"namespace":"Industry_indust","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
(() => {
const __ds_ns = (window.Industry_indust = window.Industry_indust || {});
const __ds_scope = {};
(__ds_ns.__errors = __ds_ns.__errors || []);
})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
# Industry design system
Industry is a wireframe: steel-blue on a light technical ground, Barlow Condensed headings over Barlow, a modular grid, and cards, figures and buttons framed as blueprint objects — square-cornered, hairline-bordered, with "+" registration marks at the corners. Cards and figures stay transparent line drawings; the primary button is the one solid object on the board, an accent fill that keeps the square corners and the marks. Photography is duotoned into the steel accent and icons are thin-stroke.
## How to use this
- Link the one stylesheet from every page — `<link rel="stylesheet" href="styles.css">` (adjust the relative path) — and take every color, font, spacing, radius and shadow from its variables (`var(--color-*)`, `var(--font-*)`, `var(--space-*)`, `var(--radius-*)`, `var(--shadow-*)`). Never hard-code a hex, a font name or a px value the tokens already carry.
- Build with the classes below rather than inventing parallel ones; the component pages are plain HTML, so view source and copy the markup.
- `templates/` holds starting points a consuming project can copy whole.
- The whole system was derived from `theme.json`. To change the look, edit the tokens at the top of `styles.css` — every page, the thumbnail and this guide read from them — and keep `theme.json` and the written guidance in step so they don't drift from what the CSS actually does.
## Direction
Modular grid layouts — content in equal-width cells, strong horizontal and vertical rhythm, visible structure. Cards, buttons and major sections are wireframe objects: square-cornered, thin-bordered, with `+` crosshair corner marks (the `.blueprint` class + four `<i class="corner tl/tr/bl/br">` children) — never soft filled rounded blocks. Images and figures get the same treatment: square, hairline-framed and marked, never rounded or clipped. Wrap hero and inline images in the `.duotone` class — they are desaturated and washed in the accent, like a screen print that re-colors with the theme.
## Color
A light ground (`--color-bg` #f2f2f3) with `--color-text` #1d1f20 and a single accent #5980a6 (this is a mono scheme: no second accent was chosen — the `--color-accent-2-*` variables carry a machine-derived stand-in kept only so both sets resolve; treat them as one role). Each role carries a 100900 tonal ramp (`--color-neutral-100``--color-accent-2-900`) generated in OKLCH on a shared perceptual lightness scale, so the same step of any ramp has the same visual weight. Use the light steps (100300) for tinted fills, hovers and subtle borders, 500 as the role's base, and the dark steps (700900) for text on tinted fills and for pressed states; prefer ramp steps over ad-hoc `color-mix()`. For elevation use `--shadow-sm/md/lg` (already tuned to the ground) rather than ad-hoc box-shadows.
## Type
Barlow Condensed for headings over Barlow for body text, loaded as `--font-heading` / `--font-body`. Density 0.85× and radius 4px are already baked into the `--space-*` / `--radius-*` scales — use the variables, not raw numbers.
## Icons
Use Lucide icons (https://lucide.dev), at stroke-width 1.5 for a lighter, more technical look throughout.
## Interaction states
Interactive states are themed, never browser defaults: give every interactive element a `:hover` tint and a pressed state from the accent ramp (one step past the base — `--color-accent-600` on a light ground, `--color-accent-400` on a dark one, or a `color-mix()` tint for outlined/ghost variants), and style keyboard focus with `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }` — never leave the default blue focus ring.
## Components
| Class | What it is | Shown in |
| --- | --- | --- |
| `.btn` with `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-icon`, `.btn-block` | Actions — the primary is a solid accent fill | components/buttons.html |
| `.tag` with `.tag-accent`, `.tag-accent-2`, `.tag-neutral`, `.tag-outline` | Small labels tinted from the ramps (mono palette: accent-2 reads the same as accent) | components/buttons.html |
| `.field` + `label`, `.input`, `.radio` + `.dot`, `.seg` + `.seg-opt` | Form fields and choices on native elements — no script | components/forms.html |
| `.card` with `.card-kicker`, `.card-title`, `.card-body`, `.card-meta`; `.elev-sm/md/lg` | Transparent, hairline-bordered cards with corner registration marks | components/cards.html |
| `.nav` + `.nav-brand` | The header bar | components/navigation.html |
| `.table` | Data tables with themed header and row rules | components/table.html |
| `.dialog-backdrop` + `.dialog` (+ `.dialog-title/-body/-actions`) | A modal at the top elevation | components/dialog.html |
| `.hr` | A horizontal rule — present, but this system prefers whitespace; avoid it | — |
| `.blueprint` + four `<i class="corner tl/tr/bl/br">` children | The wireframe frame every card, figure and primary button wears | components/cards.html |
| `.duotone` | The image wrapper — every content photograph goes through it | foundations/image.html |
States are built in: hovers and pressed states come from the accent ramp, keyboard focus is the 2px accent `:focus-visible` ring, `::selection` is an accent tint, and disabled controls drop to 45% opacity. Don't restyle them per page. The accent-to-ground pair is tuned to at least 3:1 — enough for icons, large text and interface chrome, not for body copy — so for paragraph-size text in the accent use a deep ramp step (`--color-accent-700` on this ground) rather than the accent itself.
## Do
- Frame cards, figures and primary buttons as blueprint objects: the `.blueprint` class plus four `<i class="corner …">` marks.
- Keep the grid visible — equal cells, strong horizontal and vertical rhythm.
- Condense headings (Barlow Condensed) and keep body copy in Barlow.
- Duotone photographs with the `.duotone` wrapper so they take the accent.
## Don't
- Do not round cards, figures or buttons, and do not give cards or figures a surface fill — they are line drawings (the solid accent primary button is the one deliberate exception).
- Do not drop the registration marks from a framed element.
- Do not use thick icon strokes; the set is Lucide at 1.5.
- Do not add decorative color beyond the steel accent. The accent's own deep step (`--color-accent-900`) may carry a full field where the deck's section dividers use it — steel as ground, type reversed to paper. (The landing's numbers sit on a drawn spec-sheet plate on the paper ground instead — its own grammar, not a field.)
## Files
- `styles.css` — the only stylesheet: the token sheet (`:root` variables, ramps, base type) plus the component layer. Link it from every page.
- `readme.md` — this guide.
- `theme.json` — the parameters these files were derived from (a machine-readable record of the theme).
- `thumbnail.html` — the project cover (brand mark + swatches).
- `foundations/type.html` — the type scale and the heading/body pairing at real sizes.
- `foundations/color.html` — color roles and the 100-900 tonal ramps, with usage notes.
- `foundations/layout.html` — the spacing scale, the grid and how edges are drawn.
- `foundations/icons.html` — the icon set at interface sizes, inline and in buttons.
- `foundations/image.html` — how photographs and figures are treated.
- `components/buttons.html` — buttons, icon buttons and tags in every variant and state.
- `components/forms.html` — text fields, radios and the segmented control on native elements.
- `components/cards.html` — content cards and the elevation steps.
- `components/navigation.html` — the header bar pattern.
- `components/table.html` — a data table with the themed header and row rules.
- `components/dialog.html` — a modal over its backdrop at the top elevation.
- `theme.html` — the theme's parameters rendered as a reference sheet.
- `templates/landing/` — a starter page consuming the system the intended way (`index.html`, its `ds-base.js` loader, and the vendored `image-slot.js` its photograph mounts).
- `assets/photo.jpg` — the reference photograph the imagery page treats.
@@ -0,0 +1,286 @@
/* Industry — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
@import url('https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;700&family=Barlow+Condensed:wght@400;600&display=swap');
:root {
--color-bg: #f2f2f3;
--color-surface: #e9e9ea;
--color-text: #1d1f20;
--color-accent: #5980a6;
--color-accent-2: #728fab;
--color-divider: color-mix(in srgb, #1d1f20 16%, transparent);
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
same step of any role matches the others in visual value. */
--color-neutral-100: #f5f5f8;
--color-neutral-200: #e7e7ea;
--color-neutral-300: #d4d4d7;
--color-neutral-400: #b7b7ba;
--color-neutral-500: #98989b;
--color-neutral-600: #7a7a7d;
--color-neutral-700: #5d5d60;
--color-neutral-800: #424244;
--color-neutral-900: #2b2b2d;
--color-accent-100: #eef6ff;
--color-accent-200: #d6ebff;
--color-accent-300: #b5d9fd;
--color-accent-400: #94bce3;
--color-accent-500: #749dc4;
--color-accent-600: #597ea3;
--color-accent-700: #416180;
--color-accent-800: #2c455d;
--color-accent-900: #1d2d3d;
--color-accent-2-100: #eef6ff;
--color-accent-2-200: #d6ebff;
--color-accent-2-300: #bdd8f2;
--color-accent-2-400: #9ebbd8;
--color-accent-2-500: #7e9cb8;
--color-accent-2-600: #627d98;
--color-accent-2-700: #486077;
--color-accent-2-800: #314457;
--color-accent-2-900: #1f2d3a;
--font-heading: "Barlow Condensed", system-ui, sans-serif;
--font-heading-weight: 600;
--font-body: "Barlow", system-ui, sans-serif;
--space-1: 3.4px;
--space-2: 6.8px;
--space-3: 10.2px;
--space-4: 13.6px;
--space-6: 20.4px;
--space-8: 27.2px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 7px;
/* Elevation — derived from the ground: soft ink-tinted shadows on a
light theme, a hairline edge + ambient darkness on a dark one. */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2b2b2d 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2b2b2d 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2b2b2d 22%, transparent);
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.blueprint {
position: relative;
border: 1px solid var(--color-divider);
border-radius: 0;
}
/* The overlay image treatments (halftone, duotone) clip their overlay
(overflow:hidden); a blueprint wrapper draws its registration marks
outside the box, so when both classes share a wrapper the frame must
win. */
.blueprint.halftone, .blueprint.plate, .blueprint.duotone { overflow: visible; }
.blueprint > .corner {
position: absolute; width: 11px; height: 11px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.blueprint > .corner::before, .blueprint > .corner::after {
content: ""; position: absolute; background: currentColor;
}
.blueprint > .corner::before { left: 5px; top: 0; width: 1px; height: 100%; }
.blueprint > .corner::after { top: 5px; left: 0; width: 100%; height: 1px; }
.blueprint > .corner.tl { top: -6px; left: -6px; }
.blueprint > .corner.tr { top: -6px; right: -6px; }
.blueprint > .corner.bl { bottom: -6px; left: -6px; }
.blueprint > .corner.br { bottom: -6px; right: -6px; }
.duotone{position:relative;overflow:hidden}
.duotone::after{content:"";position:absolute;inset:0;pointer-events:none;
background:var(--color-accent);mix-blend-mode:color}
/* ══════════════════════════════════════════════════════════════════════════
Components — built with the tokens above. Plain CSS
on plain HTML: no JavaScript, no build step. Each class is documented in
readme.md and demonstrated in foundations/ and components/.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; }
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: var(--color-divider);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
the pair sits side by side in sign-up rows */
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
}
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
/* — blueprint frame: components are wireframe objects (see .blueprint
and .corner above) — square, transparent, hairline-bordered — */
.card, .btn, .input, .tag, .seg, .dialog { border-radius: 0; }
.card, .dialog { background: transparent; border: 1px solid var(--color-divider); }
.btn { border: 1px solid var(--color-divider); }
.btn-primary { border-color: var(--color-accent); }
.btn-ghost { border-color: transparent; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
# ClawdDotNet UI — Implementierungsleitfaden für Avalonia
Dieses Verzeichnis enthält das HTML-Mockup (`ClawdDotNet-UI-Mockup.dc.html`, im Browser öffnen)
und diese Anleitung zur Übertragung nach Avalonia. Das Mockup ist eine Design-Referenz — keine
lauffähige App. Ziel-Codebasis: `src/ClawdDotNet.Desktop`.
## 1. Was sich ändert
- **Dunkelmodus** mit Schnellumschalter in der Kopfzeile.
- **Sidebar-Navigation** statt `TabControl` mit `TabStripPlacement="Left"` — zuklappbar auf Icon-Breite.
- **Instanzauswahl/-erstellung/-wechsel** als ein Dialog (ersetzt `InstancePickerWindow` in seiner
Doppelrolle als Start-Auswahl UND `OnOpenInstanceManager`-Reopen). Zeigt Status-Punkt + Logo-Monogramm.
- **Agenten-Seite**: wieder wie in WinForms in vier Quadranten (Liste/Einstellungen oben,
Werkzeuge/Werkzeug-Einstellungen unten), aber mit ziehbaren Splittern statt `PropertyGrid`,
und flachen Feldern statt verschachtelter Tabs.
- **Werkzeugleisten** ergänzt bei Jobs (inkl. Aktivieren/Deaktivieren), Diensten, Logs.
- **Statusleiste** erweitert um OpenRouter-API-Status und Token/Kosten-Kurzinfo (1h/24h, Credits).
- **Benachrichtigungsleiste** für instanzübergreifende Probleme (Exceptions, aufgebrauchte Limits).
- **Neue Menügruppe "Analyse"**: Tokennutzung (Charts + Tabelle), Agenten-Chats (Verlauf zwischen
zwei Agenten, siehe `docs/Agentenkommunikation-Konzept.md` für das Datenmodell `AgentMessages`).
## 2. Design-Tokens
Farben als `ResourceDictionary` mit `ThemeDictionaries` (Light/Dark), Zugriff per `DynamicResource`
— nie feste Farbwerte in AXAML (siehe Prüfliste in `Avalonia-Portierungsleitfaden.md`).
```
Light: Bg #f2f2f3 · Surface #e9e9ea · Text #1d1f20 · Accent #5980a6 · Divider #1d1f20 @16%
Dark: Bg #1a1c1e · Surface #232527 · Text #eef0f1 · Accent #7ea3c6 · Divider #eef0f1 @16%
```
Vollständige Ramp-Werte (Neutral/Accent 100900, je Theme gespiegelt) stehen im `<script>`-Block
des Mockups (`LIGHT`/`DARK`-Konstanten) — 1:1 als Farbressourcen übernehmen.
- **Typografie**: Barlow Condensed (Überschriften) über Barlow (Fließtext), beide Google Fonts,
als `FontFamily` einbetten (`Assets/Fonts/`, `avares://…` referenzieren).
- **Radius**: durchgehend eckig (0px) — Cards, Buttons, Inputs ohne `CornerRadius`.
- **Rahmen-Motiv ("Blueprint")**: Cards/Dialoge/primäre Buttons mit 1px Divider-Rahmen + vier
kleinen "+"-Eckmarkierungen. Als wiederverwendbares `ControlTemplate` oder `UserControl`
(`BlueprintFrame`) umsetzen, nicht pro Stelle neu zeichnen.
## 3. Screen-für-Screen
**Hauptfenster** (`MainWindow.axaml`): `TabControl` durch `DockPanel` + eigene Sidebar ersetzen —
`ItemsControl` über `Pages`, Icons per `PathIcon`/`Svg`, aktive Zeile per Style-Trigger auf
`SelectedPage`. Sidebar-Breite als `GridLength`, Zuklapp-Zustand als `bool` im
`MainWindowViewModel`, Breite per `Setter`/`Trigger` oder simple Wertumschaltung 240↔64px.
**Instanzdialog**: `InstancePickerViewModel` um `Status` (running/stopped/error) und `LogoPath`
erweitern (`InstanceInfo`-Modell). Status kommt vermutlich aus einem Health-Check pro Instanz
(separater Prozess/`AppHost` pro Instanz — prüfen, wie Multi-Instanz-Erkennung technisch geht,
da aktuell nur eine Instanz pro Prozess läuft). Logo: Pfad in `InstanceSettings.json` ablegen,
`Image`-Control mit Fallback-Monogramm.
**Agenten-Seite**: `Grid` mit `RowDefinitions="*,Auto,*"` + `GridSplitter` (Zeile 2) für
oben/unten, je Bereich `Grid` mit `ColumnDefinitions="Auto,Auto,*"` + `GridSplitter` (Spalte 2)
für Liste/Detail. Ersetzt beide `SplitContainer` aus der WinForms-Vorlage — Avalonia bringt
`GridSplitter` nativ mit, `MinWidth`/`MinHeight` statt fixer `SplitterDistance` setzen.
Identity/Soul-Buttons öffnen ein kleines Editor-Fenster (`TextEditorWindow` mit `TextBox
AcceptsReturn`), analog zu WinForms `btn_editIdentity`/`btn_editSoul`.
**Werkzeugleisten** (Jobs/Services/Logs): 1:1 aus den bestehenden `TasksPageView.axaml` /
`LogPageView.axaml` übernehmen — die Toolbars existieren dort bereits, nur die neue
Aktivieren/Deaktivieren-Aktion bei Jobs ist neu (`JobDisplayEntry.Status` umschalten,
`RelayCommand` in `TasksPageViewModel`).
**Statusleiste**: `OpenRouterStatusService` ist bereits fertig (`StatusText`, `CreditsText`,
`CreditsTooltip`, `IsApiReachable`) — nur in `MainWindowViewModel`/`StatusBar` binden, aktuell
ungenutzt.
**Benachrichtigungsleiste**: braucht einen neuen Dienst, der Fehler/Limits *anderer* Instanzen
sieht — das ist der größte neue Baustein, da `AppHost` heute nur die aktuell laufende Instanz
kennt. Einfachster Weg: beim Start alle `InstanceSettings.json`/`TokenUsage.json` im
Instanzverzeichnis lesen (ohne den vollen `AppHost` der anderen Instanz zu starten) und auf
offensichtliche Signale prüfen (Guthaben 0, Fehlerzähler in Logs). Sauberer, aber größerer
Wurf: ein leichtgewichtiger Cross-Instance-Status, den jede laufende Instanz in eine gemeinsame
Datei schreibt.
**Analyse-Seiten**: Tokennutzung liest aus `TokenUsageFile`/`TokenUsageRecord` (bereits vorhanden,
siehe `Models/TokenUsageRecord.cs`) — nur Aggregation (pro Tag/Agent/Modell) und ein Chart-Control
fehlen (z. B. `LiveChartsCore.SkiaSharpView.Avalonia`, oder für die einfachen Balken im Mockup
reicht ein simples `ItemsControl` mit `Rectangle`-Höhen wie im Mockup — keine Chart-Library nötig,
wenn nur Balken/Linien gebraucht werden). Agenten-Chats braucht den in
`docs/Agentenkommunikation-Konzept.md` beschriebenen `AgentMessages`-Speicher — die Seite selbst
ist nur zwei `ComboBox` + gefilterte `ItemsControl` wie im Chat-Tab.
## 4. Nicht im Mockup, aber zu beachten
- Alle Regeln aus `docs/Avalonia-Portierungsleitfaden.md` Abschnitt 2 und 4 gelten unverändert
(Schichtschnitt App/Desktop, `Dispatcher.UIThread`, `AppPaths.DataDirectory`, keine
`MessageBox`/WinForms-Reste).
- Das Mockup zeigt Wireframe-Icons als Inline-SVG; in Avalonia mit `PathIcon` + Lucide-Pfaden
oder als `.svg`-Assets über `Avalonia.Svg` einbinden (Stroke-Width 1.5, siehe Icon-Namen im
Mockup-Quelltext als Anhaltspunkt für die Lucide-Auswahl).
- Splitter-Positionen, Sidebar-Zuklappzustand und Analyse-Filter sollten persistiert werden
(`SettingsManager`), damit sie den Neustart überleben.
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"_comment": "Vorlage fuer deploy/packager.config.json (per .gitignore ausgeschlossen). Aufruf: pack-and-deploy --config deploy/packager.config.json ... Siehe docs/Deploymentcenter-2.4-Integrationsplan.md",
"_ftp_comment": "Dieselben Zugangsdaten wie Deploymentcenter/scripts/deploy_config.json - derselbe Server, anderes Zielverzeichnis. NICHT deploy.py verwenden: das spiegelt den Deploymentcenter-Projektbaum in die FTP-Wurzel und hat mit dem Veroeffentlichen eines Releases nichts zu tun.",
"ftpHost": "",
"ftpPort": 21,
"ftpUser": "",
"ftpPass": "",
"_remote_comment": "Auf diesem Server liegt die Release-Ablage auf der FTP-Wurzel, nicht unter public/ oder public_html/ - geprueft am 2026-08-13. Die Beispielvorlage des Packagers nennt /public_html/releases, das passt hier nicht.",
"ftpRemoteBaseDir": "/releases",
"apiBaseUrl": "https://dc.mhdf.de",
"_apiToken_comment": "Token mit dem Recht updateservice:publish, im WebUI unter Token-Verwaltung erzeugen. Ohne ihn laedt der Packager das Paket zwar hoch, meldet es aber nicht an, es entsteht KEINE Signatur, und der Rueckgabewert ist 2.",
"apiToken": "",
"_excludePatterns_comment": "Kommt gar nicht erst ins Paket. Echte Globs: * innerhalb eines Ordners, ** ueber Ordnergrenzen.",
"excludePatterns": [
"*.pdb",
"*.xml",
"*.log",
"logs/**",
"Logs/**",
"Instances/**",
"tools/**",
"*.tmp"
],
"_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber nie eine vorhandene Datei. Bei uns bewusst leer: Die Konfiguration liegt in AppPaths.ConfigDirectory (%APPDATA% bzw. $XDG_CONFIG_HOME) und damit ausserhalb des Installationsverzeichnisses - ein Update kann sie gar nicht erreichen. setup.json gehoert NICHT hierher: sie ist eine Beschreibung, keine eingerichtete Datei, und soll mit jedem Release aktualisiert werden.",
"preservePatterns": []
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Baut ClawdDotNet fuer eine Plattform und veroeffentlicht es im Deploymentcenter.
python deploy/publish.py --rid win-x64 --channel dev
python deploy/publish.py --rid linux-x64 --channel dev --dry-run
Drei Schritte, die einzeln zu leicht vergessen werden:
1. dotnet publish in ein frisches Verzeichnis. Frisch, weil der Packager alles
einpackt, was er vorfindet - Reste eines aelteren Laufs landeten sonst mit
im Paket.
2. update-agent dazulegen. Ohne ihn findet ResolveAgentPath() nichts, und die
Anwendung kann sich nicht selbst aktualisieren. Die Erstinstallation legt
ihn NICHT ins Zielverzeichnis - sie laeuft von dort, wo der Benutzer sie
hingelegt hat.
3. pack-and-deploy aufrufen.
Die Version kommt aus Directory.Build.props. Sie hier noch einmal anzugeben
waere eine zweite Pflegestelle - und der Packager bricht bei einer Abweichung
zur Assembly ohnehin ab.
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj"
CONFIG = REPO / "deploy" / "packager.config.json"
PACKAGER = Path(
"J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager"
"/bin/Release/net8.0/pack-and-deploy.exe"
)
INSTALLER_BASE = "https://dc.mhdf.de/installer"
def read_version() -> str:
text = (REPO / "Directory.Build.props").read_text(encoding="utf-8")
match = re.search(r"<Version>([^<]+)</Version>", text)
if not match:
sys.exit("Directory.Build.props enthaelt kein <Version>.")
return match.group(1).strip()
def run(cmd: list[str], **kw) -> None:
print(" $", " ".join(str(c) for c in cmd))
result = subprocess.run(cmd, **kw)
if result.returncode != 0:
sys.exit(f"Abgebrochen (Rueckgabewert {result.returncode}): {cmd[0]}")
def publish(rid: str, out_dir: Path) -> None:
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
run([
"dotnet", "publish", str(PROJECT),
"-c", "Release", "-r", rid, "--self-contained", "false",
"-o", str(out_dir), "--nologo", "-v", "q",
])
def fetch_agent(rid: str, out_dir: Path) -> None:
"""Holt den update-agent und prueft die Pruefsumme.
Bewusst das ausgelieferte Binary statt eines selbst gebauten: Es ist
dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt.
Ohne Pruefsummenvergleich waere das ein Download, der spaeter fremden Code
ausfuehrt - genau der Pfad, den eine Signatur schuetzen soll.
"""
with urllib.request.urlopen(f"{INSTALLER_BASE}/installer.json", timeout=30) as r:
manifest = json.load(r)
entry = next((b for b in manifest.get("binaries", []) if b.get("platform") == rid), None)
if entry is None:
sys.exit(f"Kein update-agent fuer {rid} im Installer-Manifest.")
print(f" update-agent {manifest.get('version')} fuer {rid} ({entry['sizeBytes']} Bytes)")
with urllib.request.urlopen(f"{INSTALLER_BASE}/{entry['file']}", timeout=180) as r:
payload = r.read()
actual = hashlib.sha256(payload).hexdigest()
if actual != entry["sha256"].lower():
sys.exit(f"Pruefsumme weicht ab!\n erwartet: {entry['sha256']}\n erhalten: {actual}")
target = out_dir / ("update-agent.exe" if rid.startswith("win") else "update-agent")
target.write_bytes(payload)
if not rid.startswith("win"):
target.chmod(0o755)
print(f" Pruefsumme in Ordnung -> {target.name}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rid", default="win-x64", help="win-x64, linux-x64, ...")
parser.add_argument("--channel", default="dev", choices=["dev", "beta", "prod"])
parser.add_argument("--changelog", default="")
parser.add_argument("--no-agent", action="store_true",
help="update-agent nicht mitliefern")
parser.add_argument("--dry-run", action="store_true",
help="nur bauen und pruefen, nicht hochladen")
args = parser.parse_args()
version = read_version()
print(f"ClawdDotNet {version} | {args.rid} | Kanal {args.channel}")
out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}"
print("\n[1/3] dotnet publish")
publish(args.rid, out_dir)
print("\n[2/3] update-agent")
if args.no_agent:
print(" uebersprungen (--no-agent)")
else:
fetch_agent(args.rid, out_dir)
total = sum(f.stat().st_size for f in out_dir.rglob("*") if f.is_file())
print(f"\n {len(list(out_dir.rglob('*')))} Eintraege, {total / 1_048_576:.1f} MB")
if args.dry_run:
print(f"\n[3/3] uebersprungen (--dry-run). Ergebnis liegt in {out_dir}")
return
if not CONFIG.exists():
sys.exit(f"{CONFIG} fehlt. Vorlage: deploy/packager.config.example.json")
print("\n[3/3] pack-and-deploy")
cmd = [
str(PACKAGER), "--config", str(CONFIG),
"--project", "clawddotnet", "--version", version,
"--channel", args.channel, "--platform", args.rid,
"--publish-dir", str(out_dir),
]
if args.changelog:
cmd += ["--changelog", args.changelog]
run(cmd)
if __name__ == "__main__":
main()
+19 -2
View File
@@ -119,8 +119,25 @@ und Tippfehler in Bindungspfaden fallen erst zur Laufzeit auf.
Benachrichtigung.
- Befehle: `[RelayCommand] private void Speichern() { … }` → bindbar als
`SpeichernCommand`.
- Formatierung gehört in `Styles/Shell.axaml`, nicht an einzelne Steuerelemente.
Vorhandene Klassen: `heading`, `caption`, `toolbar`, `card`, `statusbar`.
- Formatierung gehört in `Styles/`, nicht an einzelne Steuerelemente. Seit der
Umsetzung des Entwurfs aus `Mockup/` liegt sie in drei Dateien:
`Theme.axaml` (Farben je Thema, Schriften), `Icons.axaml` (Symbolgeometrien),
`Shell.axaml` (Steuerelement-Vorlagen und Stilklassen).
Klassen: `h1`, `h2`, `kicker`, `label`, `caption`, `muted`, `mono`, `card`,
`console`, `hr`, `sep`, `toolbar`, `thead`, `tr`, `th`, `td`, `tag`,
`statusbar`, `topbar`, `sidebar`, `nav`; an Schaltflächen zusätzlich
`primary`, `toolbar`, `ghost`, `flat`, `icon`.
- Symbole über `Controls/StrokeIcon.cs` mit einer Geometrie aus `Icons.axaml`.
Die Farbe wird geerbt — nicht gesetzt.
- Rahmen mit Eckmarken über `Controls/BlueprintFrame.cs`. Sparsam: nur Dialoge
und die Info-Karte.
- Tabellen von Hand aus `Border.thead` + `ListBox.table`, nicht mit `DataGrid`.
Das Paket ist nicht mehr referenziert.
- **Avalonia 12 hat die Ressourcenschlüssel des Fluent-Themas umgebaut.** Die aus
11er-Anleitungen bekannten Namen (`ButtonBackground`, `TextControlBackground`,
`ControlCornerRadius` …) existieren nicht mehr; ein Setter darauf ist wirkungslos
und fällt nicht auf. Für neue Steuerelemente deshalb eine eigene `ControlTheme`
in `Shell.axaml` schreiben statt zu versuchen, Fluent umzufärben.
- Ordner öffnen: `Core.Storage.SystemShell.OpenFolder(pfad)`. **Kein** `explorer.exe`.
- Dateinamen erzeugen: `Core.Storage.PortableFileName.Sanitize(name)`.
@@ -0,0 +1,299 @@
# Deploymentcenter 2.2 2.4: Was noch zu tun ist
Stand: 2026-08-13. Ergänzt [Deploymentcenter-Integration](Deploymentcenter-Integration.md)
(dort steht der Stand nach 2.1) um die drei neuen Ausbaustufen.
| Fassung | Was dazukam | Betrifft uns |
|---|---|---|
| **2.2** | Plattform-Dimension, signierte Releases, Anwenden mit Rollback, `preservePatterns` | Release-Strecke, Update-Anwendung |
| **2.3** | Erstinstallation über `update-agent --action install`, `setup.json`, Installationskonto | Neu, siehe §4 |
| **2.4** | Release-Ablage hinter HTTP-Basic-Auth, Zugang über den Lizenzschlüssel | **Erledigt**, siehe §1 |
---
## 1. Zugangsschutz (2.4) — erledigt
`CheckForUpdateAsync` übergibt jetzt `ReleaseCredentials.FromLicenseKey(...)`, und
`UpdateCheckResult.Unauthorized` wird getrennt von einem Netzfehler behandelt.
**Warum das nicht warten konnte:** UPGRADE §16.1 empfiehlt „erst ausliefern, dann
scharfschalten". Für ein Produkt, das noch nie veröffentlicht hat, geht diese Reihenfolge
nicht auf. `ReleaseGuard::regenerateForProject` überspringt Verzeichnisse, die es nicht
gibt — `/releases/clawddotnet/` liefert derzeit 404, es ist also nichts geschützt. Sobald
wir das **erste** Release hochladen, entsteht das Verzeichnis, und der nächste
`tick.php`-Lauf legt den Schutz an. Der erste ausgelieferte Build muss die Zugangsdaten
also bereits mitbringen, sonst schließt sich die Tür hinter dem ersten Release.
---
## 2. Plattform (2.2) — Release-Strecke steht
Erstes Paket veröffentlicht: **0.1.0, Kanal `dev`, Plattform `win-x64`**, 116 Dateien,
25 MB, Rückgabewert 0. Die Update-Prüfung antwortet korrekt (`0.0.9` → Update, `0.1.0`
keins).
```bash
pack-and-deploy --config deploy/packager.config.json \
--project clawddotnet --version 0.1.0 \
--channel dev --platform win-x64 \
--publish-dir <dotnet-publish-Ausgabe>
```
- Zugangsdaten in `deploy/packager.config.json` (per `.gitignore` ausgeschlossen),
Vorlage ohne Werte in [`packager.config.example.json`](../deploy/packager.config.example.json).
- **`ftpRemoteBaseDir` ist `/releases`**, nicht `/public_html/releases` wie in der
Packager-Vorlage: Auf diesem Server liegt die Release-Ablage auf der FTP-Wurzel.
- Der Packager veröffentlicht mit einem **Sub-Token**, das nur `updateservice:publish`
trägt — gezogen über `/api/tokens/v1/provision`. Das Master-Token gehört nicht in eine
Konfigurationsdatei.
- **`deploy.py` ist dafür das falsche Werkzeug.** Es spiegelt den
Deploymentcenter-Projektbaum in die FTP-Wurzel und hat mit dem Veröffentlichen eines
Anwendungspakets nichts zu tun.
- Die Versionsgegenprobe des Packagers greift und passt: `<Version>` aus
[Directory.Build.props](../Directory.Build.props) stimmt mit `clawddotnet.dll` überein.
Offen: `linux-x64` (erst nach der Avalonia-Portierung) und `prod`.
Clientseitig ist nichts zu tun: Das SDK schickt die Kennung des laufenden Systems von
selbst.
### `preservePatterns` betrifft uns kaum
Unsere Konfiguration liegt seit der Linux-Portierung in `AppPaths.ConfigDirectory`
(`%APPDATA%` bzw. XDG), **nicht** neben der Programmdatei. Ein Update kann sie also gar
nicht überschreiben. Zu prüfen bleibt nur, dass keine leeren Arbeitsordner ins Paket
wandern — die `CreateWorkingDirectories`-Targets in
[ClawdDotNet.csproj](../ClawdDotNet.csproj) legen `tools/`, `Logs/` und `Instances/`
unter `OutputPath` an, und die sind mit AppPaths ohnehin überholt.
---
## 3. Update anwenden — erledigt
Aus dem Hinweis ist eine Rückfrage geworden („Jetzt installieren" / „Später"), die den
`update-agent` startet. Umgesetzt in
[`DeploymentcenterService.StartUpdate`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs)
und `App.StartUpdateAsync`.
### Der Agent wird mitgeliefert — er muss es
Die Erstinstallation legt den Agenten **nicht** ins Zielverzeichnis: Sie läuft von dort,
wo der Benutzer sie hingelegt hat. `ResolveAgentPath()` sucht ihn aber neben der
Anwendung. Ohne Mitliefern fände die Anwendung nie einen Agenten und könnte sich nicht
aktualisieren.
[`deploy/publish.py`](../deploy/publish.py) holt das ausgelieferte Binary von
`/installer/`, **prüft die SHA256 gegen `installer.json`** und legt es plattformrichtig
ab (`update-agent.exe` bzw. `update-agent`). Bewusst das offizielle statt eines selbst
gebauten: Es ist dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt.
Ein ungeprüfter Download wäre ausgerechnet auf dem Pfad, der später fremden Code
ausführt, die falsche Sparsamkeit.
Kosten: rund 28 MB im gepackten Paket (25 → 53 MB).
### Die Reihenfolge ist der eigentliche Inhalt
```
1. AnnounceUpdate(version) → Watchdog meldet beim Beenden "maintenance"
2. AppHost.DisposeAsync() → Datenbank, Scanner, Telegram, Abmeldung
3. StartUpdate(...) → Agent starten, exitCurrentApp: false
4. desktop.Shutdown() → wir beenden uns selbst
```
Die Verlockung wäre, `LaunchUpdateAgent` das Beenden zu überlassen. Das tut es aber über
`Environment.Exit` und übergeht damit Schritt 2 vollständig: keine Abmeldung, keine
geschlossene Instanzdatenbank. Deshalb `exitCurrentApp: false` und
`waitForCurrentProcess: true` — der Agent bekommt unsere Prozesskennung und wartet, bis
wir wirklich weg sind, statt über gesperrte Dateien zu kopieren.
`maintenance` statt `stopped` ist kein Schönheitsfehler: `stopped` heißt „bewusst
beendet" und lässt den Monitor liegen, bis jemand ihn anfasst. Beim Update kommt die
Instanz aber wieder.
**Doppeltes Aufräumen** war die Falle dabei: Nach Schritt 2 ruft `desktop.Shutdown()` die
Behandlung, die erneut aufräumt — und dabei den gerade gesetzten Wartungszustand mit
einer zweiten Abmeldung überschrieben hätte. `AppHost.DisposeAsync` sperrt sich jetzt
selbst gegen den zweiten Durchlauf.
---
## 4. Erstinstallation (2.3) — `setup.json` steht
Der Konfigurationsort war der Blocker: `setup.json`-Ziele waren „relativ zum
Installationsverzeichnis", unsere Konfiguration liegt aber in `%APPDATA%` bzw.
`$XDG_CONFIG_HOME` — weil `/opt/clawddotnet` unter Linux für den Dienstbenutzer nicht
schreibbar ist ([Linux-Analyse](Linux-Portierung-Analyse.md)).
Das Deploymentcenter hat daraufhin `location` am Ziel ergänzt (`install`, `config`,
`data`, `home`) samt Variablenersetzung in `file`. Damit ist der Weg frei;
[`setup.json`](../src/ClawdDotNet.Desktop/setup.json) liegt im Projekt und wird ins
Ausgabeverzeichnis kopiert, landet also im Paket neben der `manifest.json`.
### Der Ordnername ist bewusst kleingeschrieben
`AppPaths` legt das Verzeichnis plattformabhängig unterschiedlich an:
| Plattform | Pfad |
|---|---|
| Windows | `%APPDATA%\ClawdDotNet` |
| Linux | `$XDG_CONFIG_HOME/clawddotnet` (klein, Konvention) |
Eine `setup.json` kennt nur **eine** Schreibweise. `clawddotnet/Settings.json` trifft
unter Linux exakt und unter Windows ebenfalls, weil NTFS Groß- und Kleinschreibung nicht
unterscheidet. Andersherum ginge es nicht: `ClawdDotNet` wäre unter Linux ein zweites,
leeres Verzeichnis neben dem, aus dem die Anwendung liest.
### Was dabei abfällt
Das Token stellt der Server aus (`source: "provision"`), die Server-Adresse kommt aus dem
Installer (`detect:baseurl`). Damit entfällt der Absatz „bis die Avalonia-Einstellungs-
ansicht steht, von Hand in `Settings.json`" aus der
[Integrationsbeschreibung](Deploymentcenter-Integration.md) — jedenfalls für frisch
installierte Systeme.
Der Installer schreibt Lizenzschlüssel und Token **im Klartext**; er kennt unsere
DPAPI-Hülle nicht. Das ist in Ordnung und abgesichert: `SecretProtector.Unprotect` gibt
Klartext unverändert zurück, beim ersten Speichern wird verschlüsselt. Der Test dazu
steht in `SecretProtectorTests` und nennt jetzt beide Gründe, damit ihn niemand als
Altlast entfernt.
### Zwei Grenzen bleiben
- **`CLAWD_CONFIG_DIR` kennt der Installer nicht.** Wer den Ort per Umgebungsvariable
verlegt, muss die Datei selbst verschieben.
- **Wer installiert, entscheidet mit** (SETUP warnt selbst davor): `config` bezieht sich
auf das Konto, unter dem der Installer läuft. Für einen systemd-Dienst mit eigenem
Benutzer heißt das: als dieser Benutzer installieren, sonst landet die Konfiguration
im falschen Profil.
### Durchgespielt (2026-08-15)
Anmeldung mit dem Installationskonto und der gesamte Ablauf gegen den echten Server:
| Schritt | Ergebnis |
|---|---|
| `POST /api/setup/v1/login` | 201, Rolle `installer`, Recht `setup:install`, Token 30 min gültig |
| `GET /api/setup/v1/catalog?platform=win-x64` | `clawddotnet` (dev=0.1.2) erscheint. **Ohne `platform` leer** — wie die Update-Prüfung, der Agent schickt `PlatformId.Current` |
| `POST /api/setup/v1/token` | Anwendungstoken mit genau `watchdog:ping` + `bugtracker:report` |
| Rechteschranke | Das ausgestellte Token kann **kein** `updateservice:publish` nachziehen (403 `provision_denied`) |
| `SetupPaths.Resolve` gegen unsere `setup.json` | löst unter Windows auf `%APPDATA%\ClawdDotNet\Settings.json` auf (`fileWindows` greift) |
| **Round-Trip** SDK schreibt → `SettingsManager` liest | trägt: camelCase-Keys treffen, der Klartext-Lizenzschlüssel geht durch den Entschlüsselungspfad (der Klartext unverändert durchreicht) |
Damit ist der Weg vollständig: Ein frisch aufgesetztes System bekommt über den Installer
Server-Adresse, Lizenzschlüssel und ein vom Server ausgestelltes Instanz-Token in die
`Settings.json` geschrieben, die ClawdDotNet dann ohne Zutun lädt.
### Befund am Rande: alte Felder bleiben stehen
Der `SetupWriter` merged in eine vorhandene `Settings.json`, statt sie zu ersetzen —
richtig so, sonst gingen Logging-Einstellungen und Ähnliches verloren. Auf einem System
mit einer **alten** Datei bleiben dabei Felder stehen, die es in der aktuellen
`AppSettings` nicht mehr gibt (`watchdogServerUrl`, `licensePublicKeyBase64` aus der
LicenseLabrador-Zeit). Harmlos — `SettingsManager` ignoriert unbekannte Felder beim
Laden —, aber tote Einträge in der Datei. Kein Handlungsbedarf; beim ersten `Save` der
laufenden App verschwinden sie.
**Nicht enthalten** (SETUP §7): systemd-Unit und Windows-Dienst legt der Installer nicht
an. Für den kopflosen Betrieb bleibt das unsere Aufgabe.
---
## 4a. Der Update-Weg ist durchgespielt
Am 2026-08-14 gegen den echten Server geprüft, nicht nur gebaut. Ausgangslage: das
0.1.0-Paket mit Lizenzschlüssel geladen und entpackt — also eine Installation, wie sie
beim Kunden aussieht — plus eine selbst angelegte Datei, die in keinem Manifest steht.
| Fall | Ergebnis |
|---|---|
| **0.1.0 → 0.1.1** | RC 0. SHA256 des Pakets und 117 Manifest-Hashes geprüft. Fremde Datei unangetastet, `setup.json` da, `update-agent.exe` neu im Ziel |
| **Rücksprung 0.1.1 → 0.1.0** | RC 0. `update-agent.exe` als nicht mehr zum Release gehörig **entfernt** — und nur die, die fremde Datei blieb liegen |
| **Ohne Lizenzschlüssel** | `UNAUTHORIZED: … Erwartet wird der Lizenzschluessel dieser Installation`, RC 2. Sauber von einem Netzfehler unterschieden |
| **Abbruch mitten im Schreiben** (Datei exklusiv gesperrt) | RC 1, „Vorheriger Stand wurde wiederhergestellt". Version, Dateizahl und Inhalt unverändert — die Installation blieb lauffähig |
Damit trägt die Zusage aus §4B des UpdateService-Handbuchs: Ein Abbruch hinterlässt keine
halbe Installation, und verwaiste Dateien werden aufgeräumt, ohne fremde anzufassen.
Zwei Kleinigkeiten am Rand:
- Nach dem gescheiterten Lauf blieb ein **leeres** `.dc-update-backup/` zurück. Kein
Speicherverlust — der Rollback hatte alles zurückgeholt —, und der nächste erfolgreiche
Lauf hat es entfernt. Ein leeres Verzeichnis dieses Namens sieht für einen Betreiber
aber nach „Update hängt" aus.
- Der Agent weist bei **jedem** Lauf auf das unsignierte Release hin. Das ist richtig so
und wird erst still, wenn §5 erledigt ist.
---
## 5. Signierte Releases (2.2) — Schlüssel steht, Prüfung getestet
Der Signierschlüssel ist seit dem 2026-08-14 serverseitig hinterlegt (RSA-SHA256,
`canonical-line-v1`). **0.1.2 ist das erste signierte Release**; 0.1.0 und 0.1.1 bleiben
unsigniert, weil serverseitig beim Veröffentlichen signiert wird.
Am Testsystem durchgespielt:
| Fall | Ergebnis |
|---|---|
| Signiertes 0.1.2 mit `--require-signature` | RC 0, kein Unsigniert-Hinweis mehr |
| Unsigniertes 0.1.1 mit `--require-signature` | **RC 1, Abbruch vor dem Herunterladen** |
| Unsigniertes 0.1.1 ohne die Pflicht | RC 0 mit Hinweis — wie dokumentiert |
Der öffentliche Schlüssel wird beim ersten Lauf geholt und als
`dc-release-pubkey.pem` neben dem Agenten festgehalten. Ein später abweichender Schlüssel
fällt damit auf.
### Offen: die Pflicht ist aus der Anwendung heraus nicht erreichbar
`--require-signature` gibt es **nur als Kommandozeilenschalter**.
`UpdateClient.LaunchUpdateAgent` — der vom Handbuch empfohlene Weg, den auch wir
benutzen — hat dafür keinen Parameter, und der Agent liest keine Umgebungsvariable dafür
(`Program.cs:66` liest ausschließlich `HasFlag(args, "--require-signature")`).
Damit läuft jede Anwendung, die den empfohlenen Weg geht, ohne Signaturprüfung, während
derselbe Vorgang von Hand auf der Kommandozeile geschützt wäre. Wir könnten den Start
selbst nachbauen — dann verlieren wir aber `--restart`, `--wait-for-pid` und
`--wait-timeout`, also genau die Handgriffe, für die es die Hilfsmethode gibt.
**Gemeldet.** Sobald `LaunchUpdateAgent` einen Parameter dafür hat, setzen wir ihn:
Alle unsere Releases ab 0.1.2 sind signiert, ein Rückschritt auf unsignierte Stände wäre
danach kein Verlust.
---
## 6. Reihenfolge
1. **Zugangsschutz** (§1) — erledigt, muss im ersten Release drin sein.
2. **`setup.json`** (§4) — erledigt, wird mit dem ersten Paket ausgeliefert.
3. **Release-Strecke** (§2): `packager.config.json`, erster Testlauf nach `dev`.
4. **Update anwenden** (§3): Agent mitliefern, `maintenance` beim Update melden.
5. **Signatur scharf** (§5), sobald der Serverschlüssel steht.
Schritt 3 ist die Voraussetzung für alles Weitere: Solange kein Release veröffentlicht
ist, lässt sich weder Update noch Erstinstallation erproben — und die `setup.json` wirkt
erst, wenn sie in einem Paket steckt.
---
## 7. Befunde vom 2026-08-13 — alle behoben
Zur Nachvollziehbarkeit, weil einige unsere Umsetzung geformt haben:
| Befund | Behoben durch |
|---|---|
| `.htpasswd` enthielt alle Lizenzschlüssel im Klartext (Benutzernamenspalte wird nicht gehasht) | `ReleaseGuard::licenseUsername()` leitet `lic_<sha256[0..16]>` ab; `ReleaseCredentials.UsernameForLicenseKey` bildet dieselbe Ableitung nach. Die Datei enthält jetzt nur noch bcrypt über einen hochentropen Schlüssel |
| Doku beschrieb Nginx, der Schutz greift nur unter Apache; WebUI meldete „GESCHÜTZT" allein anhand vorhandener Dateien | Echter HTTP-Selbsttest (`ReleaseGuard::selfTest`, erwartet 401), Warnhinweis und eigener Nginx-Abschnitt in der Doku |
| UPDATESERVICE §7 dokumentierte `{"status":"ok"}`, der Code liefert `"success"` | Doku berichtigt |
| `setup.json` schrieb nur ins Installationsverzeichnis | `location`-Angabe am Ziel plus Variablenersetzung in `file` |
### Offen aus dem ersten Release (2026-08-13)
| Befund | Wirkung |
|---|---|
| **Veröffentlichen löst `ReleaseGuard` nicht aus.** `regenerateForProject` läuft nur bei Lizenzänderungen, Projektlöschung, Kontoänderungen und im Sechs-Stunden-Turnus von `cli/tick.php`. Ein Produktverzeichnis entsteht aber erst beim ersten Upload | `/releases/clawddotnet/` war nach dem Upload **ohne `.htaccess`** — das frische Paket bis zum nächsten Turnuslauf für jeden ladbar. Der Turnus hat es inzwischen geschlossen (401 bestätigt). Ein Aufruf am Ende von `/api/updateservice/v1/publish` würde das Fenster ganz vermeiden; das Verzeichnis existiert dort bereits |
| **Die Prüfvorschrift aus UPGRADE §16.4 meldet falsch grün.** `curl -I …/.htpasswd → 403` trifft auch dann zu, wenn die Datei gar nicht existiert: Apache sperrt `.ht*` global | Wir hatten 403 auf `.htpasswd` **und** 200 auf `package.tar.gz`. Aussagekräftig ist nur der Paket-Abruf ohne Zugangsdaten |
| **Kein Signierschlüssel auf dem Server.** `security.release_private_key` ist nicht gesetzt (UPGRADE §15.2) | Releases sind unsigniert, der Agent kann die Herkunft nicht prüfen. `--require-signature` ist damit unbenutzbar |
Die Ableitung des Benutzernamens muss auf beiden Seiten zeichengenau übereinstimmen —
`lic_` plus die ersten 16 Hexzeichen des SHA-256 über den getrimmten Schlüssel. Wer eine
Seite ändert, sperrt die gesamte Installationsbasis aus.
+8 -1
View File
@@ -221,9 +221,16 @@ Offen:
|---|---|---|
| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt |
| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar |
| DC3 | Release-Strecke: `pack-and-deploy` mit `<Version>` aufrufen, dann `update-agent` | ohne Release hat die Update-Prüfung nichts zu finden. Versionsnummer selbst ist erledigt (`Directory.Build.props``ReleaseInfo`) |
| DC3 | Release-Strecke | **erledigt für win-x64/dev** ([`deploy/publish.py`](../deploy/publish.py), 0.1.1 veröffentlicht). Offen: `linux-x64` nach der Avalonia-Portierung, `prod` |
| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI |
| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** |
| DC6 | Update anwenden statt nur melden | **erledigt.** Agent liegt im Paket (Prüfsumme geprüft), Rückfrage in der Oberfläche, geordnetes Herunterfahren vor dem Agentenstart, `maintenance` an den Watchdog |
| DC7 | Erstinstallation über `--action install` + `setup.json` | **erledigt.** Setup-Kette live durchgespielt (Login, Katalog, Token mit Rechteschranke), Round-Trip SDK-Writer → SettingsManager trägt. `fileWindows`/`fileLinux` gesetzt |
| DC8 | Signaturpflicht (`--require-signature`) | Schlüssel steht, ab 0.1.2 wird signiert, Prüfung beidseitig getestet. **Blockiert:** `LaunchUpdateAgent` reicht den Schalter nicht durch — aus der Anwendung heraus nicht erzwingbar (gemeldet) |
Details: [Deploymentcenter 2.22.4 Integrationsplan](Deploymentcenter-2.4-Integrationsplan.md).
Der Zugangsschutz aus 2.4 (Lizenzschlüssel als Basic-Auth-Zugang zur Release-Ablage) ist
bereits umgesetzt — er muss im **ersten** veröffentlichten Release enthalten sein.
---
+219 -5
View File
@@ -62,6 +62,104 @@ Ebenso vorhanden und wiederverwendbar:
Geprüft gegen die REST- und Realtime-API von Rocket.Chat. Alles Folgende ist
Standardfunktion einer selbstgehosteten Instanz, kein Enterprise-Feature.
### 2.0 Prüfung gegen die echte Instanz (Rocket.Chat 8.7, August 2026)
Alles unten Stehende wurde gegen die Testinstanz gemessen, nicht aus der Dokumentation
übernommen. **Drei Annahmen waren falsch** — sie sind hier korrigiert.
**Bestätigt:**
| Prüfung | Ergebnis |
|---|---|
| `subscriptions.get` als Sammelabruf | liefert je Raum `rid`, `t`, `name`, `unread`, `userMentions`, `groupMentions`, `alert` — genau der Vorfilter, auf dem der Poll steht |
| `chat.postMessage`, auch mit `tmid` (Thread) | funktioniert |
| `channels.history` / `groups.history` / `im.history` mit `oldest` | funktioniert; Raumart bestimmt den Endpunkt |
| `subscriptions.read` | funktioniert; danach steht `ls` und `unread` fällt auf 0 |
| `im.create`, `groups.create`, Senden in privaten Gruppen | funktioniert |
| Erwähnungen | kommen als **`mentions[]`-Feld mit Benutzernamen** — der Erwähnungsfilter braucht kein Textparsen |
| Unbekannter Raum | `400 [invalid-channel]` |
| `users.create` mit Rolle `bot` | funktioniert (Admin) |
**Korrekturen:**
1. **Berechtigungsnamen.** Sie heißen `create-personal-access-tokens` (Rollen: `admin`,
`user`) und `user-generate-access-token` (Rolle: `admin`) — nicht wie zuvor notiert.
2. **Ein Admin kann *kein* Token für einen fremden Benutzer prägen.**
`users.generatePersonalAccessToken` lehnt `userId` ab („must NOT have additional
properties") — der Endpunkt gilt nur für den aufrufenden Benutzer.
`users.createToken` verlangt ein `secret`, für das es in dieser Instanz keine
Einstellung gibt. Beide Wege sind zu.
3. **Systemnachrichten.** Die Historie liefert auch Ereignisse wie „Benutzer beigetreten"
(Feld `t`, z. B. `uj`). Ohne Filter antwortet ein Agent auf einen Raumbeitritt. Im
Tool umgesetzt und geprüft.
**Offen geblieben:** Das Ratenlimit (`API_Enable_Rate_Limiter` = an, 10 Aufrufe/60 s)
griff bei 14 schnellen Aufrufen **nicht** — Administratoren umgehen es. Für einen
Benutzer mit reiner `bot`-Rolle ist es damit **nicht** gemessen. Der Entwurf bleibt mit
einem Sammelabruf je Takt weit darunter; nachzumessen, sobald ein Agenten-Benutzer
nutzbar ist.
### 2.0.1 Der Stolperstein: 2FA verhindert die automatische Bereitstellung
Ein frisch per API angelegter Benutzer **kann sich nicht anmelden**: Rocket.Chat antwortet
mit `totp-required` und schickt einen Code per E-Mail. Ursache ist
`Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In` (in der Testinstanz aktiv) — jeder
neue Benutzer bekommt E-Mail-2FA automatisch.
Geprüft und ausgeschlossen: `users.update` kennt kein Feld dafür („must NOT have
additional properties"), `users.resetTOTP` betrifft nur App-basiertes TOTP.
**Es gibt keinen Weg über die Admin-API, das E-Mail-2FA eines einzelnen Benutzers
abzuschalten.**
Damit stehen drei Wege offen — die Entscheidung gehört dir, weil sie eine
Sicherheitseinstellung berührt:
| Weg | Ablauf | Preis |
|---|---|---|
| **A (empfohlen)** | `Auto_Opt_In` global auf **aus**, dann Benutzer anlegen (Rollen `bot` + `user`), als dieser anmelden, PAT erzeugen, Passwort verwerfen | Neue **menschliche** Benutzer bekommen E-Mail-2FA dann nicht mehr automatisch. Bestehende Konten und TOTP bleiben unberührt |
| **B** | Setting bleibt; jeder Agent braucht ein echtes Postfach, ClawdDotNet holt den 2FA-Code per IMAP (das Mail-Tool kann das) | Funktioniert, ist aber ein zerbrechlicher Umweg |
| **C** | Halbautomatisch: API legt den Benutzer an, ein Mensch erzeugt das PAT einmalig in der Oberfläche | Kein „HR-Agent" möglich — bei jedem neuen Agenten Handarbeit |
**Weg A ist umgesetzt und gemessen** (August 2026): Nach dem Abschalten von `Auto_Opt_In`
läuft die Kette vollständig durch —
```
users.create (Rollen bot + user) → login als dieser Benutzer →
users.generatePersonalAccessToken → PAT
```
Damit ist ein „HR-Agent", der einen neuen Agenten samt Chat-Konto einrichtet, technisch
möglich. Die Rolle `user` ist dabei nötig, **nicht** nur `bot`: Nur sie bringt die
Berechtigung `create-personal-access-tokens` mit.
Ein Benutzer, der **vor** der Umstellung angelegt wurde, behält sein 2FA-Flag dauerhaft —
er lässt sich nicht nachträglich retten und muss neu angelegt werden.
Ob sich `Auto_Opt_In` nach der Bereitstellung wieder einschalten lässt, ohne die
bestehenden Agenten zu verlieren, ist plausibel (das Flag wird beim Anlegen gesetzt), aber
weiterhin **nicht gemessen**.
Für das Tool selbst ist die Frage folgenlos: Es nimmt `userId` und `authToken` aus der
Konfiguration entgegen, gleich woher sie stammen.
### 2.0.2 Zwei Fehler, die erst der Live-Test zeigte
Beide wären in keinem Schreibtischtest aufgefallen und sind behoben:
1. **`unread` zählt in dieser Instanz nur Erwähnungen.** Eine gewöhnliche Nachricht setzt
allein `alert=true`. Schwerer wiegt: `userMentions` ist ein Zähler über *ungelesene*
Erwähnungen und bleibt stehen, solange nichts gelesen wurde. Ein einziger alter,
ungelesener Ruf machte den Raum damit dauerhaft „heiß" — und anschließend wurde **jede**
weitere Nachricht ausgeliefert, auch ohne Erwähnung.
*Behoben:* Die Erwähnungsprüfung sitzt jetzt an der einzelnen Nachricht; der
Raum-Zähler ist nur noch ein billiger Vorfilter. Zusätzlich wird jeder geprüfte Raum als
gelesen markiert, auch wenn nichts zu wecken war — sonst veralten die Zähler.
2. **Die Startmarke verschluckte die erste echte Nachricht.** Ein Raum wird erst dann zum
Kandidaten, wenn Verkehr da ist — genau dann setzte der alte Code aber „Marke auf jetzt,
nichts wecken". Die auslösende Nachricht ging verloren.
*Behoben:* Beim ersten Abruf eines Raums wird begrenzt zurückgeschaut
(`initialLookbackMinutes`, Standard 5) statt zu überspringen.
### 2.1 Identität — ein echter Benutzer je Agent
Die Anforderung „jeder Agent mit eigenem Benutzer, in Gruppen und im Direktkontakt" ist
@@ -198,27 +296,143 @@ ist das am Ende nicht optional.
### 2.8 Tool-Zuschnitt
**Umgesetzt** (`src/ClawdDotNet.Tools.RocketChat`, gegen die Testinstanz geprüft):
```
Tool: RocketChat
Aktionen: send_message | reply | send_file | list_rooms | read_room
| mark_read | search
Aktionen: send_message | reply | send_file | list_rooms | read_room | mark_read
Job: rocketchat_poll
```
Noch nicht umgesetzt: `search`.
**Dateiversand.** `send_file` schickt eine Datei aus dem Workspace direkt in einen Raum
oder als Direktnachricht — der kurze Weg für „stell mir das zusammen und schick es rüber",
ohne Umweg über die Cloud. Pfade tragen dieselben Prefixe wie bei FileRW und FTP
(`personal:` / `shared:` / ohne Prefix), samt Prüfung gegen einen Ausbruch aus dem
Verzeichnis. Grenze über `maxUploadMb` (Standard 25, Serverseite erlaubt 100).
Dabei zeigte sich die **dritte Doku-Korrektur**: Der Ein-Schritt-Endpunkt `rooms.upload`
existiert in 8.7 nicht mehr — er antwortet mit einem nackten HTML-404. Aktuell ist ein
zweistufiger Ablauf:
1. `rooms.media/:rid` nimmt die Datei als `multipart/form-data` und liefert eine Datei-Id;
sichtbar ist damit noch **nichts**.
2. `rooms.mediaConfirm/:rid/:fileId` veröffentlicht sie als Nachricht.
Ohne den zweiten Schritt liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
Voraussetzung ist damit Rocket.Chat 6.x oder neuer; einen Rückfall auf `rooms.upload` für
ältere Instanzen gibt es bewusst nicht, weil er sich hier nicht prüfen ließe.
Anders als `send_message` steht `RocketChat.send_file` in der Staging-Policy auf
**`approve`**: Eine Nachricht formuliert der Agent, eine Datei verlässt den Workspace als
Ganzes. Wer das im Alltag als zu hinderlich empfindet, streicht die eine Zeile in
`StagingPolicy.DefaultRules` — dann schützt weiterhin die Raum-Allowlist.
Geprüft gegen die Testinstanz (8 von 8): Markdown aus dem persönlichen Workspace · CSV aus
dem geteilten mit abweichendem Dateinamen · Datei per Direktnachricht · Ausbruchsversuch
`../` abgewiesen · absoluter Pfad abgewiesen · fehlende Datei · fehlender `localPath` ·
nicht freigegebener Raum.
### 2.9 Eingehende Dateien und Links
Was hereinkommt, ist genauso wichtig wie das, was hinausgeht — und stand anfangs nicht im
Entwurf.
**Dateien.** Eine Dateisendung trägt den Begleittext in `msg`, die Datei selbst aber
daneben in `files[]`. Wer nur `msg` liest, sieht bei einer reinen Dateisendung einen
**leeren Beitrag**. Weckmeldung und `read_room` führen Anhänge deshalb eigens auf:
```
[20:06] @richard (messageId: rD68…): schau dir die Zahlen bitte an.
Datei: auswertung.csv (text/csv, 77 B) — fileId: 6a821828ea0ad1bcab878f74
```
Mit dieser `fileId` holt `download_file` die Datei in den Workspace. Der Abruf geht gegen
`/file-upload/{fileId}/download` mit denselben Kopfzeilen wie die API — ohne sie antwortet
der Server mit 403 (`FileUpload_ProtectFiles` ist aktiv).
Schutzmaßnahmen, weil Name und Inhalt vom Absender bestimmt sind:
- Der Zielname wird auf den reinen Dateinamen reduziert; Pfadangaben darin verfallen.
Nur das Verzeichnis (`personal:` / `shared:`) darf der Agent wählen.
- Ausführbare Endungen (`.exe`, `.ps1`, `.bat`, `.jar`, …) werden abgelehnt, sofern nicht
`allowDangerousDownloads` gesetzt ist.
- Größengrenze `maxDownloadMb` (Standard 25) — geprüft an `Content-Length` **und**
während des Schreibens, da die Angabe fehlen darf.
- Geschrieben wird über eine `.part`-Nebendatei; bricht der Abruf ab, bleibt keine halbe
Datei liegen, die der Agent für vollständig hält.
Dabei fiel ein Windows-Fehler auf, der leicht zu übersehen ist: `Path.GetFileName` behält
`"shared:kopie.csv"` unverändert bei, weil `:` dort kein Pfadtrenner ist, sondern ein
**NTFS-Alternativdatenstrom** eingeleitet wird. Die Datei landete als Datenstrom am
Verzeichnis statt als eigene Datei. Der Präfix wird jetzt vor der Namensbereinigung
abgetrennt, und `WorkspaceFile` weist Doppelpunkte grundsätzlich ab.
**Links.** Rocket.Chat entpackt Links selbst und legt in `urls[].meta` Titel und
Beschreibung der Zielseite ab. Der Agent bekommt das mitgeliefert:
```
Link: https://www.rocket.chat/ — Rocket.Chat | Secure CommsOS™ …
```
Das erspart oft einen eigenen Seitenabruf. **Wichtig:** Diese Vorschau ist Text der
verlinkten Seite, also fremdbestimmt — sie steht deshalb wie alles andere innerhalb der
`<untrusted_content>`-Rahmung. Ein tatsächlicher Abruf der Seite bleibt Sache des
WebFetch-Tools und damit eine bewusste Entscheidung des Agenten, keine Nebenwirkung des
Empfangens.
Geprüft (9 von 9): Datei in der Weckmeldung samt `fileId` · Link mit Titel · Download in
den persönlichen und in den geteilten Workspace · Inhalt stimmt · Ausbruch über den
Dateinamen entschärft · gefährliche Endung abgelehnt · unbekannte `fileId` · fehlende
`fileId`.
Der Weckpfad ist gegen die Testinstanz durchgespielt (6 von 6 Erwartungen):
ruhiger Takt weckt nicht · Nachricht ohne Erwähnung weckt nicht · Erwähnung weckt ·
Direktnachricht weckt auch ohne Erwähnung · eigene Nachricht weckt nicht ·
private Gruppe weckt.
Konfiguration je Agent:
```json
"RocketChat": {
"baseUrl": "https://chat.example.org",
"userId": "aBcD…",
"authToken": "…", // von ConfigSecrets geschützt
"allowedRooms": ["GENERAL", "finanz-team"],
"authToken": "…", // von ConfigSecrets geschützt (Schlüssel "authtoken")
"username": "agent-hermes", // für den Erwähnungsfilter
"allowedRooms": ["GENERAL", "finanz-team", "richard"],
"defaultRoom": "finanz-team",
"mentionOnly": true,
"maxWakesPerRoomPerHour": 12
"agentUsernames": ["agent-hermes", "agent-atlas"],
"maxWakesPerRoomPerHour": 12,
"maxMessagesPerRoom": 20,
"initialLookbackMinutes": 5,
"maxUploadMb": 25
}
```
**`allowedRooms` gilt auch für Direktnachrichten.** Ein DM-Raum trägt den Namen des
Gegenübers — wer per DM erreichbar sein soll, steht dort mit seinem **Benutzernamen**
(oben `"richard"`). Ohne Eintrag ignoriert der Agent die Direktnachricht. Eine leere Liste
erlaubt alles; das Tool erfindet keine Allowlist.
Mit `"@benutzername"` als `room` beginnt der Agent auch ein **neues** Gespräch (`im.create`)
— nur bei ausdrücklicher `@`-Schreibweise, damit ein vertippter Kanalname nicht
stillschweigend zur Direktnachricht wird.
Zum Verhalten des Abrufs:
- **Erster Takt je Raum setzt nur die Marke** und weckt nicht — sonst käme beim Einrichten
die gesamte Raumgeschichte auf einmal in den Kontext.
- Die Marke wandert auf die jüngste **gesehene** Nachricht, auch auf gefilterte. Sonst
würde eine ignorierte Agentennachricht bei jedem Takt erneut geprüft.
- **Gelesen-Markierung erst nach dem Einsammeln** — bricht der Takt vorher ab, bleibt der
Zähler stehen und nichts geht verloren.
- Der Schleifenschutz vergleicht gegen `agentUsernames`, nicht gegen ein Server-Flag: Wir
wissen selbst am besten, welche Konten unsere Agenten sind.
- **Kein Eintrag in der Staging-Policy** — `RocketChat.send_message` bleibt bewusst `auto`
(Standard). Der Schutz sitzt an `allowedRooms`, nicht an einer Einzelfreigabe.
---
## 3 — Redundanz: was passiert, wenn Rocket.Chat ausfällt
+11
View File
@@ -234,6 +234,7 @@ public sealed class AppHost : IAsyncDisposable
registry.Register(new AgentEditorTool());
registry.Register(new Tools.Memory.MemoryTool());
registry.Register(new Tools.Taskboard.TaskboardTool());
registry.Register(new Tools.RocketChat.RocketChatTool());
return registry;
}
@@ -468,8 +469,18 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
public static string BuildSummary => ReleaseInfo.Summary;
private bool _disposed;
public async ValueTask DisposeAsync()
{
// Beim Update fahren wir vor dem Beenden selbst herunter; danach ruft die
// Oberflaeche Shutdown, und deren Behandlung raeumt ein zweites Mal auf. Ohne
// diese Sperre gingen alle Schritte doppelt los — unter anderem ein zweiter
// Abmeldevorgang beim Watchdog, der den gerade gesetzten Wartungszustand
// wieder ueberschreibt.
if (_disposed) return;
_disposed = true;
foreach (var step in _shutdown)
{
try { await step(); }
@@ -37,6 +37,7 @@
<ProjectReference Include="..\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.RocketChat\ClawdDotNet.Tools.RocketChat.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
<!-- Deploymentcenter-SDK (Fremdrepo, netstandard2.0;net8.0). Liefert Hardware-ID v2,
den verschluesselten Lizenz-Zwischenspeicher und die Update-Pruefung. Watchdog,
@@ -210,9 +210,30 @@ public sealed class DeploymentcenterService : IAsyncDisposable
try
{
var client = new global::Deploymentcenter.Client.UpdateClient();
// Seit 2.4 liegt die Release-Ablage hinter HTTP-Basic-Auth; der Lizenzschlüssel
// ist der Zugang. Die Anwendung kennt ihn ohnehin, es muss nichts zusätzlich
// verteilt werden. Ohne ihn kommt ab dem Scharfschalten nur noch 401.
//
// platform bleibt offen: Das SDK setzt die Kennung des laufenden Systems ein.
// Sie wegzulassen wäre keine Option — der Server liefert dann ausschließlich
// plattformunabhängige Pakete.
var result = await client.CheckForUpdateAsync(
settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
settings.UpdateChannel, cancellationToken: ct);
settings.UpdateChannel,
credentials: global::Deploymentcenter.Client.ReleaseCredentials
.FromLicenseKey(settings.LicenseKey),
cancellationToken: ct);
// Ein 401 ist kein Netzwerkfehler, sondern eine Aussage über die Lizenz.
// Ohne die Unterscheidung sucht man bei einem abgelaufenen Vertrag am Netz.
if (result.Unauthorized)
{
_logger.LogWarning(
"Update-Prüfung abgewiesen: {Message} der Lizenzschlüssel öffnet die "
+ "Release-Ablage nicht (mehr).", result.Message);
return;
}
if (result.Error is not null)
{
@@ -247,6 +268,78 @@ public sealed class DeploymentcenterService : IAsyncDisposable
}
}
/// <summary>
/// Meldet dem Watchdog, dass das Herunterfahren gleich ein Update ist und kein
/// Ausfall. Muss <b>vor</b> dem Herunterfahren gerufen werden — die Meldung geht
/// beim Beenden des Heartbeats raus.
/// </summary>
public void AnnounceUpdate(string version)
=> _heartbeat?.AnnounceMaintenance($"Update auf {version} wird eingespielt.");
/// <summary>
/// Startet den <c>update-agent</c> und übergibt ihm das Feld.
///
/// <para><b>Beendet die Anwendung bewusst nicht selbst.</b> <c>LaunchUpdateAgent</c>
/// kann das (<c>exitCurrentApp</c>), täte es aber über
/// <see cref="Environment.Exit"/> — und das übergeht jedes Aufräumen: Der Watchdog
/// meldete nichts, Instanzdatenbank und Chatverläufe blieben auf halbem Weg stehen.
/// Der Aufrufer fährt deshalb erst geordnet herunter und beendet danach selbst.
/// Der Agent bekommt unsere Prozesskennung und wartet, bis wir wirklich weg
/// sind.</para>
/// </summary>
/// <returns>Der Grund, wenn es nicht losging; sonst <c>null</c>.</returns>
public string? StartUpdate(AppSettings settings, string currentVersion)
{
var agentPath = global::Deploymentcenter.Client.UpdateClient.ResolveAgentPath();
if (agentPath is null)
{
// Kein fest verdrahteter Name: Unter Linux trägt der Agent keine Endung.
_logger.LogWarning(
"Update angefordert, aber kein update-agent neben der Anwendung gefunden.");
return "Der update-agent wurde nicht gefunden. Er gehört neben die "
+ "Programmdatei; das Installationspaket bringt ihn normalerweise mit.";
}
try
{
var started = global::Deploymentcenter.Client.UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: LicenseInfo.ProductSlug,
channel: settings.UpdateChannel,
action: "update",
version: "latest",
exitCurrentApp: false,
currentVersion: currentVersion,
waitForCurrentProcess: true,
licenseKey: settings.LicenseKey,
// Ohne Signatur nicht installieren. Der SHA256 stammt aus derselben
// Quelle wie das Paket — wer den Webroot oder die FTP-Zugangsdaten
// kontrolliert, tauscht beide gemeinsam aus. Ausgerechnet auf dem Pfad,
// der anschließend fremden Code ausführt.
//
// Tragfähig, weil ab 0.1.2 jedes Release signiert ist. Ein Rücksprung
// auf 0.1.0 oder 0.1.1 bricht damit ab — das ist gewollt: Die beiden
// stammen aus der Zeit vor dem Signierschlüssel.
requireSignature: true);
if (!started)
return "Der update-agent ließ sich nicht starten.";
_logger.LogInformation("update-agent gestartet ({Path}), Anwendung wird beendet.",
agentPath);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "update-agent konnte nicht gestartet werden.");
return $"Der update-agent konnte nicht gestartet werden: {ex.Message}";
}
}
public async ValueTask DisposeAsync()
{
if (_heartbeat is not null)
@@ -36,7 +36,12 @@ public sealed class OpenRouterStatusService : IAsyncDisposable
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
private const double UsdToEur = 0.92;
/// <summary>
/// Grober Umrechnungskurs fuer die Anzeige. Oeffentlich, damit die Tokennutzungs-
/// Seite denselben Wert verwendet — zwei Kopien eines veraltenden Kurses waeren
/// zwei Gelegenheiten, verschiedene Betraege fuer dieselben Laeufe zu zeigen.
/// </summary>
public const double UsdToEur = 0.92;
public bool IsApiReachable { get; private set; }
public string StatusText { get; private set; } = "Prüfe...";
@@ -52,6 +52,46 @@ public sealed class AppSettings
[JsonPropertyName("logRefreshIntervalMs")]
public int LogRefreshIntervalMs { get; set; } = 500;
// ─── Zustand der Oberflaeche ───
//
// Bewusst hier und nicht in der Instanzkonfiguration: Das sind Gewohnheiten des
// Benutzers am Geraet, keine Eigenschaften der Instanz. Wer zwischen zwei Instanzen
// wechselt, will nicht jedes Mal wieder im Hellmodus mit ausgeklappter Leiste landen.
[Category("UI")]
[DisplayName("Farbschema")]
[Description("Light oder Dark. Umschaltbar ueber das Symbol in der Kopfzeile.")]
[JsonPropertyName("theme")]
public string Theme { get; set; } = "Light";
[Category("UI")]
[DisplayName("Seitenleiste eingeklappt")]
[Description("Merkt sich, ob die Navigation auf Symbolbreite zusammengeklappt war.")]
[JsonPropertyName("sidebarCollapsed")]
public bool SidebarCollapsed { get; set; }
// Die drei Teiler der Agenten-Seite, jeweils als Anteil 0..1 der Gesamtflaeche.
// Als Verhaeltnis und nicht in Pixeln: Sonst sitzt der Teiler nach einem Wechsel
// auf einen anderen Bildschirm an einer voellig anderen Stelle.
[Category("UI")]
[DisplayName("Agenten-Seite: Teiler waagerecht")]
[Description("Anteil des oberen Bereichs (Agentenliste und Einstellungen), 0.2 bis 0.8.")]
[JsonPropertyName("agentsSplitVertical")]
public double AgentsSplitVertical { get; set; } = 0.55;
[Category("UI")]
[DisplayName("Agenten-Seite: Teiler oben")]
[Description("Anteil der Agentenliste an der Breite des oberen Bereichs, 0.2 bis 0.8.")]
[JsonPropertyName("agentsSplitTopList")]
public double AgentsSplitTopList { get; set; } = 0.44;
[Category("UI")]
[DisplayName("Agenten-Seite: Teiler unten")]
[Description("Anteil der Werkzeugliste an der Breite des unteren Bereichs, 0.2 bis 0.8.")]
[JsonPropertyName("agentsSplitBottomList")]
public double AgentsSplitBottomList { get; set; } = 0.44;
[Category("API")]
[DisplayName("Status-Check-Intervall (Sek)")]
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
@@ -25,6 +25,10 @@ public sealed class WatchdogHeartbeatService : IAsyncDisposable
private Task? _loop;
private string _lastState = "unknown";
private string _shutdownStatus = WatchdogStatus.Stopped;
private string _shutdownMessage = "Instanz planmäßig beendet.";
private string _shutdownEventKind = WatchdogEventKind.StoppedGraceful;
public WatchdogHeartbeatService(
IWatchdogClient client,
IInstanceHealthProvider health,
@@ -57,6 +61,23 @@ public sealed class WatchdogHeartbeatService : IAsyncDisposable
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
public string LastState => _lastState;
/// <summary>
/// Meldet das nächste Herunterfahren als <b>Wartung</b> statt als planmäßiges Ende.
///
/// <para>Gedacht für das Einspielen eines Updates: Die Instanz ist gleich weg,
/// kommt aber wieder. <c>stopped</c> wäre die falsche Auskunft — es heißt „bewusst
/// beendet" und lässt den Monitor liegen, bis jemand ihn wieder anfasst.
/// <c>maintenance</c> sagt dasselbe über den Alarm aus, trägt aber die Absicht
/// mit: Im Dashboard ist zu sehen, dass hier gerade aktualisiert wird, statt dass
/// eine Instanz ohne Grund verschwindet.</para>
/// </summary>
public void AnnounceMaintenance(string message)
{
_shutdownStatus = WatchdogStatus.Maintenance;
_shutdownMessage = message;
_shutdownEventKind = WatchdogEventKind.MaintenanceStart;
}
public void Start()
{
if (IsRunning)
@@ -176,12 +197,12 @@ public sealed class WatchdogHeartbeatService : IAsyncDisposable
await _client.SendHeartbeatAsync(
new InstanceHealth(
WatchdogStatus.Stopped, "Instanz planmäßig beendet.",
_shutdownStatus, _shutdownMessage,
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
await _client.SendEventAsync(
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token)
_shutdownEventKind, "info", _shutdownMessage, null, stopCts.Token)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -47,6 +47,12 @@ public sealed class StagingPolicy
["Database.upsert"] = StagingDecision.Approve,
["FileRW.delete"] = StagingDecision.Approve,
["FTP.upload"] = StagingDecision.Approve,
["FTP.delete"] = StagingDecision.Approve
["FTP.delete"] = StagingDecision.Approve,
// Rocket.Chat ist der Arbeitsraum, nicht ein Kanal nach draußen: Müsste jede
// Antwort freigegeben werden, wäre er unbenutzbar. Der Schutz sitzt dort an
// der Raum-Allowlist des Tools, deshalb bleibt send_message bewusst `auto`.
// Eine Datei ist etwas anderes — sie verlässt den Workspace als Ganzes.
["RocketChat.send_file"] = StagingDecision.Approve
};
}
+1 -2
View File
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:ClawdDotNet.Desktop"
x:Class="ClawdDotNet.Desktop.App"
RequestedThemeVariant="Default">
RequestedThemeVariant="Light">
<Application.DataTemplates>
<local:ViewLocator />
@@ -10,7 +10,6 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<StyleInclude Source="avares://ClawdDotNet/Styles/Shell.axaml" />
</Application.Styles>
+247 -13
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -5,6 +6,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Desktop.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
@@ -13,8 +15,18 @@ namespace ClawdDotNet.Desktop;
public partial class App : Application
{
/// <summary>
/// Schalter der Befehlszeile, mit dem sich ClawdDotNet auf eine bestimmte Instanz
/// starten laesst. Gesetzt wird er nur von der Anwendung selbst, wenn der
/// Instanzdialog einen Wechsel verlangt — deshalb ist er nirgends dokumentiert.
/// </summary>
private const string InstanceArgument = "--instance";
private AppHost? _host;
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
private string? _preselectedInstancePath;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
@@ -26,6 +38,10 @@ public partial class App : Application
// in genau dieser Lücke aussteigen.
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
_preselectedInstancePath = ReadInstanceArgument(desktop.Args);
ApplySavedThemeEarly();
desktop.ShutdownRequested += async (_, _) =>
{
if (_host is not null) await _host.DisposeAsync();
@@ -40,6 +56,30 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Setzt das gespeicherte Farbschema, bevor das erste Fenster erscheint.
///
/// <para>Das Ansichtsmodell des Hauptfensters tut dasselbe — aber erst, wenn der
/// Aufbau steht. Dazwischen liegt die Instanzauswahl, und die waere sonst bei einem
/// Benutzer im Dunkelmodus hell und der Rest der Anwendung dunkel.</para>
///
/// <para>Ein zweites Laden der Einstellungen ist unbedenklich: Load liest nur und
/// schreibt allenfalls beim allerersten Start die Vorgaben fest.</para>
/// </summary>
private static void ApplySavedThemeEarly()
{
try
{
var settings = new SettingsManager();
settings.Load();
ThemeManager.Apply(ThemeManager.IsDarkName(settings.AppSettings.Theme));
}
catch (Exception)
{
// Ohne lesbare Einstellungen bleibt es beim hellen Schema.
}
}
private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
var result = await AppHost.StartAsync(new AppHost.Callbacks
@@ -70,15 +110,18 @@ public partial class App : Application
HookErrorReporting(_host);
HookLicenseWatch(_host, desktop);
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(_host)
};
var mainViewModel = new MainWindowViewModel(_host);
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
// die Lebensdauer der Anwendung, und beides kennt nur diese Klasse.
mainViewModel.RequestInstanceManagerDialog += () => _ = SwitchInstanceAsync(desktop);
desktop.MainWindow = new MainWindow { DataContext = mainViewModel };
desktop.MainWindow.Show();
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
await ShowUpdateNoticeAsync(_host);
await OfferUpdateAsync(_host, desktop);
}
/// <summary>
@@ -132,11 +175,13 @@ public partial class App : Application
}
/// <summary>
/// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: Wann aktualisiert
/// wird, entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst
/// beendet, um sich zu erneuern, ist genau dann im Weg, wenn man sie braucht.
/// Fragt, ob ein verfügbares Update jetzt eingespielt werden soll.
///
/// <para>Bewusst eine Frage und kein Selbstlauf: Wann aktualisiert wird, entscheidet
/// der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
/// erneuern, ist genau dann im Weg, wenn man sie braucht.</para>
/// </summary>
private static async Task ShowUpdateNoticeAsync(AppHost host)
private static async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
{
if (host.Deploymentcenter is null) return;
@@ -146,13 +191,202 @@ public partial class App : Application
if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
await new AvaloniaLicensePrompt().ShowInfoAsync(
var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}";
var install = await ConfirmAsync(
update.IsCritical ? "ClawdDotNet Wichtiges Update" : "ClawdDotNet Update",
$"Version {update.LatestVersion} ist verfügbar."
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
$"Version {update.LatestVersion} ist verfügbar.{notes}\n\n"
+ "Zum Einspielen wird ClawdDotNet beendet und danach neu gestartet.",
confirmText: "Jetzt installieren",
cancelText: "Später");
if (!install) return;
await StartUpdateAsync(host, desktop, update.LatestVersion);
}
private static async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
/// <summary>
/// Die Reihenfolge ist der ganze Punkt dieser Methode.
///
/// <list type="number">
/// <item>Dem Watchdog sagen, dass gleich ein Update kommt — sonst sieht das
/// Verschwinden der Instanz aus wie ein Ausfall.</item>
/// <item>Geordnet herunterfahren: Datenbank, Scanner, Telegram, Abmeldung.</item>
/// <item>Erst dann den Agenten starten. Er bekommt unsere Prozesskennung und wartet,
/// bis wir weg sind, statt über gesperrte Dateien zu kopieren.</item>
/// <item>Selbst beenden.</item>
/// </list>
///
/// <para>Die Verlockung wäre, <c>LaunchUpdateAgent</c> das Beenden zu überlassen —
/// das tut es aber über <c>Environment.Exit</c> und übergeht damit Schritt 2
/// vollständig.</para>
/// </summary>
private static async Task StartUpdateAsync(
AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version)
{
host.Deploymentcenter!.AnnounceUpdate(version);
var settings = host.Settings.AppSettings;
await host.DisposeAsync();
var problem = host.Deploymentcenter.StartUpdate(settings, AppHost.AppVersion);
if (problem is not null)
{
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet Update", problem);
// Der Aufbau ist bereits abgebaut — weiterarbeiten wäre nicht ehrlich.
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(1));
return;
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
}
/// <summary>Ja/Nein-Rückfrage. Avalonia bringt kein MessageBox mit.</summary>
private static async Task<bool> ConfirmAsync(
string title, string message, string confirmText, string cancelText)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<bool>();
var confirm = new Button { Content = confirmText, IsDefault = true };
var cancel = new Button { Content = cancelText, IsCancel = true };
var window = new Window
{
Title = title,
Width = 480,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm }
}
}
}
};
var answer = false;
confirm.Click += (_, _) => { answer = true; window.Close(); };
cancel.Click += (_, _) => window.Close();
// Schließt der Benutzer das Fenster, gilt das als „Später".
window.Closed += (_, _) => completion.TrySetResult(answer);
window.Show();
return await completion.Task;
});
/// <summary>
/// Instanzwechsel aus der laufenden Anwendung.
///
/// <para>Ein Wechsel im laufenden Prozess ist nicht vorgesehen und waere auch keine
/// kleine Sache: Engine, Scanner, Telegram-Anbindung und Datenbank haengen alle an
/// <em>einer</em> Instanz. Statt das umzubauen startet sich die Anwendung mit
/// <c>--instance</c> neu — derselbe Weg, den auch das Update geht.</para>
///
/// <para>Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess
/// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank.</para>
/// </summary>
private async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
if (_host is null) return;
var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath);
var window = new InstancePickerWindow { DataContext = viewModel };
// Schliesst der Benutzer das Fenster, gilt das als Abbruch.
window.Closed += (_, _) => viewModel.CancelCommand.Execute(null);
// Umgekehrt: Sobald eine Wahl feststeht, schliesst sich das Fenster von selbst.
_ = viewModel.Result.ContinueWith(
_ => Dispatcher.UIThread.Post(window.Close), TaskScheduler.Default);
if (desktop.MainWindow is { } owner) await window.ShowDialog(owner);
else window.Show();
var path = await viewModel.Result;
if (path is null) return;
if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return;
var executable = Environment.ProcessPath;
if (executable is null)
{
await new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet Instanzwechsel",
"Der eigene Programmpfad ist nicht ermittelbar; ein Neustart ist von hier "
+ "aus nicht möglich. Bitte ClawdDotNet von Hand neu starten.");
return;
}
await _host.DisposeAsync();
try
{
Process.Start(new ProcessStartInfo(executable)
{
ArgumentList = { InstanceArgument, path },
UseShellExecute = false
});
}
catch (Exception ex)
{
await new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet Instanzwechsel",
$"Neustart fehlgeschlagen: {ex.Message}");
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
}
private static string? ReadInstanceArgument(IReadOnlyList<string>? args)
{
if (args is null) return null;
for (var i = 0; i < args.Count - 1; i++)
{
if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
return null;
}
/// <summary>
/// Instanzauswahl beim Start.
///
/// <para>Kam die Anwendung mit <c>--instance</c> hoch, wird nicht gefragt: Dann hat
/// der Benutzer die Instanz im vorigen Prozess bereits gewaehlt, und ein zweiter
/// Dialog waere nur die Frage nach der schon gegebenen Antwort. Der Wert wird einmal
/// verbraucht — bleibt der Pfad ungueltig, erscheint doch der Dialog, statt dass der
/// Start wortlos scheitert.</para>
/// </summary>
private async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
{
var preselected = _preselectedInstancePath;
_preselectedInstancePath = null;
if (preselected is not null && Directory.Exists(preselected))
return preselected;
return await ShowInstancePickerAsync(directories);
}
private static async Task<string?> ShowInstancePickerAsync(InstanceDirectoryManager directories)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var viewModel = new InstancePickerViewModel(directories);
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -22,17 +22,47 @@
<PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<!-- Assets enthaelt die App-Symbole und Assets\Fonts die beiden mitgelieferten
Schriften (Barlow, Barlow Condensed; SIL Open Font License, siehe OFL.txt).
Sie werden ueber avares:// referenziert, siehe Styles/Theme.axaml. -->
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<!-- Muss neben der Programmdatei liegen, damit sie im Paket neben der manifest.json
landet: Von dort liest der update-agent sie bei 'install' und 'configure'. -->
<ItemGroup>
<Content Include="setup.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
<!-- Native Debug-Symbole aus NuGet-Paketen nicht mitkopieren. libSkiaSharp.pdb ist
allein 80 MB, libHarfBuzzSharp.pdb weitere 20 - sie blaehten die Publish-Ausgabe
auf das Dreifache auf, obwohl sie im Paket ohnehin ausgeschlossen sind. Unsere
eigenen Symbole sind eingebettet (siehe Directory.Build.props) und daher nicht
betroffen. -->
<Target Name="RemoveNativeSymbols" AfterTargets="ResolveReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)"
Condition="'%(Extension)' == '.pdb'" />
</ItemGroup>
</Target>
<!-- Dieselbe Bereinigung noch einmal fuer die Publish-Ausgabe: Die nativen Symbole
kommen als Laufzeit-Bestandteile ueber ResolvedFileToPublish und werden von der
Kopierliste oben gar nicht erfasst. Unbedenklich fuer unsere eigenen Assemblies -
deren Symbole stecken eingebettet in der DLL. -->
<Target Name="RemoveNativeSymbolsFromPublish" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
Condition="'%(Extension)' == '.pdb'" />
</ItemGroup>
</Target>
</Project>
@@ -0,0 +1,24 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace ClawdDotNet.Desktop.Controls;
/// <summary>
/// Der Rahmen mit den vier Eckmarken — das wiederkehrende Motiv des Entwurfs.
///
/// <para>Eigenes Steuerelement statt vier von Hand gesetzter Striche je Stelle: Die Marken
/// liegen bewusst <em>halb außerhalb</em> des Rahmens, und das ist genau die Sorte Detail,
/// die beim Nachbauen an der fünften Stelle nicht mehr stimmt.</para>
///
/// <para>Weil die Marken über den Rand hinausragen, darf kein Elternteil zuschneiden.
/// In der Praxis heißt das: nicht direkt an den Fensterrand setzen, sondern mit etwas
/// Abstand — die Vorlage im Entwurf tut dasselbe.</para>
///
/// <para>Sparsam verwenden. Im Entwurf tragen nur die Info-Karte und die beiden Dialoge
/// die Marken; alle übrigen Karten sind schlichte Haarlinien-Kästen (Klasse
/// <c>card</c> in <c>Styles/Shell.axaml</c>).</para>
/// </summary>
public sealed class BlueprintFrame : ContentControl
{
protected override Type StyleKeyOverride => typeof(BlueprintFrame);
}
@@ -0,0 +1,113 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Controls;
/// <summary>
/// Ein Strichsymbol aus dem Entwurf — 24×24-Geometrie, gezeichnet mit der geerbten
/// Vordergrundfarbe.
///
/// <para>Warum nicht <c>PathIcon</c>: Das füllt seine Geometrie. Die Symbole des Entwurfs
/// sind Umrisse (Lucide, Strichstärke 1.5); gefüllt werden daraus schwarze Klumpen.</para>
///
/// <para>Warum nicht <c>Path</c> mit <c>Stroke</c>: Dann müsste an jeder einzelnen Stelle
/// die Farbe mitgegeben werden — und zwar die <em>richtige</em>, je nachdem ob das Symbol
/// in einer Schaltfläche, einer ausgewählten Navigationszeile oder der Statusleiste sitzt.
/// <see cref="Foreground"/> ist hier von <see cref="TextElement"/> übernommen und erbt
/// daher wie eine Textfarbe: Wer den Text einfärbt, färbt das Symbol mit.</para>
///
/// <para>Die Strichstärke wird beim Skalieren mitskaliert. Das ist keine Nachlässigkeit,
/// sondern das Verhalten von SVG: <c>stroke-width</c> zählt in Einheiten des viewBox.
/// Ein Symbol mit 1.5 in 24×24, dargestellt auf 16 px, hat genau 1 px starke Linien.</para>
/// </summary>
public sealed class StrokeIcon : Control
{
/// <summary>Kantenlänge des Koordinatenraums aller Symbole (SVG-viewBox 0 0 24 24).</summary>
private const double ViewBox = 24d;
public static readonly StyledProperty<Geometry?> DataProperty =
AvaloniaProperty.Register<StrokeIcon, Geometry?>(nameof(Data));
/// <summary>Geerbt wie eine Textfarbe — siehe Klassenbeschreibung.</summary>
public static readonly StyledProperty<IBrush?> ForegroundProperty =
TextElement.ForegroundProperty.AddOwner<StrokeIcon>();
public static readonly StyledProperty<double> StrokeThicknessProperty =
AvaloniaProperty.Register<StrokeIcon, double>(nameof(StrokeThickness), 1.5d);
/// <summary>Für die wenigen massiven Symbole des Entwurfs (Wiedergabe, Halt).</summary>
public static readonly StyledProperty<bool> IsFilledProperty =
AvaloniaProperty.Register<StrokeIcon, bool>(nameof(IsFilled));
static StrokeIcon()
{
AffectsRender<StrokeIcon>(
DataProperty, ForegroundProperty, StrokeThicknessProperty, IsFilledProperty);
AffectsMeasure<StrokeIcon>(DataProperty);
}
public Geometry? Data
{
get => GetValue(DataProperty);
set => SetValue(DataProperty, value);
}
public IBrush? Foreground
{
get => GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public double StrokeThickness
{
get => GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public bool IsFilled
{
get => GetValue(IsFilledProperty);
set => SetValue(IsFilledProperty, value);
}
/// <summary>
/// Ohne gesetzte Größe 16×16 — die Größe, in der die meisten Symbole im Entwurf stehen.
/// Ein unbegrenzter Vorschlag würde das Symbol den ganzen Platz nehmen lassen.
/// </summary>
protected override Size MeasureOverride(Size availableSize)
{
var width = double.IsInfinity(availableSize.Width) ? 16d : Math.Min(16d, availableSize.Width);
var height = double.IsInfinity(availableSize.Height) ? 16d : Math.Min(16d, availableSize.Height);
return new Size(width, height);
}
public override void Render(DrawingContext context)
{
if (Data is not { } geometry || Foreground is not { } brush) return;
var scale = Math.Min(Bounds.Width / ViewBox, Bounds.Height / ViewBox);
if (scale <= 0) return;
// Mittig setzen, falls das Steuerelement nicht quadratisch ist.
var offsetX = (Bounds.Width - ViewBox * scale) / 2;
var offsetY = (Bounds.Height - ViewBox * scale) / 2;
using var _ = context.PushTransform(
Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(offsetX, offsetY));
if (IsFilled)
{
context.DrawGeometry(brush, null, geometry);
return;
}
// Runde Enden und Verbindungen: Ohne sie verschwinden die Punkt-Symbole
// (Pfadstücke der Länge 0.01, etwa in „Agenten-Chats") vollständig.
var pen = new Pen(brush, StrokeThickness,
lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round);
context.DrawGeometry(null, pen, geometry);
}
}
@@ -1,65 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Layout;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten.
/// </summary>
public sealed class ChatBubbleBrushConverter : IValueConverter
{
public static readonly ChatBubbleBrushConverter Instance = new();
private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isUser && isUser)
return UserBubble;
return null;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten.
/// </summary>
public sealed class ChatAlignmentConverter : IValueConverter
{
public static readonly ChatAlignmentConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isUser && isUser)
return HorizontalAlignment.Right;
return HorizontalAlignment.Left;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt.
/// </summary>
public sealed class ToolMatchConverter : IValueConverter
{
public static readonly ToolMatchConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string toolName && parameter is string targetTool)
return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
return false;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -0,0 +1,54 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Schlägt eine Symbol-Geometrie aus <c>Styles/Icons.axaml</c> über ihren Namen nach.
///
/// <para>Damit halten die Ansichtsmodelle nur den Namen („IconChat") statt einer
/// <see cref="Geometry"/> — sie brauchen dann keinen Verweis auf <c>Avalonia.Media</c>,
/// und die Navigationsleiste kommt mit einer schlichten Zeichenkette je Eintrag aus.</para>
///
/// <para>Dass hier keine Themenumschaltung nötig ist, ist kein Zufall: Die Geometrien sind
/// themenunabhängig, gefärbt wird erst beim Zeichnen über die geerbte Vordergrundfarbe
/// (siehe <c>Controls/StrokeIcon.cs</c>). Für Farben wäre dieser Weg falsch — ein einmal
/// aufgelöster Pinsel bliebe beim Wechsel auf Dunkel stehen.</para>
/// </summary>
public sealed class IconConverter : IValueConverter
{
public static readonly IconConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string key || string.IsNullOrEmpty(key)) return null;
if (Application.Current is not { } app) return null;
return app.TryFindResource(key, out var resource) ? resource as Geometry : null;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Der Pfeil am Einklapp-Schalter der Seitenleiste: zeigt nach links, solange
/// eingeklappt werden kann, und nach rechts, wenn wieder aufgeklappt wird.
/// </summary>
public sealed class SidebarChevronConverter : IValueConverter
{
public static readonly SidebarChevronConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var isCollapsed = value is true;
return IconConverter.Instance.Convert(
isCollapsed ? "IconChevronRight" : "IconChevronLeft", targetType, parameter, culture);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -0,0 +1,40 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Layout;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Richtet Sprechblasen aus: rechts fuer den Benutzer, links fuer den Agenten.
///
/// <para>Ausrichtung und nicht Farbe — deshalb ist ein Konverter hier richtig. Der
/// frueher danebenstehende ChatBubbleBrushConverter lieferte einen Pinsel mit festem
/// Farbwert und ueberlebte den Themenwechsel nicht; die Hinterlegung eigener Nachrichten
/// laeuft jetzt ueber die Stilklasse <c>card accent</c>.</para>
/// </summary>
public sealed class ChatAlignmentConverter : IValueConverter
{
public static readonly ChatAlignmentConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? HorizontalAlignment.Right : HorizontalAlignment.Left;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Prueft, ob ein Werkzeugname mit dem <c>ConverterParameter</c> uebereinstimmt.
/// Blendet im Werkzeug-Einstellungsbereich die passende Feldgruppe ein.
/// </summary>
public sealed class ToolMatchConverter : IValueConverter
{
public static readonly ToolMatchConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string toolName && parameter is string targetTool
&& toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -1,33 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Färbt eine Logzeile nach ihrer Stufe.
///
/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht
/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die
/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel.
/// </summary>
public sealed class LogLevelBrushConverter : IValueConverter
{
public static readonly LogLevelBrushConverter Instance = new();
private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B));
private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value as string switch
{
"ERR" or "FTL" => Error,
"WRN" => Warn,
// null heißt: Bindung greift nicht, das Steuerelement behält seine eigene
// Farbe aus dem Thema.
_ => null
};
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -0,0 +1,27 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Vergleicht eine Zeichenkette mit dem <c>ConverterParameter</c>, ohne Gross- und
/// Kleinschreibung zu unterscheiden.
///
/// <para>Gedacht fuer <c>Classes.xyz="{Binding …}"</c>: Die Ansicht setzt damit eine
/// Stilklasse, und die Farbe dazu steht in <c>Styles/Shell.axaml</c> an
/// <c>DynamicResource</c>. Das ersetzt den frueheren LogLevelBrushConverter, der
/// Pinsel mit festen Farbwerten zurueckgab — die blieben beim Wechsel auf Dunkel
/// unveraendert stehen, weil ein einmal gelieferter Pinsel nichts mehr vom Thema
/// mitbekommt.</para>
/// </summary>
public sealed class StringEqualsConverter : IValueConverter
{
public static readonly StringEqualsConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string text && parameter is string expected
&& text.Equals(expected, StringComparison.OrdinalIgnoreCase);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
+5 -4
View File
@@ -25,9 +25,10 @@ internal static class Program
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
// Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen:
// Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die
// Oberfläche leer.
.WithInterFont()
// Kein WithInterFont mehr: Die Oberfläche bringt seit dem Umbau auf den
// Entwurf Barlow und Barlow Condensed als eigene Ressourcen mit
// (Assets/Fonts, referenziert in Styles/Theme.axaml). Der Grund von damals
// gilt unverändert — ein schlankes Linux-Abbild hat oft gar keine
// Systemschrift —, nur liegt die mitgelieferte Schrift jetzt woanders.
.LogToTrace();
}
@@ -0,0 +1,35 @@
using Avalonia;
using Avalonia.Styling;
namespace ClawdDotNet.Desktop.Services;
/// <summary>
/// Hell/Dunkel umschalten — an genau einer Stelle.
///
/// <para>Die Farbwerte selbst stehen in <c>Styles/Theme.axaml</c> als
/// <c>ThemeDictionaries</c>. Hier wird nur umgelegt, welches der beiden Wörterbücher gilt;
/// jede Bindung mit <c>DynamicResource</c> zieht dann von allein nach. Deshalb gibt es
/// hier auch keine Liste von Steuerelementen, die nachgefärbt werden müssten — die wäre
/// beim nächsten neuen Bereich schon wieder unvollständig.</para>
/// </summary>
public static class ThemeManager
{
/// <summary>Name in den Einstellungen, wenn dunkel gewählt ist.</summary>
public const string DarkName = "Dark";
/// <summary>Übersetzt den gespeicherten Namen; alles außer „Dark" gilt als hell.</summary>
public static bool IsDarkName(string? name)
=> string.Equals(name, DarkName, StringComparison.OrdinalIgnoreCase);
public static string ToName(bool isDark) => isDark ? DarkName : "Light";
/// <summary>
/// Legt die Variante an der Anwendung um. Ohne laufende Anwendung — etwa im
/// Entwurfsmodus des Editors — passiert nichts.
/// </summary>
public static void Apply(bool isDark)
{
if (Application.Current is not { } app) return;
app.RequestedThemeVariant = isDark ? ThemeVariant.Dark : ThemeVariant.Light;
}
}
@@ -0,0 +1,51 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Die Symbole des Entwurfs, 1:1 aus dem Mockup übernommen (Lucide-Umrisse, viewBox
0 0 24 24, Strichstärke 1.5). Gezeichnet werden sie von Controls/StrokeIcon.cs, das
denselben Koordinatenraum voraussetzt — wer hier ein Symbol ergänzt, muss es also
ebenfalls auf 24×24 bringen.
Kreise stehen als zwei Halbbögen da (a-Befehle): StreamGeometry kennt keine
<Ellipse>, und ein Rechteck mit vollem Radius wäre nicht dasselbe.
-->
<!-- ─── Marke ─── -->
<StreamGeometry x:Key="IconLogo">M12 9c-1.5-2.5-4-3-5.5-1.5S5 11 7 12c-1 1-1.5 2-1.5 3.5S7 18 8.5 17c1-1 1.5-2 1.5-3 M12 9c1.5-2.5 4-3 5.5-1.5S19 11 17 12c1 1 1.5 2 1.5 3.5S15 18 13.5 17c-1-1-1.5-2-1.5-3 M12 9v3</StreamGeometry>
<!-- ─── Navigation ─── -->
<StreamGeometry x:Key="IconChat">M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z</StreamGeometry>
<StreamGeometry x:Key="IconAgents">M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75</StreamGeometry>
<StreamGeometry x:Key="IconTasks">M3 17l2 2 4-4 M3 7l2 2 4-4 M13 6h8 M13 12h8 M13 18h8</StreamGeometry>
<StreamGeometry x:Key="IconChart">M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7</StreamGeometry>
<StreamGeometry x:Key="IconAgentChats">M8 12h.01 M12 12h.01 M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z</StreamGeometry>
<StreamGeometry x:Key="IconBackup">M3 4h18a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9 M10 13h4</StreamGeometry>
<StreamGeometry x:Key="IconLogs">M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M9 13h6 M9 17h6</StreamGeometry>
<StreamGeometry x:Key="IconSettings">M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0 M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9c.13-.32.19-.66.18-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.5 1.24.66 1.82.33H9c.32-.13.66-.19 1-.18A1.65 1.65 0 0 0 11.51 3H12a2 2 0 0 1 4 0v.09c0 .34.06.68.18 1a1.65 1.65 0 0 0 1.82.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06c-.51.5-.67 1.24-.33 1.82V9c.13.32.19.66.18 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z</StreamGeometry>
<StreamGeometry x:Key="IconInfo">M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0 M12 16v-4 M12 8h.01</StreamGeometry>
<!-- ─── Kopfzeile ─── -->
<StreamGeometry x:Key="IconSun">M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M12 2v2 M12 20v2 M4.9 4.9l1.4 1.4 M17.7 17.7l1.4 1.4 M2 12h2 M20 12h2 M4.9 19.1l1.4-1.4 M17.7 6.3l1.4-1.4</StreamGeometry>
<StreamGeometry x:Key="IconMoon">M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z</StreamGeometry>
<StreamGeometry x:Key="IconChevronLeft">M15 18l-6-6 6-6</StreamGeometry>
<StreamGeometry x:Key="IconChevronRight">M9 18l6-6-6-6</StreamGeometry>
<StreamGeometry x:Key="IconChevronDown">M6 9l6 6 6-6</StreamGeometry>
<!-- ─── Meldungen ─── -->
<StreamGeometry x:Key="IconAlert">M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z</StreamGeometry>
<StreamGeometry x:Key="IconClose">M18 6L6 18 M6 6l12 12</StreamGeometry>
<!-- ─── Werkzeugleisten ─── -->
<StreamGeometry x:Key="IconPlus">M12 5v14 M5 12h14</StreamGeometry>
<StreamGeometry x:Key="IconMinus">M5 12h14</StreamGeometry>
<StreamGeometry x:Key="IconPlay">M8 5v14l11-7z</StreamGeometry>
<StreamGeometry x:Key="IconStop">M6 6h12v12H6z</StreamGeometry>
<StreamGeometry x:Key="IconPencil">M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z</StreamGeometry>
<StreamGeometry x:Key="IconTrash">M3 6h18 M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6</StreamGeometry>
<StreamGeometry x:Key="IconFolder">M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2Z</StreamGeometry>
<StreamGeometry x:Key="IconRefresh">M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M20.49 15a9 9 0 0 1-14.85 3.36L1 14</StreamGeometry>
<StreamGeometry x:Key="IconPower">M18.36 6.64a9 9 0 1 1-12.73 0 M12 2v10</StreamGeometry>
<StreamGeometry x:Key="IconSave">M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8</StreamGeometry>
</ResourceDictionary>
+840 -22
View File
@@ -1,44 +1,862 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls">
<!--
Gemeinsame Anmutung der Oberflaeche.
Die gemeinsame Anmutung — Umsetzung des Entwurfs aus Mockup/.
Bewusst wenige, benannte Regeln statt Formatierung an jedem Steuerelement: Die
WinForms-Fassung hatte Groessen, Abstaende und Schriften ueber 130 Stellen in den
Designer-Dateien verteilt: Jede Aenderung war eine Suche.
Warum eigene ControlTheme statt Fluent umzufärben: Avalonia 12 hat die Ressourcen des
Fluent-Themas umgebaut. Die aus 11er-Anleitungen bekannten Schlüssel (ButtonBackground,
TextControlBackground, ControlCornerRadius …) existieren nicht mehr; ein Setter darauf
ist wirkungslos und fällt nicht auf, weil er weder Build noch Test bricht. Für die
prägenden Steuerelemente steht die Vorlage deshalb hier vollständig. Fluent bleibt
darunter als Grundlage für alles Übrige (Bildlaufleisten, Menüs, Popup-Rahmen).
Regel wie gehabt: keine festen Farbwerte. Alles über DynamicResource aus Theme.axaml,
sonst überlebt es den Wechsel zwischen hell und dunkel nicht.
-->
<Style Selector="TextBlock.heading">
<Setter Property="FontSize" Value="16" />
<Styles.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://ClawdDotNet/Styles/Theme.axaml" />
<ResourceInclude Source="avares://ClawdDotNet/Styles/Icons.axaml" />
</ResourceDictionary.MergedDictionaries>
<!-- ════════════════════════════ Schaltflächen ════════════════════════════
Grundform ist die sekundäre Schaltfläche des Entwurfs: durchsichtig, eine
Haarlinie Rahmen, eckig. Die Spielarten kommen als Klasse dazu:
.primary / .accent gefüllt in Akzentfarbe
.toolbar kleiner, Fließtextschrift — für Werkzeugleisten
.ghost ohne Rahmen, Akzentfarbe
.flat ohne Rahmen, Textfarbe (Schließen, Einklappen)
.icon quadratisch, ohne Innenabstand
.accent bleibt neben .primary bestehen: Die noch nicht überarbeiteten Dialoge
verwenden diesen Namen. -->
<ControlTheme x:Key="{x:Type Button}" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
RecognizesAccessKey="True" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
</Style>
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PressedBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.45" />
<Setter Property="Cursor" Value="Arrow" />
</Style>
<Style Selector="^.primary, ^.accent">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource BgBrush}" />
</Style>
<Style Selector="^.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter,
^.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Accent600Brush}" />
</Style>
<Style Selector="^.primary:pressed /template/ ContentPresenter#PART_ContentPresenter,
^.accent:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Accent700Brush}" />
</Style>
<Style Selector="^.toolbar">
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Padding" Value="10,6" />
</Style>
<Style Selector="^.ghost">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style Selector="^.flat">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style Selector="^.icon">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
</Style>
</ControlTheme>
<!-- Umschalt-Schaltfläche: dieselbe Form, plus ausgewählter Zustand.
Trägt im Entwurf die Segmentleiste über den Aufgaben-Listen. -->
<ControlTheme x:Key="{x:Type ToggleButton}" TargetType="ToggleButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Padding" Value="14,7" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
</Style>
<Style Selector="^:checked">
<Setter Property="Foreground" Value="{DynamicResource BgBrush}" />
</Style>
<Style Selector="^:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.45" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Eingabefelder ════════════════════════════ -->
<ControlTheme x:Key="{x:Type TextBox}" TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="CaretBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="SelectionBrush" Value="{DynamicResource AccentSoftBrush}" />
<Setter Property="SelectionForegroundBrush" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="9,5" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Panel>
<Border Name="PART_BorderElement"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
MinHeight="{TemplateBinding MinHeight}" />
<DockPanel Margin="{TemplateBinding Padding}">
<ContentPresenter Content="{TemplateBinding InnerLeftContent}" DockPanel.Dock="Left" />
<ContentPresenter Content="{TemplateBinding InnerRightContent}" DockPanel.Dock="Right" />
<ScrollViewer Name="PART_ScrollViewer"
HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
<Panel>
<!-- Der Platzhalter verschwindet, sobald etwas im Feld steht. Über
IsVisible statt Opacity, damit er keine Maus-Ereignisse abfängt. -->
<TextBlock Name="PART_Watermark"
Text="{TemplateBinding PlaceholderText}"
Foreground="{DynamicResource TextGhostBrush}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}"
IsVisible="{TemplateBinding Text, Converter={x:Static StringConverters.IsNullOrEmpty}}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
<TextPresenter Name="PART_TextPresenter"
Text="{TemplateBinding Text, Mode=TwoWay}"
CaretIndex="{TemplateBinding CaretIndex}"
SelectionStart="{TemplateBinding SelectionStart}"
SelectionEnd="{TemplateBinding SelectionEnd}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}"
PasswordChar="{TemplateBinding PasswordChar}"
RevealPassword="{TemplateBinding RevealPassword}"
SelectionBrush="{TemplateBinding SelectionBrush}"
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
CaretBrush="{TemplateBinding CaretBrush}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Panel>
</ScrollViewer>
</DockPanel>
</Panel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource TextGhostBrush}" />
</Style>
<Style Selector="^:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
<!-- Mehrzeilig: Text oben beginnen lassen, nicht mittig. -->
<Style Selector="^[AcceptsReturn=True]">
<Setter Property="VerticalContentAlignment" Value="Top" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Auswahlfeld ════════════════════════════ -->
<ControlTheme x:Key="{x:Type ComboBox}" TargetType="ComboBox">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="9,5" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="PlaceholderForeground" Value="{DynamicResource TextGhostBrush}" />
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto">
<Border Name="Background" Grid.ColumnSpan="2"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
MinHeight="{TemplateBinding MinHeight}" />
<TextBlock Name="PlaceholderTextBlock" Grid.Column="0"
Text="{TemplateBinding PlaceholderText}"
Foreground="{TemplateBinding PlaceholderForeground}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
IsVisible="{TemplateBinding SelectionBoxItem, Converter={x:Static ObjectConverters.IsNull}}" />
<ContentControl Name="ContentPresenter" Grid.Column="0"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding ItemTemplate}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" />
<ctrl:StrokeIcon Grid.Column="1" Data="{StaticResource IconChevronDown}"
Width="12" Height="12" Margin="0,0,9,0"
VerticalAlignment="Center" IsHitTestVisible="False" />
<Popup Name="PART_Popup"
IsOpen="{TemplateBinding IsDropDownOpen, Mode=TwoWay}"
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
PlacementTarget="Background"
IsLightDismissEnabled="True">
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="1" CornerRadius="0" Padding="0,2">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="320">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#Background">
<Setter Property="BorderBrush" Value="{DynamicResource TextGhostBrush}" />
</Style>
<Style Selector="^:focus /template/ Border#Background">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type ComboBoxItem}" TargetType="ComboBoxItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
</Style>
<Style Selector="^:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
</Style>
</ControlTheme>
<!-- ════════════════════ Kontrollkästchen & Auswahlknopf ════════════════════
Beide teilen sich die Form aus dem Entwurf: 16 px Marke, 1.5 px Rand, im
gewählten Zustand mit Akzent gefüllt. Unterschied ist allein die Rundung —
eckig für Mehrfach-, rund für Einfachauswahl. -->
<ControlTheme x:Key="{x:Type CheckBox}" TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" Background="Transparent">
<Border Name="Mark" Width="16" Height="16" CornerRadius="3"
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1.5"
Background="Transparent" VerticalAlignment="Center">
<ctrl:StrokeIcon Name="Glyph" Width="12" Height="12" StrokeThickness="3"
Foreground="{DynamicResource BgBrush}"
Data="M4 12l5 5L20 6" IsVisible="False" />
</Border>
<ContentPresenter Name="PART_ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
VerticalAlignment="Center" />
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#Mark">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ Border#Mark">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ ctrl|StrokeIcon#Glyph">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:indeterminate /template/ Border#Mark">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type RadioButton}" TargetType="RadioButton">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" Background="Transparent">
<Border Name="Mark" Width="16" Height="16" CornerRadius="8"
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1.5"
Background="Transparent" VerticalAlignment="Center">
<!-- Der innere Punkt entsteht wie im Entwurf durch einen Ring in
Hintergrundfarbe, nicht durch eine zweite Ellipse. -->
<Border Name="Dot" CornerRadius="8" Margin="3"
Background="{DynamicResource BgBrush}" IsVisible="False" />
</Border>
<ContentPresenter Name="PART_ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
VerticalAlignment="Center" />
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#Mark">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ Border#Mark">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ Border#Dot">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Listen ════════════════════════════ -->
<ControlTheme x:Key="{x:Type ListBox}" TargetType="ListBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ScrollViewer HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}"
Margin="{TemplateBinding Padding}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="{x:Type ListBoxItem}" TargetType="ListBoxItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="Padding" Value="7" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource RowHoverBrush}" />
</Style>
<Style Selector="^:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentRowBrush}" />
</Style>
<Style Selector="^:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Rahmen mit Eckmarken ════════════════════════════
Die vier Marken liegen halb außerhalb des Rahmens; sie sitzen deshalb in einem
Grid über dem Rahmen und nicht in ihm. IsHitTestVisible=False, damit sie
Klicks auf den Inhalt nicht abfangen. -->
<ControlTheme x:Key="{x:Type ctrl:BlueprintFrame}" TargetType="ctrl:BlueprintFrame">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<ControlTemplate>
<Grid>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
<Panel Width="11" Height="11" Margin="-6,-6,0,0" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Top">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
<Panel Width="11" Height="11" Margin="0,-6,-6,0" IsHitTestVisible="False"
HorizontalAlignment="Right" VerticalAlignment="Top">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
<Panel Width="11" Height="11" Margin="-6,0,0,-6" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Bottom">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
<Panel Width="11" Height="11" Margin="0,0,-6,-6" IsHitTestVisible="False"
HorizontalAlignment="Right" VerticalAlignment="Bottom">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
</Grid>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
</Styles.Resources>
<!-- ══════════════════════════════════════════════════════════════════════════
Grundlagen: Fenster, Schrift, Trenner
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource BgBrush}" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style Selector="UserControl">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
</Style>
<!-- Überschriften laufen in Barlow Condensed, Fließtext in Barlow — das ist der
auffälligste Zug des Entwurfs und darf nirgends per Hand gesetzt werden. -->
<Style Selector="TextBlock.h1">
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="TextBlock.h2">
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="17" />
</Style>
<!-- Bestandsname aus der ersten Portierung; die noch nicht überarbeiteten Dialoge
binden darauf. -->
<Style Selector="TextBlock.heading">
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="17" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<Style Selector="TextBlock.caption">
<Setter Property="Opacity" Value="0.7" />
<Setter Property="FontSize" Value="12" />
<!-- Die kleine Versalzeile über jedem Abschnitt — im Entwurf durchgehend in
Akzentfarbe, 10 px, weit gesperrt. -->
<Style Selector="TextBlock.kicker">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<!-- Werkzeugleiste ueber einer Ansicht -->
<Style Selector="TextBlock.caption">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
</Style>
<Style Selector="TextBlock.label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}" />
<Setter Property="FontSize" Value="12.5" />
</Style>
<Style Selector="SelectableTextBlock">
<Setter Property="SelectionBrush" Value="{DynamicResource AccentSoftBrush}" />
</Style>
<!-- Log-Stufen. Über Klassen statt über einen Pinsel aus einem Konverter, damit die
Farben beim Wechsel auf Dunkel mitziehen. -->
<Style Selector="SelectableTextBlock.warn">
<Setter Property="Foreground" Value="{DynamicResource WarningBrush}" />
</Style>
<Style Selector="SelectableTextBlock.err, SelectableTextBlock.ftl">
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Flächen
══════════════════════════════════════════════════════════════════════ -->
<!-- Karte: eckig, Haarlinie, durchsichtig — der Entwurf setzt bewusst keine
Füllfarbe, damit die Kästen als Zeichnung und nicht als Kacheln wirken. -->
<Style Selector="Border.card">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="10" />
<Setter Property="Background" Value="Transparent" />
</Style>
<!-- Karte in Akzent hinterlegt — eigene Chat-Nachrichten. -->
<Style Selector="Border.card.accent">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
</Style>
<Style Selector="Border.surface">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
</Style>
<Style Selector="Border.statusbar">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="24,6" />
</Style>
<Style Selector="Border.topbar">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="24,14" />
</Style>
<!-- Konsolenfläche der Log-Ansicht -->
<Style Selector="Border.console">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14" />
</Style>
<!-- Senkrechter Strich zwischen Gruppen einer Werkzeugleiste -->
<Style Selector="Border.sep">
<Setter Property="Width" Value="1" />
<Setter Property="Height" Value="20" />
<Setter Property="Margin" Value="4,0" />
<Setter Property="Background" Value="{DynamicResource DividerBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.hr">
<Setter Property="Height" Value="1" />
<Setter Property="Background" Value="{DynamicResource DividerBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Werkzeugleisten
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="StackPanel.toolbar">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Spacing" Value="6" />
<Setter Property="Margin" Value="8" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style Selector="Border.card">
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="12" />
<Style Selector="WrapPanel.toolbar">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="ItemSpacing" Value="6" />
<Setter Property="LineSpacing" Value="6" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<!-- Statusleiste am unteren Rand -->
<Style Selector="Border.statusbar">
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="8,4" />
<!-- ══════════════════════════════════════════════════════════════════════════
Tabellen
Von Hand statt DataGrid: Der Entwurf zeigt eine Kopfzeile in Versalien und
Zeilen mit Haarlinie darunter — mehr nicht, kein Sortieren, kein Spaltenziehen.
Ein umgefärbtes DataGrid wäre hier deutlich mehr Aufwand als eine ListBox, und
das Ergebnis säße am Ende doch nicht auf dem Raster des Entwurfs.
══════════════════════════════════════════════════════════════════════ -->
<!-- Kopfzelle -->
<Style Selector="TextBlock.th">
<Setter Property="FontSize" Value="11" />
<Setter Property="LetterSpacing" Value="0.9" />
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Kopfzeile: trägt die Linie, die die Spaltentitel vom Inhalt trennt -->
<Style Selector="Border.thead">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="7" />
</Style>
<!-- Zeile: Haarlinie unten, schwächer als die der Kopfzeile -->
<Style Selector="Border.tr">
<Setter Property="BorderBrush" Value="{DynamicResource RowLineBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
<Style Selector="ListBox.table > ListBoxItem">
<Setter Property="Padding" Value="0" />
</Style>
<!-- Zelltext -->
<Style Selector="TextBlock.td">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.td.dim">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Etiketten (Status in Tabellen)
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="Border.tag">
<Setter Property="Padding" Value="9,2" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Background" Value="{DynamicResource Neutral100Brush}" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.tag > TextBlock">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{DynamicResource Neutral800Brush}" />
</Style>
<Style Selector="Border.tag.ok">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
</Style>
<Style Selector="Border.tag.ok > TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Accent700Brush}" />
</Style>
<Style Selector="Border.tag.bad">
<Setter Property="Background" Value="{DynamicResource DangerSoftBrush}" />
</Style>
<Style Selector="Border.tag.bad > TextBlock">
<Setter Property="Foreground" Value="{DynamicResource DangerTextBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Seitenleiste
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="Border.sidebar">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,1,0" />
</Style>
<!-- Gruppentitel „Arbeiten“ / „Analyse“ / „System“ -->
<Style Selector="TextBlock.navgroup">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Foreground" Value="{DynamicResource TextGhostBrush}" />
<Setter Property="Margin" Value="8,0,8,6" />
</Style>
<!-- Navigationszeile: Grundzustand durchsichtig, gewählt mit Akzent hinterlegt.
Als Button und nicht als ListBoxItem, damit Tastaturbedienung und Tooltip im
eingeklappten Zustand ohne Zusatzarbeit funktionieren. -->
<Style Selector="Button.nav">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="13.5" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Padding" Value="10,9" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
</Style>
<Style Selector="Button.nav.selected">
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Eingeklappt: nur das Symbol, mittig -->
<Style Selector="Button.nav.collapsed">
<Setter Property="Padding" Value="9" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<!-- Instanz-Schalter oben in der Seitenleiste — einziger Knopf mit Akzentrahmen -->
<Style Selector="Button.instance">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Padding" Value="10,9" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Teiler zwischen den Bereichen der Agenten-Seite
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="GridSplitter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
<!-- Waagerechter Teiler: eine sichtbare Leiste mit Griffstrich, wie im Entwurf -->
<Style Selector="GridSplitter.horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="Cursor" Value="SizeNorthSouth" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1">
<Rectangle Width="36" Height="2" Fill="{DynamicResource DividerBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter>
</Style>
<!-- Senkrechter Teiler: unsichtbar bis auf den Mauszeiger, wie im Entwurf -->
<Style Selector="GridSplitter.vertical">
<Setter Property="Width" Value="10" />
<Setter Property="Cursor" Value="SizeWestEast" />
</Style>
</Styles>
+157
View File
@@ -0,0 +1,157 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Farbwerte des Entwurfs (Mockup/…/ClawdDotNet-UI-Mockup.dc.html, Konstanten LIGHT/DARK).
Warum ThemeDictionaries und nicht zwei Dateien: Avalonia tauscht den passenden Block
aus, sobald die ThemeVariant wechselt. Jede Ansicht bindet mit DynamicResource auf
einen Namen — der Umschalter in der Kopfzeile ist damit eine einzige Zuweisung und
kein Neuaufbau der Oberflaeche.
Deckkraft-Abstufungen stehen als eigene Buerste da, nicht als Opacity am
Steuerelement: Opacity blasst auch alle Kindelemente aus, eine halbdurchsichtige
Farbe nur sich selbst.
-->
<ResourceDictionary.ThemeDictionaries>
<!-- ══════════════════════════════ HELL ══════════════════════════════ -->
<ResourceDictionary x:Key="Light">
<!-- Grundflaechen -->
<SolidColorBrush x:Key="BgBrush" Color="#f2f2f3" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#e9e9ea" />
<SolidColorBrush x:Key="TextBrush" Color="#1d1f20" />
<SolidColorBrush x:Key="AccentBrush" Color="#5980a6" />
<SolidColorBrush x:Key="Accent2Brush" Color="#728fab" />
<SolidColorBrush x:Key="DividerBrush" Color="#291d1f20" />
<Color x:Key="BgColor">#f2f2f3</Color>
<Color x:Key="SurfaceColor">#e9e9ea</Color>
<Color x:Key="TextColor">#1d1f20</Color>
<Color x:Key="AccentColor">#5980a6</Color>
<!-- Text in Abstufungen: 70 % / 60 % / 55 % / 50 % Deckkraft -->
<SolidColorBrush x:Key="TextMutedBrush" Color="#b31d1f20" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#991d1f20" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#8c1d1f20" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#801d1f20" />
<!-- Zustaende: aus der Textfarbe abgeleitet, damit sie auf jedem Grund sitzen -->
<SolidColorBrush x:Key="HoverBrush" Color="#121d1f20" />
<SolidColorBrush x:Key="PressedBrush" Color="#241d1f20" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#0a1d1f20" />
<SolidColorBrush x:Key="RowLineBrush" Color="#141d1f20" />
<!-- Akzent, hinterlegt -->
<SolidColorBrush x:Key="AccentSoftBrush" Color="#265980a6" />
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#295980a6" />
<SolidColorBrush x:Key="AccentRowBrush" Color="#1f5980a6" />
<!-- Neutrale Rampe -->
<SolidColorBrush x:Key="Neutral100Brush" Color="#f5f5f8" />
<SolidColorBrush x:Key="Neutral200Brush" Color="#e7e7ea" />
<SolidColorBrush x:Key="Neutral300Brush" Color="#d4d4d7" />
<SolidColorBrush x:Key="Neutral400Brush" Color="#b7b7ba" />
<SolidColorBrush x:Key="Neutral500Brush" Color="#98989b" />
<SolidColorBrush x:Key="Neutral600Brush" Color="#7a7a7d" />
<SolidColorBrush x:Key="Neutral700Brush" Color="#5d5d60" />
<SolidColorBrush x:Key="Neutral800Brush" Color="#424244" />
<SolidColorBrush x:Key="Neutral900Brush" Color="#2b2b2d" />
<!-- Akzent-Rampe -->
<SolidColorBrush x:Key="Accent100Brush" Color="#eef6ff" />
<SolidColorBrush x:Key="Accent200Brush" Color="#d6ebff" />
<SolidColorBrush x:Key="Accent300Brush" Color="#b5d9fd" />
<SolidColorBrush x:Key="Accent400Brush" Color="#94bce3" />
<SolidColorBrush x:Key="Accent500Brush" Color="#749dc4" />
<SolidColorBrush x:Key="Accent600Brush" Color="#597ea3" />
<SolidColorBrush x:Key="Accent700Brush" Color="#416180" />
<SolidColorBrush x:Key="Accent800Brush" Color="#2c455d" />
<SolidColorBrush x:Key="Accent900Brush" Color="#1d2d3d" />
<!-- Zustandsfarben: Statuspunkt, Meldungsleiste, Tabellen-Etiketten -->
<SolidColorBrush x:Key="SuccessBrush" Color="#3f8f5c" />
<SolidColorBrush x:Key="WarningBrush" Color="#b5822a" />
<SolidColorBrush x:Key="DangerBrush" Color="#c0392b" />
<SolidColorBrush x:Key="DangerSoftBrush" Color="#2eb3453b" />
<SolidColorBrush x:Key="DangerTextBrush" Color="#7a2c25" />
<SolidColorBrush x:Key="WarningSoftBrush" Color="#1ab5822a" />
<SolidColorBrush x:Key="DangerBarBrush" Color="#1ac0392b" />
</ResourceDictionary>
<!-- ══════════════════════════════ DUNKEL ══════════════════════════════ -->
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="BgBrush" Color="#1a1c1e" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#232527" />
<SolidColorBrush x:Key="TextBrush" Color="#eef0f1" />
<SolidColorBrush x:Key="AccentBrush" Color="#7ea3c6" />
<SolidColorBrush x:Key="Accent2Brush" Color="#8fa8c2" />
<SolidColorBrush x:Key="DividerBrush" Color="#29eef0f1" />
<Color x:Key="BgColor">#1a1c1e</Color>
<Color x:Key="SurfaceColor">#232527</Color>
<Color x:Key="TextColor">#eef0f1</Color>
<Color x:Key="AccentColor">#7ea3c6</Color>
<SolidColorBrush x:Key="TextMutedBrush" Color="#b3eef0f1" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#99eef0f1" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#8ceef0f1" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#80eef0f1" />
<SolidColorBrush x:Key="HoverBrush" Color="#12eef0f1" />
<SolidColorBrush x:Key="PressedBrush" Color="#24eef0f1" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#0aeef0f1" />
<SolidColorBrush x:Key="RowLineBrush" Color="#14eef0f1" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#267ea3c6" />
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#297ea3c6" />
<SolidColorBrush x:Key="AccentRowBrush" Color="#1f7ea3c6" />
<!-- Rampen gespiegelt: 100 ist im Dunkeln die dunkelste Stufe -->
<SolidColorBrush x:Key="Neutral100Brush" Color="#2b2b2d" />
<SolidColorBrush x:Key="Neutral200Brush" Color="#424244" />
<SolidColorBrush x:Key="Neutral300Brush" Color="#5d5d60" />
<SolidColorBrush x:Key="Neutral400Brush" Color="#7a7a7d" />
<SolidColorBrush x:Key="Neutral500Brush" Color="#98989b" />
<SolidColorBrush x:Key="Neutral600Brush" Color="#b7b7ba" />
<SolidColorBrush x:Key="Neutral700Brush" Color="#d4d4d7" />
<SolidColorBrush x:Key="Neutral800Brush" Color="#e7e7ea" />
<SolidColorBrush x:Key="Neutral900Brush" Color="#f5f5f8" />
<SolidColorBrush x:Key="Accent100Brush" Color="#1d2d3d" />
<SolidColorBrush x:Key="Accent200Brush" Color="#2c455d" />
<SolidColorBrush x:Key="Accent300Brush" Color="#416180" />
<SolidColorBrush x:Key="Accent400Brush" Color="#597ea3" />
<SolidColorBrush x:Key="Accent500Brush" Color="#749dc4" />
<SolidColorBrush x:Key="Accent600Brush" Color="#94bce3" />
<SolidColorBrush x:Key="Accent700Brush" Color="#b5d9fd" />
<SolidColorBrush x:Key="Accent800Brush" Color="#d6ebff" />
<SolidColorBrush x:Key="Accent900Brush" Color="#eef6ff" />
<SolidColorBrush x:Key="SuccessBrush" Color="#5fb37e" />
<SolidColorBrush x:Key="WarningBrush" Color="#d1a153" />
<SolidColorBrush x:Key="DangerBrush" Color="#e0685a" />
<SolidColorBrush x:Key="DangerSoftBrush" Color="#2ee0685a" />
<SolidColorBrush x:Key="DangerTextBrush" Color="#f0a79c" />
<SolidColorBrush x:Key="WarningSoftBrush" Color="#1ad1a153" />
<SolidColorBrush x:Key="DangerBarBrush" Color="#1ae0685a" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<!-- ══════════════════ Themenunabhaengig ══════════════════ -->
<!--
Barlow Condensed ueber Barlow, beide mitgeliefert statt per Systemname referenziert:
Barlow Condensed liegt auf keinem Windows und auf keinem schlanken Linux-Abbild, und
ohne die Schrift faellt der halbe Entwurf in sich zusammen.
-->
<FontFamily x:Key="HeadingFont">avares://ClawdDotNet/Assets/Fonts#Barlow Condensed</FontFamily>
<FontFamily x:Key="BodyFont">avares://ClawdDotNet/Assets/Fonts#Barlow</FontFamily>
<FontFamily x:Key="MonoFont">Cascadia Mono,Consolas,Menlo,DejaVu Sans Mono,monospace</FontFamily>
</ResourceDictionary>
@@ -0,0 +1,95 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>Eine Nachricht zwischen zwei Agenten.</summary>
public sealed class AgentMessageItem
{
public required string SenderName { get; init; }
public required string Text { get; init; }
public required DateTime Time { get; init; }
/// <summary>Links steht Agent A, rechts Agent B — wie im Chat mit dem Benutzer.</summary>
public required bool IsFromAgentA { get; init; }
}
/// <summary>Ein Agent in den beiden Auswahlfeldern.</summary>
public sealed record AgentChoice(string AgentId, string DisplayName)
{
public override string ToString() => DisplayName;
}
/// <summary>
/// Verlauf der Nachrichten zwischen zwei Agenten.
///
/// <para><strong>Ohne Datenbestand.</strong> Agenten können sich heute schon Nachrichten
/// schicken — <c>AgentEngine.SendMessageAsync</c> stellt zu, aber nichts schreibt den
/// Wortlaut mit. Der Speicher <c>AgentMessages</c> aus
/// <c>docs/Agentenkommunikation-Konzept.md</c> ist noch nicht gebaut.</para>
///
/// <para>Die Seite steht trotzdem vollständig da: Auswahl, Filterung und Darstellung sind
/// fertig, und wenn der Speicher kommt, ist hier genau eine Abfrage zu ergänzen —
/// <see cref="LoadMessages"/>. Bis dahin erklärt die Seite ihren Leerzustand, statt als
/// leerer Kasten dazustehen.</para>
/// </summary>
public sealed partial class AgentChatsPageViewModel : PageViewModel
{
public ObservableCollection<AgentChoice> Agents { get; } = [];
public ObservableCollection<AgentMessageItem> Messages { get; } = [];
[ObservableProperty]
private AgentChoice? _agentA;
[ObservableProperty]
private AgentChoice? _agentB;
[ObservableProperty]
private bool _isEmpty = true;
[ObservableProperty]
private string _emptyText = "";
public AgentChatsPageViewModel() : this(null) { }
public AgentChatsPageViewModel(AppHost? host) : base("Agenten-Chats")
{
if (host is null)
{
Agents.Add(new AgentChoice("dev-assistant", "Dev Assistant"));
Agents.Add(new AgentChoice("social-media", "Social Media Manager"));
}
else
{
foreach (var agent in host.Instance.Agents)
Agents.Add(new AgentChoice(agent.AgentId, agent.DisplayName));
}
AgentA = Agents.FirstOrDefault();
AgentB = Agents.Skip(1).FirstOrDefault() ?? AgentA;
LoadMessages();
}
partial void OnAgentAChanged(AgentChoice? value) => LoadMessages();
partial void OnAgentBChanged(AgentChoice? value) => LoadMessages();
/// <summary>
/// Hier kommt die Abfrage hin, sobald der <c>AgentMessages</c>-Speicher steht: alle
/// Nachrichten zwischen <see cref="AgentA"/> und <see cref="AgentB"/> in beiden
/// Richtungen, nach Zeit sortiert.
/// </summary>
private void LoadMessages()
{
Messages.Clear();
EmptyText = Agents.Count < 2
? "Für einen Verlauf braucht es mindestens zwei Agenten in dieser Instanz."
: "Noch keine Nachrichten aufgezeichnet. Agenten können sich bereits gegenseitig "
+ "erreichen; mitgeschrieben wird der Wortlaut erst, wenn der Nachrichtenspeicher "
+ "aus docs/Agentenkommunikation-Konzept.md steht.";
IsEmpty = Messages.Count == 0;
}
}
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
@@ -11,12 +12,20 @@ public sealed partial class ToolItemViewModel : ObservableObject
{
public string Name { get; }
/// <summary>
/// Kurzbeschreibung aus der Werkzeug-Registrierung. Leer, wenn das Werkzeug in dieser
/// Instanz gar nicht geladen ist — die Liste der bekannten Namen ist länger als die
/// der tatsächlich vorhandenen Werkzeuge.
/// </summary>
public string Description { get; }
[ObservableProperty]
private bool _isEnabled;
public ToolItemViewModel(string name, bool isEnabled)
public ToolItemViewModel(string name, bool isEnabled, string description = "")
{
Name = name;
Description = description;
_isEnabled = isEnabled;
}
}
@@ -75,7 +84,7 @@ public sealed partial class AgentItemViewModel : ObservableObject
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public AgentItemViewModel(AgentConfig config)
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
{
Config = config;
_displayName = config.DisplayName;
@@ -95,7 +104,9 @@ public sealed partial class AgentItemViewModel : ObservableObject
foreach (var tool in KnownTools)
{
var isEnabled = config.Tools.ContainsKey(tool);
var item = new ToolItemViewModel(tool, isEnabled);
var description = descriptions?.GetValueOrDefault(tool) ?? "";
var item = new ToolItemViewModel(tool, isEnabled, description);
item.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
@@ -128,8 +139,21 @@ public sealed partial class AgentItemViewModel : ObservableObject
}
}
/// <summary>Welches Feld der Texteditor gerade bearbeitet.</summary>
public enum AgentTextField
{
Identity,
Soul
}
/// <summary>
/// Ansichtsmodell für die Agenten-Verwaltungsseite.
///
/// <para>Der Entwurf ordnet die Seite wieder wie die WinForms-Vorlage in vier Felder an:
/// oben Agentenliste und Agenteneinstellungen, unten Werkzeugliste und
/// Werkzeugeinstellungen. Für dieses Modell heißt das vor allem: Die Werkzeugeinstellungen
/// sind kein eigenes Fenster mehr, sondern hängen als <see cref="ToolSettings"/> an der
/// Auswahl in der Werkzeugliste.</para>
/// </summary>
public sealed partial class AgentsPageViewModel : PageViewModel
{
@@ -141,9 +165,27 @@ public sealed partial class AgentsPageViewModel : PageViewModel
[ObservableProperty]
private AgentItemViewModel? _selectedAgent;
/// <summary>
/// Das ausgewählte Werkzeug des ausgewählten Agenten. Steuert, welche Felder unten
/// rechts stehen.
/// </summary>
[ObservableProperty]
private ToolItemViewModel? _selectedTool;
/// <summary>
/// Die Felder zum ausgewählten Werkzeug. Wird bei jedem Wechsel neu gebaut — das
/// Ansichtsmodell trägt die Werte von einem guten Dutzend Werkzeuge, und eines davon
/// mit den Werten eines anderen weiterzuverwenden ginge irgendwann schief.
/// </summary>
[ObservableProperty]
private ToolSettingsViewModel? _toolSettings;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isRunning;
public List<string> KnownModels { get; } =
[
"anthropic/claude-3-5-sonnet",
@@ -158,7 +200,12 @@ public sealed partial class AgentsPageViewModel : PageViewModel
public List<string> CachingOptions { get; } = ["auto", "on", "off"];
public event Func<AddAgentViewModel, Task<AddAgentResultData?>>? RequestAddAgentDialog;
public event Func<ToolSettingsViewModel, Task<Dictionary<string, object?>?>>? RequestToolSettingsDialog;
/// <summary>
/// Öffnet den Editor für Identity bzw. Soul. Gibt der Aufrufer einen Text zurück,
/// wurde gespeichert; <c>null</c> heißt Abbruch.
/// </summary>
public event Func<string, string, Task<string?>>? RequestTextEditorDialog;
public AgentsPageViewModel() : this(null) { }
@@ -177,20 +224,55 @@ public sealed partial class AgentsPageViewModel : PageViewModel
}
}
/// <summary>
/// Beschreibungen der tatsächlich geladenen Werkzeuge. Die Namensliste in
/// <see cref="AgentItemViewModel.KnownTools"/> ist fest verdrahtet und kennt auch
/// Werkzeuge, die in dieser Instanz nicht registriert sind — für die bleibt die
/// Beschreibung eben leer.
/// </summary>
private IReadOnlyDictionary<string, string> ToolDescriptions()
=> _host?.Tools.GetAll()
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>();
[RelayCommand]
private void ReloadAgents()
{
Agents.Clear();
if (_host is null) return;
var descriptions = ToolDescriptions();
foreach (var agent in _host.Instance.Agents)
{
Agents.Add(new AgentItemViewModel(agent));
}
Agents.Add(new AgentItemViewModel(agent, descriptions));
SelectedAgent = Agents.FirstOrDefault();
}
partial void OnSelectedAgentChanged(AgentItemViewModel? value)
{
// Die Werkzeugliste unten gehört zum gewählten Agenten — bei einem Wechsel darf
// dort nicht die Auswahl des vorigen stehen bleiben.
SelectedTool = value?.AvailableTools.FirstOrDefault();
}
partial void OnSelectedToolChanged(ToolItemViewModel? value)
{
if (SelectedAgent is null || value is null)
{
ToolSettings = null;
return;
}
// Auch für ein noch nicht zugelassenes Werkzeug sollen die Felder sichtbar sein —
// sonst müsste man erst ankreuzen, um zu sehen, was einzustellen wäre.
if (!SelectedAgent.Config.Tools.TryGetValue(value.Name, out var config) || config is null)
config = new Dictionary<string, object?>();
ToolSettings = new ToolSettingsViewModel(value.Name, config);
}
[RelayCommand]
private async Task AddAgentAsync()
{
@@ -217,7 +299,7 @@ public sealed partial class AgentsPageViewModel : PageViewModel
_host.Instance.Agents.Add(newConfig);
SaveAgents();
var item = new AgentItemViewModel(newConfig);
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
Agents.Add(item);
SelectedAgent = item;
@@ -239,28 +321,118 @@ public sealed partial class AgentsPageViewModel : PageViewModel
StatusText = $"Agent '{agentId}' entfernt.";
}
/// <summary>
/// Weckt den gewählten Agenten von Hand — im Entwurf die Schaltfläche „Jetzt ausf.".
/// Nimmt dieselbe Nachricht wie der geplante Lauf, damit der Handlauf zeigt, was der
/// geplante täte.
/// </summary>
[RelayCommand]
private async Task ConfigureToolAsync(ToolItemViewModel? toolItem)
private async Task RunAgentNowAsync()
{
if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return;
if (_host is null || SelectedAgent is null) return;
var toolName = toolItem.Name;
if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config))
if (_host.Engine is null)
{
config = new Dictionary<string, object?>();
SelectedAgent.Config.Tools[toolName] = config;
toolItem.IsEnabled = true;
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
return;
}
var vm = new ToolSettingsViewModel(toolName, config);
var newConfig = await RequestToolSettingsDialog(vm);
var config = SelectedAgent.Config;
var message = config.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
if (newConfig is not null)
IsRunning = true;
StatusText = $"Führe '{config.DisplayName}' aus…";
try
{
SelectedAgent.Config.Tools[toolName] = newConfig;
SaveAgents();
StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert.";
var result = await _host.Engine.RunAsync(
config, message, _host.Instance.InstanceId, CancellationToken.None);
StatusText = $"'{config.DisplayName}' abgeschlossen: {result.Status} "
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).";
}
catch (Exception ex)
{
StatusText = $"Lauf fehlgeschlagen: {ex.Message}";
_logger?.LogError(ex, "Handlauf des Agenten fehlgeschlagen");
}
finally
{
IsRunning = false;
}
}
[RelayCommand]
private Task EditIdentityAsync() => EditTextFieldAsync(AgentTextField.Identity);
[RelayCommand]
private Task EditSoulAsync() => EditTextFieldAsync(AgentTextField.Soul);
/// <summary>
/// Identity und Soul sind lange Texte. Der Entwurf gibt ihnen ein eigenes kleines
/// Fenster statt eines Feldes in der Spalte — in einem 60 Pixel hohen Kasten lässt
/// sich eine Rollenbeschreibung nicht schreiben.
/// </summary>
private async Task EditTextFieldAsync(AgentTextField field)
{
if (SelectedAgent is null || RequestTextEditorDialog is null) return;
var isIdentity = field == AgentTextField.Identity;
var title = isIdentity
? $"Identity bearbeiten — {SelectedAgent.DisplayName}"
: $"Soul bearbeiten — {SelectedAgent.DisplayName}";
var current = isIdentity ? SelectedAgent.Identity : SelectedAgent.Soul;
var edited = await RequestTextEditorDialog(title, current);
if (edited is null) return;
if (isIdentity) SelectedAgent.Identity = edited;
else SelectedAgent.Soul = edited;
SaveAgents();
}
/// <summary>Öffnet den Arbeitsordner des gewählten Agenten.</summary>
[RelayCommand]
private void OpenAgentFolder()
{
if (_host is null || SelectedAgent is null) return;
var path = _host.Directories.GetAgentPath(_host.InstancePath, SelectedAgent.DisplayName);
if (!Directory.Exists(path))
{
StatusText = $"Ordner nicht gefunden: {path}";
return;
}
SystemShell.OpenFolder(path);
}
/// <summary>
/// Übernimmt die Felder des gewählten Werkzeugs in die Agentenkonfiguration.
///
/// <para>Das Ankreuzfeld in der Liste und diese Schaltfläche sind zwei verschiedene
/// Dinge: Ersteres lässt das Werkzeug zu, letztere schreibt seine Einstellungen. Wer
/// nur ankreuzt, bekommt ein Werkzeug mit Vorgabewerten — das ist gewollt.</para>
/// </summary>
[RelayCommand]
private void SaveToolSettings()
{
if (SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
ToolSettings.ConfirmCommand.Execute(null);
if (ToolSettings.Result is not { } config) return;
SelectedAgent.Config.Tools[SelectedTool.Name] = config;
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen.
SelectedTool.IsEnabled = true;
SaveAgents();
StatusText = $"Einstellungen für '{SelectedTool.Name}' gespeichert.";
}
[RelayCommand]
@@ -269,12 +441,46 @@ public sealed partial class AgentsPageViewModel : PageViewModel
if (_host is null) return;
foreach (var agentVm in Agents)
{
agentVm.ApplyChanges();
}
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
_logger?.LogInformation("Agenten-Konfiguration gespeichert.");
StatusText = "Agenten-Konfiguration gespeichert.";
}
// ─── Teilerstellungen ───
//
// Die drei Teiler der Seite sollen den Neustart ueberleben. Gespeichert wird ein
// Verhaeltnis, kein Pixelwert: Sonst sitzt der Teiler nach einem Wechsel auf einen
// anderen Bildschirm an einer voellig anderen Stelle.
public double SplitVertical => Clamp(_host?.Settings.AppSettings.AgentsSplitVertical ?? 0.55);
public double SplitTopList => Clamp(_host?.Settings.AppSettings.AgentsSplitTopList ?? 0.44);
public double SplitBottomList => Clamp(_host?.Settings.AppSettings.AgentsSplitBottomList ?? 0.44);
/// <summary>
/// Hält den Teiler in einem Bereich, in dem beide Seiten noch bedienbar sind. Ein
/// gespeicherter Wert von 0.02 hätte eine Spalte auf einen Strich zusammengezogen und
/// wäre ohne Zurücksetzen nicht mehr aufzuziehen gewesen.
/// </summary>
private static double Clamp(double ratio)
=> double.IsFinite(ratio) ? Math.Clamp(ratio, 0.2, 0.8) : 0.5;
public void SaveSplits(double vertical, double topList, double bottomList)
{
if (_host is null) return;
try
{
var settings = _host.Settings.AppSettings;
settings.AgentsSplitVertical = Clamp(vertical);
settings.AgentsSplitTopList = Clamp(topList);
settings.AgentsSplitBottomList = Clamp(bottomList);
_host.Settings.Save();
}
catch (Exception)
{
// Eine Teilerstellung ist es nicht wert, die Bedienung zu unterbrechen.
}
}
}
@@ -1,7 +1,9 @@
using System.Collections.ObjectModel;
using System.Reflection;
using ClawdDotNet.App;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
@@ -64,6 +66,17 @@ public sealed partial class InfoPageViewModel : PageViewModel
LoadAssemblyBuildInfos();
}
/// <summary>
/// Fruehere Menueleiste, Punkt „Bearbeiten &gt; Instanz-Ordner oeffnen". Der Entwurf
/// kennt keine Menueleiste mehr; der Befehl sitzt jetzt dort, wo auch der Pfad steht.
/// </summary>
[RelayCommand]
private void OpenInstanceFolder()
{
if (!Directory.Exists(InstancePath)) return;
SystemShell.OpenFolder(InstancePath);
}
private void LoadAssemblyBuildInfos()
{
Assemblies.Clear();
@@ -7,21 +7,52 @@ using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Auswahl der Instanz beim Start — der erste Rückruf, den <c>AppHost</c> erfragt.
/// Eine Instanz in der Auswahl.
///
/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster in
/// einem Formular. Hier steht nur, <em>was</em> geschieht; das <em>wie</em> es aussieht
/// <para>Das Monogramm und der Punkt stammen aus dem Entwurf. Der Punkt zeigt bewusst
/// <em>Bereitschaft</em> und nicht „läuft": Ob eine andere Instanz gerade läuft, weiß
/// diese Anwendung nicht — jede Instanz ist ein eigener Prozess, und einen
/// instanzübergreifenden Status gibt es noch nicht. Ein grüner Punkt für „läuft" wäre
/// hier schlicht geraten.</para>
/// </summary>
public sealed class InstanceChoiceViewModel(InstanceInfo info, bool isCurrent)
{
public InstanceInfo Info { get; } = info;
public string InstanceName => Info.InstanceName;
public string FolderPath => Info.FolderPath;
/// <summary>Erste zwei Buchstaben, gross — wie im Entwurf.</summary>
public string Monogram => Info.InstanceName.Length >= 2
? Info.InstanceName[..2].ToUpperInvariant()
: Info.InstanceName.ToUpperInvariant();
public string Summary => $"{Info.AgentCount} Agenten · API-Key {Info.ApiKeyStatus.ToLowerInvariant()}";
/// <summary>Ohne API-Key kann die Instanz keine Agenten starten.</summary>
public bool IsReady => Info.ApiKeyStatus.Equals("Konfiguriert", StringComparison.OrdinalIgnoreCase);
/// <summary>Die gerade laufende Instanz — traegt im Entwurf das Etikett „Aktiv".</summary>
public bool IsCurrent { get; } = isCurrent;
}
/// <summary>
/// Auswahl der Instanz — beim Start und beim Wechseln aus der laufenden Anwendung.
///
/// <para>Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster
/// in einem Formular. Hier steht nur, <em>was</em> geschieht; das <em>wie</em> es aussieht
/// liegt in der Ansicht, und Rückfragen laufen über <see cref="ConfirmError"/> statt über
/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.
/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.</para>
/// </summary>
public sealed partial class InstancePickerViewModel : ViewModelBase
{
private readonly InstanceDirectoryManager _directories;
private readonly TaskCompletionSource<string?> _completion = new();
private readonly string? _currentInstancePath;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
private InstanceInfo? _selected;
private InstanceChoiceViewModel? _selected;
[ObservableProperty]
private string _newInstanceName = "";
@@ -30,16 +61,38 @@ public sealed partial class InstancePickerViewModel : ViewModelBase
[ObservableProperty]
private string _errorText = "";
public ObservableCollection<InstanceInfo> Instances { get; } = [];
public ObservableCollection<InstanceChoiceViewModel> Instances { get; } = [];
/// <summary>Der gewählte Pfad, oder <c>null</c> bei Abbruch.</summary>
public Task<string?> Result => _completion.Task;
public string InstancesDirectory => _directories.InstancesDirectory;
public InstancePickerViewModel(InstanceDirectoryManager directories)
/// <summary>
/// Beim Start heisst die Schaltflaeche „Starten", aus der laufenden Anwendung heraus
/// „Wechseln" — und dann steht der Hinweis dabei, dass dafuer neu gestartet wird.
/// </summary>
public bool IsSwitchMode => _currentInstancePath is not null;
public string ConfirmLabel => IsSwitchMode ? "Wechseln" : "Starten";
/// <summary>Ueberschrift des Dialogs — nicht aus ConfirmLabel zusammengesetzt,
/// sonst stuende dort „Instanz Starten".</summary>
public string Title => IsSwitchMode ? "Instanz wechseln" : "Instanz wählen";
public string Hint => IsSwitchMode
? "Jede Instanz läuft unabhängig mit eigenen Agenten und Daten. "
+ "Wechseln startet ClawdDotNet mit der gewählten Instanz neu."
: "Jede Instanz läuft unabhängig mit eigenen Agenten und Daten.";
/// <param name="currentInstancePath">
/// Pfad der laufenden Instanz, oder <c>null</c> beim Start. Steuert Beschriftung und
/// das Etikett „Aktiv" in der Liste.
/// </param>
public InstancePickerViewModel(InstanceDirectoryManager directories, string? currentInstancePath = null)
{
_directories = directories;
_currentInstancePath = currentInstancePath;
Refresh();
}
@@ -49,11 +102,17 @@ public sealed partial class InstancePickerViewModel : ViewModelBase
Instances.Clear();
foreach (var instance in _directories.ListInstances())
Instances.Add(instance);
{
var isCurrent = _currentInstancePath is not null
&& string.Equals(instance.FolderPath, _currentInstancePath,
StringComparison.OrdinalIgnoreCase);
Instances.Add(new InstanceChoiceViewModel(instance, isCurrent));
}
// Eine einzelne Instanz gleich vorwählen — der häufigste Fall, und ein Klick
// weniger bei jedem Start.
Selected ??= Instances.FirstOrDefault();
Selected ??= Instances.FirstOrDefault(i => i.IsCurrent) ?? Instances.FirstOrDefault();
}
private bool CanStart => Selected is not null;
@@ -61,7 +120,16 @@ public sealed partial class InstancePickerViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanStart))]
private void Start() => _completion.TrySetResult(Selected!.FolderPath);
/// <summary>Abbruch — der Aufrufer beendet die Anwendung.</summary>
/// <summary>Waehlt eine Zeile und bestaetigt in einem Zug — die Schaltflaeche je Zeile.</summary>
[RelayCommand]
private void Activate(InstanceChoiceViewModel? choice)
{
if (choice is null) return;
Selected = choice;
_completion.TrySetResult(choice.FolderPath);
}
/// <summary>Abbruch — der Aufrufer beendet die Anwendung oder schliesst den Dialog.</summary>
[RelayCommand]
private void Cancel() => _completion.TrySetResult(null);
@@ -1,26 +1,98 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Desktop.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
// Ohne Alias verweist "App" hier auf ClawdDotNet.Desktop.App, die Anwendungsklasse
// von Avalonia — und nicht auf die gleichnamige Fachschicht.
using AppSettings = ClawdDotNet.App.Settings.AppSettings;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Das Gerüst des Hauptfensters: welche Bereiche es gibt und welcher gerade sichtbar ist.
/// Das Gerüst des Hauptfensters: Seitenleiste, Kopfzeile, Meldungen, Statusleiste.
///
/// <para>Die Menüleiste ist entfallen. Der Entwurf kennt keine, und ihre Einträge hatten
/// ohnehin überall sonst schon einen Platz: Der Instanzwechsel sitzt jetzt als eigener
/// Schalter oben in der Seitenleiste, die Ordner-Befehle stehen dort, wo die Ordner
/// gebraucht werden (Logs-Seite, Info-Seite), und „Ansicht" war eine zweite Navigation
/// neben der Navigation.</para>
/// </summary>
public sealed partial class MainWindowViewModel : ViewModelBase
{
private readonly AppHost? _host;
/// <summary>Breite der Seitenleiste, ausgeklappt und eingeklappt (siehe Entwurf).</summary>
private const double SidebarWidthExpanded = 240;
private const double SidebarWidthCollapsed = 64;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PageTitle))]
private PageViewModel? _selectedPage;
public ObservableCollection<PageViewModel> Pages { get; }
/// <summary>Text der Statusleiste am unteren Rand.</summary>
[ObservableProperty]
private string _statusText = "Bereit";
[NotifyPropertyChangedFor(nameof(SidebarWidth))]
[NotifyPropertyChangedFor(nameof(IsSidebarExpanded))]
private bool _isSidebarCollapsed;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ThemeLabel))]
[NotifyPropertyChangedFor(nameof(ThemeIconKey))]
private bool _isDarkTheme;
// ─── Statusleiste ───
[ObservableProperty]
private string _instanceName = "—";
[ObservableProperty]
private bool _isApiReachable;
[ObservableProperty]
private string _apiStatusText = "unbekannt";
/// <summary>Kurzinfo zu Tokens und Kosten der letzten Stunde bzw. 24 Stunden.</summary>
[ObservableProperty]
private string _usageText = "—";
[ObservableProperty]
private string _usageTooltip = "";
[ObservableProperty]
private string _creditsText = "—";
/// <summary>Alle Bereiche, in der Reihenfolge der Navigation.</summary>
public ObservableCollection<PageViewModel> Pages { get; } = [];
/// <summary>Die Navigation, gruppiert wie im Entwurf.</summary>
public ObservableCollection<NavGroupViewModel> NavGroups { get; } = [];
/// <summary>
/// Instanzübergreifende Hinweise. Bleibt vorerst leer — der Dienst, der Fehler und
/// aufgebrauchte Guthaben <em>anderer</em> Instanzen sieht, existiert noch nicht
/// (siehe Implementierungsleitfaden im Mockup-Verzeichnis). Die Leiste blendet sich
/// aus, solange nichts anliegt, und ist damit heute schon unauffällig richtig.
/// </summary>
public ObservableCollection<NotificationViewModel> Notifications { get; } = [];
public bool HasNotifications => Notifications.Count > 0;
public double SidebarWidth => IsSidebarCollapsed ? SidebarWidthCollapsed : SidebarWidthExpanded;
public bool IsSidebarExpanded => !IsSidebarCollapsed;
public string PageTitle => SelectedPage?.Title ?? "";
public string ThemeLabel => IsDarkTheme ? "Dunkelmodus" : "Hellmodus";
/// <summary>Fusszeile der Seitenleiste.</summary>
public string VersionText => $"ClawdDotNet {AppHost.AppVersion} · Avalonia";
/// <summary>Sonne im Hellmodus, Mond im Dunkelmodus — beides Namen aus Icons.axaml.</summary>
public string ThemeIconKey => IsDarkTheme ? "IconMoon" : "IconSun";
public event Action? RequestExit;
public event Action? RequestInstanceManagerDialog;
@@ -32,25 +104,171 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{
_host = host;
Pages =
var chat = new ChatPageViewModel(host);
var agents = new AgentsPageViewModel(host);
var tasks = new TasksPageViewModel(host);
var tokens = new TokenUsagePageViewModel(host);
var agentChats = new AgentChatsPageViewModel(host);
var backup = new BackupPageViewModel(host);
var logs = new LogPageViewModel(host);
var settings = new SettingsPageViewModel(host);
var info = new InfoPageViewModel(host);
foreach (var page in new PageViewModel[]
{ chat, agents, tasks, tokens, agentChats, backup, logs, settings, info })
Pages.Add(page);
NavGroups.Add(new NavGroupViewModel("Arbeiten",
[
new ChatPageViewModel(host),
new LogPageViewModel(host),
new AgentsPageViewModel(host),
new SettingsPageViewModel(host),
new TasksPageViewModel(host),
new BackupPageViewModel(host),
new InfoPageViewModel(host)
];
new NavItemViewModel(this, chat, "IconChat"),
new NavItemViewModel(this, agents, "IconAgents"),
new NavItemViewModel(this, tasks, "IconTasks")
]));
SelectedPage = Pages[0];
NavGroups.Add(new NavGroupViewModel("Analyse",
[
new NavItemViewModel(this, tokens, "IconChart"),
new NavItemViewModel(this, agentChats, "IconAgentChats")
]));
StatusText = host is null
? "Entwurfsmodus"
: $"Instanz: {host.Instance.InstanceName} · "
+ (host.Engine is null ? "Agenten deaktiviert (kein API-Key)" : "bereit");
NavGroups.Add(new NavGroupViewModel("System",
[
new NavItemViewModel(this, backup, "IconBackup"),
new NavItemViewModel(this, logs, "IconLogs"),
new NavItemViewModel(this, settings, "IconSettings"),
new NavItemViewModel(this, info, "IconInfo")
]));
// Die Leiste blendet sich aus, solange nichts anliegt — dafür muss die Ansicht
// von Zu- und Abgängen erfahren, nicht nur von der Sammlung selbst.
Notifications.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasNotifications));
SelectedPage = chat;
if (host is null)
{
InstanceName = "Entwurfsmodus";
ApiStatusText = "Entwurf";
UsageText = "1h: 3.240 Tok · 24h: 168.500 Tok";
CreditsText = "Credits: $41.86 von $100.00";
// Zwei Beispielmeldungen, damit die Leiste im Editor sichtbar ist.
Notifications.Add(new NotificationViewModel(
this, isError: true, "sandbox", "OpenRouter-Guthaben aufgebraucht — Agenten pausiert."));
Notifications.Add(new NotificationViewModel(
this, isError: false, "stock-team", "3 unbehandelte Exceptions in den letzten 15 Minuten."));
return;
}
InstanceName = host.Instance.InstanceName;
IsSidebarCollapsed = host.Settings.AppSettings.SidebarCollapsed;
IsDarkTheme = ThemeManager.IsDarkName(host.Settings.AppSettings.Theme);
ThemeManager.Apply(IsDarkTheme);
ApplyCollapsedToNav();
HookStatusService(host);
}
/// <summary>
/// Hängt die Statusleiste an den OpenRouter-Dienst.
///
/// <para>Der Dienst war fertig und ungenutzt — er lieferte seine Texte bislang an
/// niemanden. <c>OnStatusUpdated</c> kommt vom Hintergrundfaden des
/// <c>PeriodicTimer</c>, deshalb der Wechsel auf den Oberflächenfaden.</para>
/// </summary>
private void HookStatusService(AppHost host)
{
if (host.Status is not { } status)
{
ApiStatusText = "kein API-Key";
UsageText = "keine Nutzungsdaten";
CreditsText = "";
return;
}
void Update()
{
IsApiReachable = status.IsApiReachable;
ApiStatusText = status.IsApiReachable ? "OK" : "nicht erreichbar";
UsageText = status.CreditsText;
UsageTooltip = status.CreditsTooltip;
CreditsText = status.CreditRemaining is { } remaining && status.CreditBalance is { } total
? $"Credits: ${remaining:F2} von ${total:F2}"
: "Credits: —";
}
status.OnStatusUpdated += () => Dispatcher.UIThread.Post(Update);
Update();
}
partial void OnSelectedPageChanged(PageViewModel? value)
{
foreach (var item in NavGroups.SelectMany(g => g.Items))
item.IsSelected = ReferenceEquals(item.Page, value);
}
partial void OnIsSidebarCollapsedChanged(bool value)
{
ApplyCollapsedToNav();
Persist(s => s.SidebarCollapsed = value);
}
partial void OnIsDarkThemeChanged(bool value)
{
ThemeManager.Apply(value);
Persist(s => s.Theme = ThemeManager.ToName(value));
}
/// <summary>
/// Der eingeklappte Zustand steht an jedem Eintrag statt an einer Bindung auf das
/// Elternfenster: Aus einer DataTemplate heraus zurück auf das Fenster zu binden ist
/// mit übersetzten Bindungen jedes Mal ein Ausdruck, den niemand mehr liest.
/// </summary>
private void ApplyCollapsedToNav()
{
foreach (var group in NavGroups)
{
group.IsTitleVisible = !IsSidebarCollapsed;
foreach (var item in group.Items)
item.IsCollapsed = IsSidebarCollapsed;
}
}
/// <summary>
/// Einstellungen sind ein Nebeneffekt der Bedienung, kein eigener Vorgang: Wer die
/// Leiste einklappt, drückt nicht anschließend auf Speichern. Ein Schreibfehler darf
/// die Bedienung deshalb auch nicht unterbrechen.
/// </summary>
private void Persist(Action<AppSettings> change)
{
if (_host is null) return;
try
{
change(_host.Settings.AppSettings);
_host.Settings.Save();
}
catch (Exception)
{
// Der Zustand gilt für diese Sitzung trotzdem — nur eben nicht für die nächste.
}
}
[RelayCommand]
private void SelectPage(PageViewModel? page)
{
if (page is not null) SelectedPage = page;
}
[RelayCommand]
private void ToggleSidebar() => IsSidebarCollapsed = !IsSidebarCollapsed;
[RelayCommand]
private void ToggleTheme() => IsDarkTheme = !IsDarkTheme;
[RelayCommand]
private void OpenInstanceFolder()
{
@@ -59,31 +277,62 @@ public sealed partial class MainWindowViewModel : ViewModelBase
}
[RelayCommand]
private void OpenLogFolder()
{
if (_host is null) return;
SystemShell.OpenFolder(_host.LogDirectory);
}
private void OpenInstanceManager() => RequestInstanceManagerDialog?.Invoke();
[RelayCommand]
private void OpenInstanceManager()
{
RequestInstanceManagerDialog?.Invoke();
}
private void Exit() => RequestExit?.Invoke();
}
/// <summary>Eine Gruppe der Navigation („Arbeiten", „Analyse", „System").</summary>
public sealed partial class NavGroupViewModel(string title, IReadOnlyList<NavItemViewModel> items)
: ObservableObject
{
public string Title { get; } = title;
public IReadOnlyList<NavItemViewModel> Items { get; } = items;
/// <summary>Im eingeklappten Zustand entfällt die Gruppenüberschrift.</summary>
[ObservableProperty]
private bool _isTitleVisible = true;
}
/// <summary>
/// Ein Eintrag der Navigation.
///
/// <para>Der Befehl sitzt am Eintrag selbst und nicht am Fenster: Aus einer DataTemplate
/// heraus über <c>$parent[…]</c> auf einen Befehl des Fensters zu binden funktioniert
/// zwar, ist aber bei übersetzten Bindungen ein Ausdruck mit Typumwandlung, den man beim
/// nächsten Lesen erst wieder entziffern muss.</para>
/// </summary>
public sealed partial class NavItemViewModel(
MainWindowViewModel owner, PageViewModel page, string iconKey) : ObservableObject
{
public PageViewModel Page { get; } = page;
public string Title => Page.Title;
/// <summary>Name der Geometrie in <c>Styles/Icons.axaml</c>.</summary>
public string IconKey { get; } = iconKey;
[ObservableProperty]
private bool _isSelected;
[ObservableProperty]
private bool _isCollapsed;
[RelayCommand]
private void SelectPageByTitle(string title)
{
var target = Pages.FirstOrDefault(p => p.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
if (target is not null)
SelectedPage = target;
}
private void Select() => owner.SelectedPage = Page;
}
/// <summary>Eine Zeile der Benachrichtigungsleiste.</summary>
public sealed partial class NotificationViewModel(
MainWindowViewModel owner, bool isError, string instanceName, string text) : ObservableObject
{
public bool IsError { get; } = isError;
public bool IsWarning => !IsError;
public string InstanceName { get; } = instanceName;
public string Text { get; } = text;
[RelayCommand]
private void Exit()
{
RequestExit?.Invoke();
}
private void Dismiss() => owner.Notifications.Remove(this);
}
/// <summary>Ein Bereich des Hauptfensters.</summary>
@@ -93,7 +342,7 @@ public abstract partial class PageViewModel(string title) : ViewModelBase
}
/// <summary>
/// Platzhalter, solange der Bereich noch nicht portiert ist.
/// Platzhalter, solange ein Bereich noch nicht portiert ist.
/// </summary>
public sealed partial class PlaceholderPageViewModel(string title, string note)
: PageViewModel(title)
@@ -26,6 +26,14 @@ public sealed record JobDisplayItemViewModel(
string Status
);
/// <summary>Die drei Ansichten der Aufgaben-Seite.</summary>
public enum TasksTab
{
Jobs,
Services,
History
}
public sealed record ServiceDisplayItemViewModel(
string ServiceId,
string Name,
@@ -61,6 +69,22 @@ public sealed partial class TasksPageViewModel : PageViewModel
[ObservableProperty]
private bool _isRunningJob;
/// <summary>
/// Der Entwurf ersetzt die Registerkarten durch eine Segmentleiste. Fachlich ist das
/// dasselbe: eine von drei Ansichten. Die drei Kennzeichen darunter gibt es, weil
/// eine Bindung „Ist dieser Wert gleich Services?" in AXAML sonst jedes Mal einen
/// Konverter mit Parameter bräuchte.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsJobsTab))]
[NotifyPropertyChangedFor(nameof(IsServicesTab))]
[NotifyPropertyChangedFor(nameof(IsHistoryTab))]
private TasksTab _selectedTab = TasksTab.Jobs;
public bool IsJobsTab => SelectedTab == TasksTab.Jobs;
public bool IsServicesTab => SelectedTab == TasksTab.Services;
public bool IsHistoryTab => SelectedTab == TasksTab.History;
public event Func<AddJobViewModel, Task<AddJobResultData?>>? RequestAddJobDialog;
public event Func<AddServiceViewModel, Task<AddServiceResultData?>>? RequestAddServiceDialog;
@@ -429,6 +453,85 @@ public sealed partial class TasksPageViewModel : PageViewModel
RefreshServiceList();
}
/// <summary>
/// Schaltet einen Job an oder ab, ohne ihn zu entfernen — im Entwurf die neue
/// Schaltfläche „Aktivieren/Deaktivieren" in der Werkzeugleiste.
///
/// <para>Gilt nur für Tool-Jobs: <c>SchedulerConfig</c> hat keinen solchen Schalter,
/// ein Agent-Wakeup existiert oder existiert nicht. Ihn hier zu ergänzen wäre keine
/// Oberflächenarbeit — der Scanner müsste den Schalter erst einmal lesen, sonst liefe
/// ein „deaktivierter" Wakeup munter weiter.</para>
/// </summary>
[RelayCommand]
private void ToggleJobEnabled()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
if (entry.JobType != "Tool Job")
{
StatusText = "Nur Tool-Jobs lassen sich abschalten. "
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.";
return;
}
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
var job = agent?.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (job is null) return;
job.Enabled = !job.Enabled;
SaveInstanceConfig();
RefreshJobList();
// Die Auswahl geht beim Neuaufbau der Liste verloren — sie wieder zu setzen
// erspart es, für zwei Schaltvorgänge zweimal dieselbe Zeile anzuklicken.
SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == entry.JobId);
StatusText = job.Enabled
? $"Job '{entry.TaskMessage}' aktiviert."
: $"Job '{entry.TaskMessage}' deaktiviert.";
}
/// <summary>
/// Aktiviert bzw. deaktiviert einen Dienst.
///
/// <para>Bewusst nicht „Start"/„Stop" genannt, auch wenn der Entwurf das so zeigt:
/// Umgelegt wird das Kennzeichen <c>Enabled</c> in der Instanzkonfiguration. Ein
/// laufender Dienstprozess wird davon nicht angehalten — eine Schaltfläche „Stop",
/// nach der etwas weiterläuft, wäre schlimmer als gar keine.</para>
/// </summary>
[RelayCommand]
private void ToggleServiceEnabled()
{
if (_host is null || SelectedService is null) return;
var id = SelectedService.ServiceId;
var service = _host.Instance.Services.FirstOrDefault(s => s.ServiceId == id);
if (service is null) return;
service.Enabled = !service.Enabled;
SaveInstanceConfig();
RefreshServiceList();
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id);
StatusText = service.Enabled
? $"Dienst '{service.Name}' aktiviert."
: $"Dienst '{service.Name}' deaktiviert.";
}
[RelayCommand]
private void SelectTab(string? tab)
{
SelectedTab = tab switch
{
"Services" => TasksTab.Services,
"History" => TasksTab.History,
_ => TasksTab.Jobs
};
}
private static string CalculateNextRun(string? cronExpr)
{
if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
@@ -0,0 +1,201 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Accounting;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>Ein Balken des Wochendiagramms.</summary>
public sealed class TokenDayItem
{
public required string DayLabel { get; init; }
public required string TokensLabel { get; init; }
public required int Tokens { get; init; }
/// <summary>
/// Höhe in Bildpunkten, nicht in Prozent: Ein Balken kann sich nicht auf die Höhe
/// seines Elternteils beziehen, ohne dass die Zeile eine feste Höhe bekommt — und
/// eine feste Höhe im Ansichtsmodell auszurechnen ist ehrlicher als eine, die in
/// drei Bindungen verteilt steht.
/// </summary>
public required double BarHeight { get; init; }
}
/// <summary>Eine Zeile der Aufschlüsselung nach Agent und Modell.</summary>
public sealed class TokenAgentItem
{
public required string AgentName { get; init; }
public required string Model { get; init; }
public required string TokensLabel { get; init; }
public required string CostLabel { get; init; }
public required int Runs { get; init; }
}
/// <summary>
/// Tokennutzung der letzten sieben Tage.
///
/// <para>Liest aus <see cref="SqliteUsageRepository"/> — dieselbe Quelle, aus der die
/// Tagesbudgets bedient werden. Kein Diagramm-Paket: Der Entwurf zeigt sieben Balken und
/// eine Tabelle, und dafür genügen Rechtecke mit ausgerechneter Höhe. Eine Bibliothek
/// dafür wäre 8 MB Abhängigkeit für sieben Rechtecke.</para>
/// </summary>
public sealed partial class TokenUsagePageViewModel : PageViewModel
{
/// <summary>Höhe der Diagrammfläche in Bildpunkten (siehe Entwurf: 160 px inkl. Beschriftung).</summary>
private const double ChartHeight = 130;
/// <summary>Wie viele Läufe für die Aufschlüsselung herangezogen werden.</summary>
private const int RecentRunLimit = 1000;
private const int Days = 7;
private readonly AppHost? _host;
public ObservableCollection<TokenDayItem> Days7 { get; } = [];
public ObservableCollection<TokenAgentItem> ByAgent { get; } = [];
[ObservableProperty]
private string _totalTokens = "—";
[ObservableProperty]
private string _totalCost = "—";
[ObservableProperty]
private string _totalRuns = "—";
[ObservableProperty]
private string _statusText = "";
[ObservableProperty]
private bool _isEmpty = true;
public TokenUsagePageViewModel() : this(null) { }
public TokenUsagePageViewModel(AppHost? host) : base("Tokennutzung")
{
_host = host;
if (host?.Usage is null)
{
StatusText = host is null
? "Entwurfsmodus"
: "Keine Verbrauchsdatenbank für diese Instanz.";
return;
}
// Nicht abwarten: Der Aufbau des Fensters darf nicht auf eine Datenbankabfrage
// warten. Die Seite füllt sich, sobald die Zahlen da sind.
_ = RefreshAsync();
}
[RelayCommand]
private async Task RefreshAsync()
{
if (_host?.Usage is not { } usage) return;
try
{
var today = DateOnly.FromDateTime(DateTime.Now);
var daily = new List<DailyUsage>(Days);
for (var offset = Days - 1; offset >= 0; offset--)
daily.Add(await usage.GetDailyAsync(today.AddDays(-offset), "", CancellationToken.None));
var recent = await usage.GetRecentAsync(RecentRunLimit, CancellationToken.None);
var since = DateTime.Now.Date.AddDays(-(Days - 1));
var window = recent.Where(r => r.OccurredAt >= since).ToArray();
BuildChart(daily);
BuildBreakdown(window);
BuildTotals(daily, window);
}
catch (Exception ex)
{
StatusText = $"Verbrauchsdaten konnten nicht gelesen werden: {ex.Message}";
}
}
private void BuildChart(IReadOnlyList<DailyUsage> daily)
{
Days7.Clear();
// Ohne Höchstwert gäbe es keinen Maßstab; ohne die Untergrenze 1 eine Division
// durch null an dem Tag, an dem noch nichts gelaufen ist.
var max = Math.Max(1, daily.Max(d => d.TotalTokens));
foreach (var day in daily)
{
Days7.Add(new TokenDayItem
{
DayLabel = day.Date.ToString("ddd"),
Tokens = day.TotalTokens,
TokensLabel = day.TotalTokens >= 1000
? $"{day.TotalTokens / 1000d:0.#}k"
: day.TotalTokens.ToString("N0"),
// Mindestens ein Pixel, damit ein Tag mit wenigen Tokens nicht wie ein
// Tag ohne Läufe aussieht.
BarHeight = Math.Max(1, day.TotalTokens / (double)max * ChartHeight)
});
}
}
private void BuildBreakdown(IReadOnlyList<RunUsage> runs)
{
ByAgent.Clear();
var names = _host?.Instance.Agents
.ToDictionary(a => a.AgentId, a => a.DisplayName, StringComparer.OrdinalIgnoreCase)
?? [];
var groups = runs
.GroupBy(r => (r.AgentId, r.Model))
.OrderByDescending(g => g.Sum(r => r.PromptTokens + r.CompletionTokens));
foreach (var group in groups)
{
var (agentId, model) = group.Key;
var tokens = group.Sum(r => r.PromptTokens + r.CompletionTokens);
var cost = group.Sum(r => r.CostUsd);
var incomplete = group.Any(r => !r.CostIsKnown);
ByAgent.Add(new TokenAgentItem
{
AgentName = names.TryGetValue(agentId, out var name) ? name : agentId,
Model = model,
TokensLabel = tokens.ToString("N0"),
CostLabel = FormatCost(cost, incomplete),
Runs = group.Count()
});
}
}
private void BuildTotals(IReadOnlyList<DailyUsage> daily, IReadOnlyList<RunUsage> runs)
{
var tokens = daily.Sum(d => d.TotalTokens);
var cost = daily.Sum(d => d.CostUsd);
var incomplete = daily.Any(d => !d.CostIsComplete);
TotalTokens = tokens.ToString("N0");
TotalCost = FormatCost(cost, incomplete);
TotalRuns = daily.Sum(d => d.RunCount).ToString("N0");
IsEmpty = tokens == 0 && runs.Count == 0;
StatusText = IsEmpty ? "In den letzten sieben Tagen ist kein Lauf verzeichnet." : "";
}
/// <summary>
/// Kosten in Euro, wie in der Statusleiste — mit demselben Kurs aus
/// <see cref="OpenRouterStatusService.UsdToEur"/>. Das Zeichen ⚠ steht für Läufe,
/// deren Modellpreise nicht vorlagen: Die Summe ist dann zu niedrig, und das soll man
/// ihr ansehen.
/// </summary>
private static string FormatCost(decimal usd, bool incomplete)
{
var eur = (double)usd * OpenRouterStatusService.UsdToEur;
return incomplete ? $"{eur:F2} € ⚠" : $"{eur:F2} €";
}
}
@@ -0,0 +1,61 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.AgentChatsPageView"
x:DataType="vm:AgentChatsPageViewModel">
<DockPanel MaxWidth="820" HorizontalAlignment="Left">
<!-- Wer mit wem -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10"
Margin="0,0,0,16" VerticalAlignment="Center">
<TextBlock Text="Agent A:" FontWeight="SemiBold" VerticalAlignment="Center" />
<ComboBox Width="220" ItemsSource="{Binding Agents}" SelectedItem="{Binding AgentA}" />
<TextBlock Text="Agent B:" FontWeight="SemiBold" VerticalAlignment="Center"
Margin="8,0,0,0" />
<ComboBox Width="220" ItemsSource="{Binding Agents}" SelectedItem="{Binding AgentB}" />
</StackPanel>
<!-- Leerzustand: erklärt, warum hier noch nichts steht -->
<Border Classes="card" DockPanel.Dock="Top" VerticalAlignment="Top"
IsVisible="{Binding IsEmpty}">
<TextBlock Classes="muted" Text="{Binding EmptyText}" TextWrapping="Wrap" />
</Border>
<!-- Verlauf -->
<ScrollViewer VerticalScrollBarVisibility="Auto" IsVisible="{Binding !IsEmpty}">
<ItemsControl ItemsSource="{Binding Messages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AgentMessageItem">
<Border Classes="card" MaxWidth="560"
Classes.accent="{Binding !IsFromAgentA}"
HorizontalAlignment="{Binding !IsFromAgentA,
Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
<StackPanel Spacing="4">
<DockPanel>
<TextBlock FontSize="12" FontWeight="SemiBold" Text="{Binding SenderName}" />
<TextBlock Classes="caption" DockPanel.Dock="Right" HorizontalAlignment="Right"
Text="{Binding Time, StringFormat='{}{0:dd.MM. HH:mm}'}" />
</DockPanel>
<SelectableTextBlock FontSize="13.5" TextWrapping="Wrap" Text="{Binding Text}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class AgentChatsPageView : UserControl
{
public AgentChatsPageView() => InitializeComponent();
}
+248 -118
View File
@@ -3,153 +3,283 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="820"
x:Class="ClawdDotNet.Desktop.Views.AgentsPageView"
x:DataType="vm:AgentsPageViewModel">
<!--
Vier Felder wie in der WinForms-Vorlage: oben Agentenliste und Agenteneinstellungen,
unten Werkzeugliste und Werkzeugeinstellungen — aber mit ziehbaren Teilern statt
fester SplitterDistance und mit flachen Feldern statt PropertyGrid.
Die Teilerstellungen werden im Code-Behind gelesen und gespeichert: ColumnDefinition
liegt nicht im Sichtbarkeitsbaum und erbt deshalb keinen DataContext — eine Bindung
darauf wuerde stillschweigend nichts tun.
-->
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
Text="{Binding StatusText}" TextWrapping="Wrap" />
<!-- Master-Detail Split -->
<Grid ColumnDefinitions="260,*">
<Grid x:Name="OuterGrid" RowDefinitions="*,Auto,*">
<!-- Linke Spalte: Agenten-Liste -->
<DockPanel Grid.Column="0" Margin="0,0,12,0">
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Neu" Command="{Binding AddAgentCommand}" Classes="accent" />
<Button Content="Entfernen" Command="{Binding RemoveAgentCommand}" IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Speichern" Command="{Binding SaveAgentsCommand}" />
</StackPanel>
<!-- ══════════════════ Oben: Agenten ══════════════════ -->
<Grid x:Name="TopGrid" Grid.Row="0" ColumnDefinitions="0.44*,Auto,0.56*">
<!-- Liste -->
<ListBox ItemsSource="{Binding Agents}" SelectedItem="{Binding SelectedAgent}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<StackPanel Margin="4">
<TextBlock Text="{Binding DisplayName}" FontWeight="Bold" />
<TextBlock Text="{Binding Model}" Classes="caption" />
<DockPanel Grid.Column="0" Margin="0,0,14,0">
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar" Command="{Binding AddAgentCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="Agent" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Button>
</DockPanel>
<Button Classes="toolbar" Command="{Binding RemoveAgentCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconMinus}" Width="14" Height="14" />
<TextBlock Text="Agent" />
</StackPanel>
</Button>
<!-- Rechte Spalte: Agenten-Details -->
<ContentControl Grid.Column="1" Content="{Binding SelectedAgent}">
<ContentControl.ContentTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<TabControl>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding RunAgentNowCommand}"
IsEnabled="{Binding !IsRunning}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="Jetzt ausf." />
</StackPanel>
</Button>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding EditIdentityCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
<TextBlock Text="Identity" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding EditSoulCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
<TextBlock Text="Soul" />
</StackPanel>
</Button>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding OpenAgentFolderCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
<TextBlock Text="Ordner" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding SaveAgentsCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
<TextBlock Text="Speichern" />
</StackPanel>
</Button>
</WrapPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.4*,1.6*,2*">
<TextBlock Grid.Column="0" Classes="th" Text="ANZEIGENAME" />
<TextBlock Grid.Column="1" Classes="th" Text="MODELL" />
<TextBlock Grid.Column="2" Classes="th" Text="KURZINFO" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.4*,1.6*,2*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold"
Text="{Binding DisplayName}" />
<TextBlock Grid.Column="1" Classes="td dim" Text="{Binding Model}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding Description}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<GridSplitter x:Name="TopSplitter" Grid.Column="1" Classes="vertical"
ResizeDirection="Columns" />
<!-- Einstellungen des gewählten Agenten -->
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto" Padding="4,0,0,0">
<ContentControl Content="{Binding SelectedAgent}">
<ContentControl.ContentTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<StackPanel Spacing="20" MaxWidth="640" HorizontalAlignment="Left">
<!-- TAB 1: Allgemein & Modell -->
<TabItem Header="Allgemein &amp; Modell">
<ScrollViewer Padding="8">
<StackPanel Spacing="12">
<TextBlock Classes="kicker" Text="ALLGEMEIN &amp; MODELL" />
<TextBlock Classes="h2" Text="{Binding DisplayName}" />
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Agent ID:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding AgentId}" IsReadOnly="True" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anzeigename:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DisplayName}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}" SelectedItem="{Binding Model}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Rolle / Beschreibung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Description}" AcceptsReturn="True" Height="60" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Prompt Caching:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="4" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}" SelectedItem="{Binding PromptCaching}" Width="150" HorizontalAlignment="Left" Margin="0,8,0,0" />
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Agent-ID" />
<TextBox Text="{Binding AgentId}" IsReadOnly="True" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Anzeigename" />
<TextBox Text="{Binding DisplayName}" />
</StackPanel>
</Grid>
<!-- Limits Card -->
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="Schutzgrenzen (LoopGuard &amp; Budget)" Classes="heading" />
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Max. Schritte pro Run:" />
<NumericUpDown Value="{Binding MaxSteps}" Minimum="1" Maximum="100" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="8,0,0,0">
<TextBlock Text="Timeout (Sekunden):" />
<NumericUpDown Value="{Binding TimeoutSeconds}" Minimum="10" Maximum="3600" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,8,0,0">
<TextBlock Text="Max. Kontext-Tokens:" />
<NumericUpDown Value="{Binding MaxContextTokens}" Minimum="1000" Maximum="2000000" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="8,8,0,0">
<TextBlock Text="Max. Tool-Ergebnis (Zeichen):" />
<NumericUpDown Value="{Binding MaxToolResultChars}" Minimum="1000" Maximum="500000" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Modell" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}"
SelectedItem="{Binding Model}" />
</StackPanel>
</Border>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Prompt Caching" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}"
SelectedItem="{Binding PromptCaching}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="Rolle / Beschreibung" />
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="54"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="System-Prompt (zusätzliche Anweisungen)" />
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="70"
TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB 2: System Prompt & Seele -->
<TabItem Header="System-Prompt &amp; Soul">
<ScrollViewer Padding="8">
<Border Classes="hr" />
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="System-Prompt (Zusätzliche Anweisungen):" FontWeight="Bold" />
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="120" TextWrapping="Wrap" />
</StackPanel>
<TextBlock Classes="kicker" Text="GRENZEN" />
<TextBlock Classes="h2" Text="Schutzgrenzen (LoopGuard &amp; Budget)" />
<StackPanel Spacing="4">
<TextBlock Text="Identität (Identity):" FontWeight="Bold" />
<TextBox Text="{Binding Identity}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Max. Schritte pro Run" />
<TextBox Text="{Binding MaxSteps}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Timeout (Sekunden)" />
<TextBox Text="{Binding TimeoutSeconds}" />
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Seele &amp; Werte (Soul):" FontWeight="Bold" />
<TextBox Text="{Binding Soul}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Max. Kontext-Tokens" />
<TextBox Text="{Binding MaxContextTokens}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Max. Tool-Ergebnis (Zeichen)" />
<TextBox Text="{Binding MaxToolResultChars}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Tagesbudget ($ USD)" />
<TextBox Text="{Binding DailyCostUsd}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Tagesbudget (Tokens)" />
<TextBox Text="{Binding DailyTokens}" />
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB 3: Werkzeuge (Tools) -->
<TabItem Header="Werkzeuge (Tools)">
<ScrollViewer Padding="8">
<StackPanel Spacing="8">
<TextBlock Text="Zugelassene Werkzeuge für diesen Agenten:" Classes="heading" />
<ItemsControl ItemsSource="{Binding AvailableTools}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ToolItemViewModel">
<Grid ColumnDefinitions="200,Auto" Margin="0,4">
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsEnabled}" Grid.Column="0" VerticalAlignment="Center" />
<Button Content="⚙ Konfigurieren" Command="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).ConfigureToolCommand}" CommandParameter="{Binding}" IsEnabled="{Binding IsEnabled}" Grid.Column="1" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</TabItem>
</StackPanel>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</ScrollViewer>
</Grid>
</TabControl>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
<GridSplitter x:Name="VerticalSplitter" Grid.Row="1" Classes="horizontal"
ResizeDirection="Rows" Margin="0,8" />
<!-- ══════════════════ Unten: Werkzeuge ══════════════════ -->
<Grid x:Name="BottomGrid" Grid.Row="2" ColumnDefinitions="0.44*,Auto,0.56*">
<DockPanel Grid.Column="0" Margin="0,0,14,0">
<TextBlock DockPanel.Dock="Top" Classes="kicker" Margin="0,4,0,8"
Text="{Binding SelectedAgent.DisplayName,
StringFormat='ZUGELASSENE WERKZEUGE FÜR {0}'}" />
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="36,1.4*,2.4*">
<TextBlock Grid.Column="1" Classes="th" Text="WERKZEUG" />
<TextBlock Grid.Column="2" Classes="th" Text="BESCHREIBUNG" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding SelectedAgent.AvailableTools}"
SelectedItem="{Binding SelectedTool}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:ToolItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="36,1.4*,2.4*">
<CheckBox Grid.Column="0" IsChecked="{Binding IsEnabled}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="td" FontWeight="SemiBold"
Text="{Binding Name}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding Description}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<GridSplitter x:Name="BottomSplitter" Grid.Column="1" Classes="vertical"
ResizeDirection="Columns" />
<DockPanel Grid.Column="2" Margin="4,0,0,0">
<StackPanel DockPanel.Dock="Top" Spacing="2" Margin="0,4,0,10">
<TextBlock Classes="kicker" Text="WERKZEUG-EINSTELLUNGEN" />
<DockPanel>
<TextBlock Classes="h2" VerticalAlignment="Center"
Text="{Binding SelectedTool.Name}" />
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar primary"
Command="{Binding SaveToolSettingsCommand}"
IsEnabled="{Binding ToolSettings, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
<TextBlock Text="Übernehmen" />
</StackPanel>
</Button>
</DockPanel>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ContentControl Content="{Binding ToolSettings}" />
</ScrollViewer>
</DockPanel>
</Grid>
</Grid>
</DockPanel>
</UserControl>
@@ -1,46 +1,101 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AgentsPageView : UserControl
{
private AgentsPageViewModel? _viewModel;
public AgentsPageView()
{
InitializeComponent();
// Ein Zug am Teiler ist erst zu Ende, wenn die Maus losgelassen wird. Waehrend
// des Ziehens zu speichern hiesse, fuer eine Bewegung ein paar Dutzend Mal die
// Einstellungsdatei zu schreiben.
TopSplitter.DragCompleted += (_, _) => SaveSplits();
BottomSplitter.DragCompleted += (_, _) => SaveSplits();
VerticalSplitter.DragCompleted += (_, _) => SaveSplits();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AgentsPageViewModel vm)
{
vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
vm.RequestToolSettingsDialog += OnRequestToolSettingsDialog;
}
if (DataContext is not AgentsPageViewModel vm) return;
_viewModel = vm;
vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
vm.RequestTextEditorDialog += OnRequestTextEditorDialog;
ApplySavedSplits(vm);
}
/// <summary>
/// Setzt die gespeicherten Teilerstellungen.
///
/// <para>Von Hand und nicht per Bindung: <see cref="ColumnDefinition"/> und
/// <see cref="RowDefinition"/> liegen nicht im Sichtbarkeitsbaum und erben deshalb
/// keinen DataContext. Eine Bindung auf ihre Breite bliebe ohne Wirkung — und zwar
/// ohne Fehlermeldung.</para>
/// </summary>
private void ApplySavedSplits(AgentsPageViewModel vm)
{
SetRatio(TopGrid.ColumnDefinitions[0], TopGrid.ColumnDefinitions[2], vm.SplitTopList);
SetRatio(BottomGrid.ColumnDefinitions[0], BottomGrid.ColumnDefinitions[2], vm.SplitBottomList);
OuterGrid.RowDefinitions[0].Height = new GridLength(vm.SplitVertical, GridUnitType.Star);
OuterGrid.RowDefinitions[2].Height = new GridLength(1 - vm.SplitVertical, GridUnitType.Star);
}
private static void SetRatio(ColumnDefinition first, ColumnDefinition second, double ratio)
{
first.Width = new GridLength(ratio, GridUnitType.Star);
second.Width = new GridLength(1 - ratio, GridUnitType.Star);
}
private void SaveSplits()
{
if (_viewModel is null) return;
_viewModel.SaveSplits(
RowRatio(OuterGrid.RowDefinitions[0], OuterGrid.RowDefinitions[2]),
ColumnRatio(TopGrid.ColumnDefinitions[0], TopGrid.ColumnDefinitions[2]),
ColumnRatio(BottomGrid.ColumnDefinitions[0], BottomGrid.ColumnDefinitions[2]));
}
/// <summary>
/// Das Verhaeltnis wird aus den tatsaechlichen Pixelbreiten gebildet, nicht aus den
/// Sternwerten: Der GridSplitter setzt beim Ziehen mitunter absolute Breiten, und
/// dann stuenden in den Sternwerten noch die Zahlen von vor dem Zug.
/// </summary>
private static double ColumnRatio(ColumnDefinition first, ColumnDefinition second)
=> Ratio(first.ActualWidth, second.ActualWidth);
private static double RowRatio(RowDefinition first, RowDefinition second)
=> Ratio(first.ActualHeight, second.ActualHeight);
private static double Ratio(double first, double second)
{
var total = first + second;
return total > 0 ? first / total : 0.5;
}
private async Task<AddAgentResultData?> OnRequestAddAgentDialog(AddAgentViewModel vm)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new AddAgentWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
if (TopLevel.GetTopLevel(this) is not Window window) return null;
var dialog = new AddAgentWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
private async Task<Dictionary<string, object?>?> OnRequestToolSettingsDialog(ToolSettingsViewModel vm)
private async Task<string?> OnRequestTextEditorDialog(string title, string text)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new ToolSettingsWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
if (TopLevel.GetTopLevel(this) is not Window window) return null;
return await TextEditorWindow.ShowAsync(window, title, text);
}
}
@@ -3,95 +3,147 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="740"
x:Class="ClawdDotNet.Desktop.Views.BackupPageView"
x:DataType="vm:BackupPageViewModel">
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
Text="{Binding StatusText}" TextWrapping="Wrap" />
<ScrollViewer>
<StackPanel Spacing="16">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="820" HorizontalAlignment="Left">
<TextBlock Text="Sicherung &amp; Wiederherstellung" Classes="heading" FontSize="20" />
<!-- ══════════════════ Manuell ══════════════════ -->
<StackPanel Spacing="12">
<TextBlock Classes="kicker" Text="MANUELL" />
<TextBlock Classes="h2" Text="Sicherung erstellen" />
<!-- Card: Neue Sicherung erstellen -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Manuelle Sicherung erstellen" Classes="heading" />
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto">
<TextBox Text="{Binding TargetFolder}" Grid.Column="0" PlaceholderText="Zielordner für Sicherungen..." />
<Button Content="Ordner wählen" Click="OnBrowseTargetFolder" Grid.Column="1" Margin="8,0,0,0" />
</Grid>
<!-- Zugangsdaten Optionen -->
<TextBlock Text="Zugangsdaten (API-Schlüssel, Passwörter):" FontWeight="Bold" Margin="0,4,0,0" />
<RadioButton Content="Zugangsdaten NICHT mit sichern (ohne Passphrase)" IsChecked="{Binding SecretsExclude}" />
<RadioButton Content="Mit Passphrase verschlüsseln" IsChecked="{Binding SecretsPassphrase}" />
<StackPanel Spacing="8" IsVisible="{Binding SecretsPassphrase}" Margin="20,0,0,0">
<TextBox Text="{Binding Passphrase}" PasswordChar="*" PlaceholderText="Passphrase eingeben" Width="300" HorizontalAlignment="Left" />
<TextBox Text="{Binding PassphraseRepeat}" PasswordChar="*" PlaceholderText="Passphrase wiederholen" Width="300" HorizontalAlignment="Left" />
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Bottom">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Zielordner" />
<TextBox Text="{Binding TargetFolder}"
PlaceholderText="Zielordner für Sicherungen…" />
</StackPanel>
<Button Grid.Column="1" Content="Durchsuchen…" Click="OnBrowseTargetFolder"
Margin="8,0,0,0" VerticalAlignment="Bottom" />
</Grid>
<!-- Inhalt Optionen -->
<CheckBox Content="Protokolle (Logs) mit einschließen" IsChecked="{Binding IncludeLogs}" />
<CheckBox Content="Chatverläufe mit einschließen" IsChecked="{Binding IncludeChatHistory}" />
<Button Content="▶ Sicherung jetzt erstellen" Command="{Binding CreateBackupCommand}"
IsEnabled="{Binding !IsBusy}" Classes="accent" HorizontalAlignment="Left" Margin="0,8,0,0" />
</StackPanel>
</Border>
<!-- Card: Automatische Sicherung -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Automatische Sicherung" Classes="heading" />
<CheckBox Content="Automatische tägliche Sicherung aktivieren" IsChecked="{Binding AutoBackupEnabled}" />
<StackPanel Orientation="Horizontal" Spacing="16" IsEnabled="{Binding AutoBackupEnabled}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Uhrzeit:" VerticalAlignment="Center" />
<TextBox Text="{Binding AutoBackupTime}" Width="80" PlaceholderText="03:00" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Max. Anzahl behalten:" VerticalAlignment="Center" />
<NumericUpDown Value="{Binding KeepCount}" Minimum="1" Maximum="999" Width="120" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="Zugangsdaten" />
<StackPanel Orientation="Horizontal" Spacing="18">
<RadioButton GroupName="secrets" Content="nicht mitsichern"
IsChecked="{Binding SecretsExclude}" />
<RadioButton GroupName="secrets" Content="mit Passphrase schützen"
IsChecked="{Binding SecretsPassphrase}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Vorhandene Sicherungen List -->
<Border Classes="card">
<DockPanel Height="260">
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<TextBlock Text="Vorhandene Sicherungen" Classes="heading" VerticalAlignment="Center" />
<Button Content="Aktualisieren" Command="{Binding RefreshBackupListCommand}" Margin="12,0,0,0" />
<Button Content="Ordner öffnen" Command="{Binding ShowSelectedInFolderCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Löschen" Command="{Binding DeleteSelectedCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Wiederherstellen..." Command="{Binding RestoreSelectedCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" Classes="accent" />
<StackPanel Spacing="8" Margin="24,0,0,0" IsVisible="{Binding SecretsPassphrase}">
<TextBox Text="{Binding Passphrase}" PasswordChar="•" Width="320"
HorizontalAlignment="Left" PlaceholderText="Passphrase eingeben" />
<TextBox Text="{Binding PassphraseRepeat}" PasswordChar="•" Width="320"
HorizontalAlignment="Left" PlaceholderText="Passphrase wiederholen" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="Umfang" />
<StackPanel Orientation="Horizontal" Spacing="18">
<CheckBox Content="Chatverläufe" IsChecked="{Binding IncludeChatHistory}" />
<CheckBox Content="Protokolle" IsChecked="{Binding IncludeLogs}" />
</StackPanel>
</StackPanel>
<Button Classes="primary" HorizontalAlignment="Left" Margin="0,4,0,0"
Command="{Binding CreateBackupCommand}" IsEnabled="{Binding !IsBusy}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="Sicherung jetzt erstellen" />
</StackPanel>
</Button>
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Automatik ══════════════════ -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="AUTOMATIK" />
<TextBlock Classes="h2" Text="Automatische Sicherung" />
<CheckBox Content="täglich sichern um" IsChecked="{Binding AutoBackupEnabled}" />
<StackPanel Orientation="Horizontal" Spacing="24" Margin="24,0,0,0"
IsEnabled="{Binding AutoBackupEnabled}">
<StackPanel Width="100">
<TextBlock Classes="label" Text="Uhrzeit" />
<TextBox Text="{Binding AutoBackupTime}" PlaceholderText="03:00" />
</StackPanel>
<DataGrid ItemsSource="{Binding Backups}" SelectedItem="{Binding SelectedBackup}" AutoGenerateColumns="False" IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Dateiname" Binding="{Binding FileName}" Width="220" />
<DataGridTextColumn Header="Erstellt am" Binding="{Binding LastWriteTime, StringFormat='{}{0:g}'}" Width="150" />
<DataGridTextColumn Header="Instanz" Binding="{Binding InstanceName}" Width="130" />
<DataGridTextColumn Header="Größe" Binding="{Binding SizeFormatted}" Width="100" />
<DataGridTextColumn Header="Zugangsdaten" Binding="{Binding SecretsInfo}" Width="*" />
</DataGrid.Columns>
</DataGrid>
<StackPanel Width="160">
<TextBlock Classes="label" Text="Aufbewahren" />
<TextBox Text="{Binding KeepCount}" />
</StackPanel>
</StackPanel>
<TextBlock Classes="caption" Margin="24,0,0,0" TextWrapping="Wrap"
Text="Die Automatik sichert ohne Zugangsdaten — eine gespeicherte Passphrase wäre wirkungslos." />
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Bestand ══════════════════ -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="BESTAND" />
<DockPanel>
<TextBlock Classes="h2" Text="Vorhandene Sicherungen" VerticalAlignment="Center" />
<WrapPanel DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
Margin="0">
<Button Classes="toolbar" Content="Aktualisieren"
Command="{Binding RefreshBackupListCommand}" />
<Button Classes="toolbar" Content="Ordner öffnen"
Command="{Binding ShowSelectedInFolderCommand}"
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="toolbar" Content="Löschen"
Command="{Binding DeleteSelectedCommand}"
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="toolbar primary" Content="Wiederherstellen…"
Command="{Binding RestoreSelectedCommand}"
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
</WrapPanel>
</DockPanel>
</Border>
<Border Classes="thead">
<Grid ColumnDefinitions="2.6*,1.5*,1.3*,0.9*,1.3*">
<TextBlock Grid.Column="0" Classes="th" Text="DATEINAME" />
<TextBlock Grid.Column="1" Classes="th" Text="ERSTELLT AM" />
<TextBlock Grid.Column="2" Classes="th" Text="INSTANZ" />
<TextBlock Grid.Column="3" Classes="th" Text="GRÖSSE" />
<TextBlock Grid.Column="4" Classes="th" Text="ZUGANGSDATEN" />
</Grid>
</Border>
<ListBox Classes="table" MaxHeight="300" Margin="0,-10,0,0"
ItemsSource="{Binding Backups}" SelectedItem="{Binding SelectedBackup}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:BackupItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="2.6*,1.5*,1.3*,0.9*,1.3*">
<TextBlock Grid.Column="0" Classes="td" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Classes="td"
Text="{Binding LastWriteTime, StringFormat='{}{0:dd.MM.yyyy HH:mm}'}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding InstanceName}" />
<TextBlock Grid.Column="3" Classes="td" Text="{Binding SizeFormatted}" />
<TextBlock Grid.Column="4" Classes="td dim" Text="{Binding SecretsInfo}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -3,23 +3,19 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.ChatPageView"
x:DataType="vm:ChatPageViewModel">
<DockPanel>
<DockPanel MaxWidth="960" HorizontalAlignment="Left">
<!-- Statusleiste am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<!-- Top Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<TextBlock Text="Agent:" VerticalAlignment="Center" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}" MinWidth="200">
<!-- Agentenauswahl und Verlaufsbefehle -->
<StackPanel DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<TextBlock Text="Agent:" FontWeight="SemiBold" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}"
Width="220">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
@@ -27,43 +23,67 @@
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Verlauf leeren" Command="{Binding ClearHistoryCommand}" Margin="12,0,0,0" />
<Button Content="Abbrechen" Command="{Binding AbortCommand}" IsEnabled="{Binding IsSending}" />
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding ClearHistoryCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="Verlauf leeren" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding AbortCommand}" IsEnabled="{Binding IsSending}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconStop}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="Abbrechen" />
</StackPanel>
</Button>
</StackPanel>
<!-- Eingabezeile unten -->
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,8,0,0">
<TextBox Text="{Binding InputPrompt}" PlaceholderText="Nachricht eingeben... (Strg+Eingabe zum Senden)"
AcceptsReturn="True" Height="60" TextWrapping="Wrap" Grid.Column="0" KeyDown="OnInputKeyDown" />
<Button Content="Senden" Command="{Binding SendMessageCommand}" IsEnabled="{Binding !IsSending}"
Classes="accent" VerticalAlignment="Stretch" Margin="8,0,0,0" Grid.Column="1" Width="90" />
<!-- Statuszeile des Bereichs -->
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,8,0,0"
Text="{Binding StatusText}" />
<!-- Eingabe -->
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<TextBox Grid.Column="0" Text="{Binding InputPrompt}"
PlaceholderText="Nachricht eingeben… (Strg+Eingabe zum Senden)"
AcceptsReturn="True" Height="60" TextWrapping="Wrap"
KeyDown="OnInputKeyDown" />
<Button Grid.Column="1" Classes="primary" Content="Senden" Width="100"
Command="{Binding SendMessageCommand}" IsEnabled="{Binding !IsSending}"
VerticalAlignment="Stretch" Margin="8,0,0,0" />
</Grid>
<!-- Nachrichtenverlauf (Mitte) -->
<ScrollViewer x:Name="ChatScroller">
<!-- Verlauf -->
<ScrollViewer x:Name="ChatScroller" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding ChatEntries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
<StackPanel Spacing="12" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ChatEntryItemViewModel">
<!-- Distinct styling: User right-aligned bubble vs Assistant left-aligned card -->
<DockPanel HorizontalAlignment="{Binding IsUser, Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
<Border Classes="card" CornerRadius="8" MaxWidth="680"
Background="{Binding IsUser, Converter={x:Static conv:ChatBubbleBrushConverter.Instance}, TargetNullValue={x:Null}}">
<StackPanel Spacing="4">
<DockPanel>
<TextBlock Text="{Binding Sender}" FontWeight="Bold" FontSize="12" />
<TextBlock Text="{Binding Timestamp, StringFormat='{}{0:HH:mm}'}" Classes="caption" HorizontalAlignment="Right" Margin="8,0,0,0" />
</DockPanel>
<!-- Eigene Nachrichten rechts und in Akzent hinterlegt, Antworten links
als schlichter Kasten — wie im Entwurf. -->
<Border Classes="card" MaxWidth="660"
Classes.accent="{Binding IsUser}"
HorizontalAlignment="{Binding IsUser,
Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
<StackPanel Spacing="4">
<DockPanel>
<TextBlock Text="{Binding Sender}" FontWeight="SemiBold" FontSize="12" />
<TextBlock Classes="caption" DockPanel.Dock="Right" HorizontalAlignment="Right"
Margin="8,0,0,0"
Text="{Binding Timestamp, StringFormat='{}{0:HH:mm}'}" />
</DockPanel>
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="13" />
</StackPanel>
</Border>
</DockPanel>
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="13.5" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -3,67 +3,111 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.InfoPageView"
x:DataType="vm:InfoPageViewModel">
<ScrollViewer Padding="16">
<StackPanel Spacing="16">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<!-- Etwas Luft nach aussen: Die Eckmarken des BlueprintFrame ragen ueber den
Rahmen hinaus und wuerden sonst am Rand des Bereichs abgeschnitten. -->
<StackPanel Spacing="26" Margin="8" MaxWidth="820" HorizontalAlignment="Left">
<!-- Titel -->
<TextBlock Text="Software- &amp; Instanz-Informationen" Classes="heading" FontSize="20" />
<!-- Die einzige Karte des Entwurfs mit Eckmarken -->
<ctrl:BlueprintFrame MaxWidth="420" HorizontalAlignment="Left">
<StackPanel Spacing="6">
<TextBlock Classes="h2" Text="ClawdDotNet" />
<TextBlock Classes="muted" FontSize="13">
<Run Text="Version" /><Run Text=" " /><Run Text="{Binding AppVersion}" /><Run Text=" · Avalonia UI · net10.0" />
</TextBlock>
</StackPanel>
</ctrl:BlueprintFrame>
<!-- Anwendungs-Details Card -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Anwendung" Classes="heading" />
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="140,*">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version:" FontWeight="Bold" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" />
<!-- Anwendung -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="ANWENDUNG" />
<TextBlock Classes="h2" Text="Build" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Build-Summary:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding BuildSummary}" Margin="0,4,0,0" TextWrapping="Wrap" />
<Grid ColumnDefinitions="170,*" RowDefinitions="Auto,Auto" >
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="Version" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="Build-Zusammenfassung"
Margin="0,6,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding BuildSummary}"
TextWrapping="Wrap" Margin="0,6,0,0" />
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- Instanz -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="INSTANZ" />
<DockPanel>
<TextBlock Classes="h2" Text="Aktuelle Instanz" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
Command="{Binding OpenInstanceFolderCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
<TextBlock Text="Instanz-Ordner öffnen" />
</StackPanel>
</Button>
</DockPanel>
<Grid ColumnDefinitions="170,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="Name" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="ID" Margin="0,6,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Classes="mono" Text="{Binding InstanceId}"
Margin="0,6,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="label" Text="Pfad" Margin="0,6,0,0" />
<SelectableTextBlock Grid.Row="2" Grid.Column="1" Text="{Binding InstancePath}"
TextWrapping="Wrap" Margin="0,6,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="label" Text="Log-Ordner" Margin="0,6,0,0" />
<SelectableTextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LogDirectory}"
TextWrapping="Wrap" Margin="0,6,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Classes="label" Text="OpenRouter" Margin="0,6,0,0" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding OpenRouterStatus}" Margin="0,6,0,0" />
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- Baugruppen -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="MODULE" />
<TextBlock Classes="h2" Text="Baugruppen" />
<Border Classes="thead">
<Grid ColumnDefinitions="1.6*,0.6*,1.4*,3*">
<TextBlock Grid.Column="0" Classes="th" Text="MODUL" />
<TextBlock Grid.Column="1" Classes="th" Text="BUILD" />
<TextBlock Grid.Column="2" Classes="th" Text="BUILD-DATUM" />
<TextBlock Grid.Column="3" Classes="th" Text="ÄNDERUNGEN" />
</Grid>
</StackPanel>
</Border>
</Border>
<!-- Instanz-Details Card -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Aktuelle Instanz" Classes="heading" />
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="140,*">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name:" FontWeight="Bold" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="ID:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding InstanceId}" Margin="0,4,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Pfad:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding InstancePath}" Margin="0,4,0,0" TextWrapping="Wrap" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Log-Ordner:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LogDirectory}" Margin="0,4,0,0" TextWrapping="Wrap" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="OpenRouter Status:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding OpenRouterStatus}" Margin="0,4,0,0" />
</Grid>
</StackPanel>
</Border>
<!-- Assembly Build Info Card -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Module &amp; Baugruppen" Classes="heading" />
<DataGrid ItemsSource="{Binding Assemblies}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Modul" Binding="{Binding ShortName}" Width="160" />
<DataGridTextColumn Header="Build #" Binding="{Binding BuildNumber}" Width="80" />
<DataGridTextColumn Header="Build-Datum" Binding="{Binding BuildDate}" Width="160" />
<DataGridTextColumn Header="Änderungen" Binding="{Binding Changes}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<ItemsControl ItemsSource="{Binding Assemblies}" Margin="0,-10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AssemblyInfoItem">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.6*,0.6*,1.4*,3*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding ShortName}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding BuildNumber}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding BuildDate}" />
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding Changes}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -3,71 +3,121 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:models="using:ClawdDotNet.App.Models"
mc:Ignorable="d" d:DesignWidth="720" d:DesignHeight="460"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="560"
x:Class="ClawdDotNet.Desktop.Views.InstancePickerWindow"
x:DataType="vm:InstancePickerViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet Instanz wählen"
Width="720" Height="460"
Title="ClawdDotNet Instanz"
Width="640" Height="560"
MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterScreen">
<DockPanel Margin="16">
<!-- Rahmen mit Eckmarken wie im Entwurf; 12 Punkte Abstand, weil die Marken nach
aussen ragen. -->
<ctrl:BlueprintFrame Margin="12" Padding="16">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Classes="heading" Text="Instanz wählen" />
<TextBlock DockPanel.Dock="Top" Classes="caption" Margin="0,0,0,12"
TextWrapping="Wrap"
Text="{Binding InstancesDirectory, StringFormat='Verzeichnis: {0}'}" />
<!-- Fehlerzeile: nimmt keinen Platz weg, solange nichts anliegt -->
<TextBlock DockPanel.Dock="Bottom"
Margin="0,8,0,0"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{Binding ErrorText}"
IsVisible="{Binding ErrorText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Aktionen -->
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto,Auto" Margin="0,12,0,0">
<StackPanel Orientation="Horizontal" Spacing="6" Grid.Column="0">
<TextBox Width="200"
Watermark="Name der neuen Instanz"
Text="{Binding NewInstanceName}" />
<Button Content="Anlegen" Command="{Binding CreateCommand}" />
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,14">
<TextBlock Classes="h1" Text="{Binding Title}" />
<TextBlock Classes="muted" FontSize="13" Text="{Binding Hint}" TextWrapping="Wrap" />
<TextBlock Classes="caption" TextWrapping="Wrap"
Text="{Binding InstancesDirectory, StringFormat='Verzeichnis: {0}'}" />
</StackPanel>
<Button Grid.Column="1" Content="Abbrechen" Margin="0,0,6,0"
Command="{Binding CancelCommand}" />
<!-- Fehlerzeile: nimmt keinen Platz weg, solange nichts anliegt -->
<TextBlock DockPanel.Dock="Bottom" Margin="0,10,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource DangerBrush}"
Text="{Binding ErrorText}"
IsVisible="{Binding ErrorText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<Button Grid.Column="2" Content="Starten"
Classes="accent"
IsDefault="True"
Command="{Binding StartCommand}" />
</Grid>
<!-- Neue Instanz anlegen -->
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,14,0,0">
<Border Classes="hr" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding NewInstanceName}"
PlaceholderText="Name der neuen Instanz" />
<Button Grid.Column="1" Classes="primary" Margin="8,0,0,0"
Command="{Binding CreateCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="Anlegen" />
</StackPanel>
</Button>
</Grid>
</StackPanel>
<!-- Liste -->
<DataGrid ItemsSource="{Binding Instances}"
SelectedItem="{Binding Selected}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
GridLinesVisibility="Horizontal">
<!-- Abschluss -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" IsCancel="True" />
<Button Classes="primary" Content="{Binding ConfirmLabel}" IsDefault="True"
Command="{Binding StartCommand}" />
</StackPanel>
<DataGrid.Columns>
<DataGridTextColumn Header="Instanzname" Width="2*"
Binding="{Binding InstanceName, x:DataType=models:InstanceInfo}" />
<DataGridTextColumn Header="Ordner" Width="2*"
Binding="{Binding FolderName, x:DataType=models:InstanceInfo}" />
<DataGridTextColumn Header="Agenten" Width="Auto"
Binding="{Binding AgentCount, x:DataType=models:InstanceInfo}" />
<DataGridTextColumn Header="API-Key" Width="Auto"
Binding="{Binding ApiKeyStatus, x:DataType=models:InstanceInfo}" />
</DataGrid.Columns>
<!-- Liste -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="InstanceList" ItemsSource="{Binding Instances}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</DataGrid>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:InstanceChoiceViewModel">
<Border Classes="card" Padding="8,8">
<DockPanel>
</DockPanel>
<!-- Monogramm mit Statuspunkt -->
<Panel DockPanel.Dock="Left" Width="30" Height="30" Margin="0,0,10,0"
VerticalAlignment="Center">
<Border Width="26" Height="26" Background="{DynamicResource Accent600Brush}"
HorizontalAlignment="Left" VerticalAlignment="Top">
<TextBlock Classes="h2" FontSize="10" Text="{Binding Monogram}"
Foreground="{DynamicResource Neutral100Brush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Ellipse Width="10" Height="10" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Stroke="{DynamicResource SurfaceBrush}" StrokeThickness="2"
Fill="{DynamicResource SuccessBrush}"
IsVisible="{Binding IsReady}"
ToolTip.Tip="API-Key hinterlegt" />
<Ellipse Width="10" Height="10" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Stroke="{DynamicResource SurfaceBrush}" StrokeThickness="2"
Fill="{DynamicResource Neutral500Brush}"
IsVisible="{Binding !IsReady}"
ToolTip.Tip="Kein API-Key — Agenten bleiben deaktiviert" />
</Panel>
<Button DockPanel.Dock="Right" Classes="toolbar" VerticalAlignment="Center"
Content="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).ConfirmLabel}"
Command="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).ActivateCommand}"
CommandParameter="{Binding}" />
<Border DockPanel.Dock="Right" Classes="tag ok" Margin="0,0,8,0"
IsVisible="{Binding IsCurrent}">
<TextBlock Text="Aktiv" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Classes="h2" FontSize="13.5" Text="{Binding InstanceName}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="caption" Text="{Binding Summary}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</ctrl:BlueprintFrame>
</Window>
@@ -10,15 +10,21 @@ public partial class InstancePickerWindow : Window
{
InitializeComponent();
// Doppelklick auf eine Zeile startet — wie in der WinForms-Fassung. Das laesst
// sich nicht sinnvoll als Befehl binden, weil DataGrid kein DoubleTapped je
// Zeile anbietet; deshalb hier und nicht im Ansichtsmodell.
AddHandler(DoubleTappedEvent, OnDoubleTapped, handledEventsToo: false);
// Doppelklick auf eine Zeile bestaetigt — wie in der WinForms-Fassung.
//
// Der Behandler haengt an der Liste und nicht mehr am Fenster: Am Fenster loeste
// auch ein Doppelklick ins Namensfeld die Auswahl aus, was seit dem Umbau
// deutlich leichter passiert, weil das Feld gross und mittig steht.
InstanceList.AddHandler(DoubleTappedEvent, OnRowDoubleTapped, handledEventsToo: false);
}
private void OnDoubleTapped(object? sender, TappedEventArgs e)
private void OnRowDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is InstancePickerViewModel vm && vm.StartCommand.CanExecute(null))
vm.StartCommand.Execute(null);
if (DataContext is not InstancePickerViewModel vm) return;
// Getroffen wird irgendein Element in der Zeile; der Datenkontext ist derselbe.
if ((e.Source as Control)?.DataContext is not InstanceChoiceViewModel choice) return;
vm.ActivateCommand.Execute(choice);
}
}
@@ -23,7 +23,7 @@
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
</Border>
<TextBox Watermark="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
<TextBox PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
Text="{Binding Key}"
FontFamily="{DynamicResource ContentControlThemeFontFamily}" />
+64 -35
View File
@@ -4,33 +4,50 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:svc="using:ClawdDotNet.App.Services"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.LogPageView"
x:DataType="vm:LogPageViewModel">
<DockPanel>
<!-- Werkzeugleiste -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<StackPanel DockPanel.Dock="Top" Classes="toolbar">
<TextBlock Text="Modul:" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding Modules}"
SelectedItem="{Binding SelectedModule}"
MinWidth="180" />
<TextBlock Text="Modul:" FontSize="12.5" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding Modules}" SelectedItem="{Binding SelectedModule}"
Width="180" MinHeight="30" Padding="8,3" />
<TextBlock Text="Stufe:" VerticalAlignment="Center" Margin="12,0,0,0" />
<ComboBox ItemsSource="{Binding Levels}"
SelectedItem="{Binding SelectedLevel}"
MinWidth="100" />
<TextBlock Text="Stufe:" FontSize="12.5" VerticalAlignment="Center" Margin="6,0,0,0" />
<ComboBox ItemsSource="{Binding Levels}" SelectedItem="{Binding SelectedLevel}"
Width="120" MinHeight="30" Padding="8,3" />
<Button Content="Aktualisieren" Command="{Binding RefreshModulesCommand}" Margin="12,0,0,0" />
<Button Content="Leeren" Command="{Binding ClearCommand}" />
<Button Content="Ordner öffnen" Command="{Binding OpenFolderCommand}" />
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding RefreshModulesCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="Aktualisieren" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding ClearCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="Leeren" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding OpenFolderCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
<TextBlock Text="Ordner öffnen" />
</StackPanel>
</Button>
<CheckBox Content="Mitlaufen" IsChecked="{Binding FollowTail}"
VerticalAlignment="Center" Margin="12,0,0,0" />
VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel>
<!--
@@ -38,29 +55,41 @@
einzelne Zeile herauskopieren, und die Virtualisierung des ItemsControl haelt auch
bei 2000 Zeilen die Darstellung fluessig.
-->
<ScrollViewer Name="Scroller" Padding="8">
<ItemsControl ItemsSource="{Binding Lines}">
<Border Classes="console">
<ScrollViewer Name="Scroller" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Lines}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svc:LogLine">
<SelectableTextBlock Text="{Binding Text}"
FontFamily="Consolas, Menlo, DejaVu Sans Mono, monospace"
FontSize="12"
TextWrapping="NoWrap"
Foreground="{Binding Level,
Converter={x:Static conv:LogLevelBrushConverter.Instance},
TargetNullValue={x:Null}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svc:LogLine">
<!-- Nur Warnung und Fehler werden hervorgehoben. Alles einzufärben macht
die Ansicht unruhig und lenkt von genau den Zeilen ab, die auffallen
sollen; der Rest behält die Vordergrundfarbe des Themas. -->
<SelectableTextBlock Classes="mono"
Classes.warn="{Binding Level,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=WRN}"
Classes.err="{Binding Level,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=ERR}"
Classes.ftl="{Binding Level,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=FTL}"
Text="{Binding Text}"
LineHeight="21"
TextWrapping="NoWrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
+202 -48
View File
@@ -3,70 +3,224 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1440" d:DesignHeight="860"
x:Class="ClawdDotNet.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet"
Width="1100" Height="700"
MinWidth="800" MinHeight="500">
Width="1440" Height="860"
MinWidth="960" MinHeight="600">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<DockPanel>
<Grid ColumnDefinitions="Auto,*">
<!-- Haupt-Menüleiste -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="_Datei">
<MenuItem Header="_Beenden" Command="{Binding ExitCommand}" InputGesture="Alt+F4" />
</MenuItem>
<MenuItem Header="_Bearbeiten">
<MenuItem Header="_Instanz-Ordner öffnen" Command="{Binding OpenInstanceFolderCommand}" />
<MenuItem Header="_Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
</MenuItem>
<MenuItem Header="_Instanz">
<MenuItem Header="_Instanz-Manager..." Command="{Binding OpenInstanceManagerCommand}" />
</MenuItem>
<MenuItem Header="_Ansicht">
<MenuItem Header="_Chat" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Chat" />
<MenuItem Header="_Logs" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Logs" />
<MenuItem Header="_Agenten" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Agenten" />
<MenuItem Header="_Einstellungen" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Einstellungen" />
<MenuItem Header="_Aufgaben" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Aufgaben" />
<MenuItem Header="_Sicherung" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Sicherung" />
<MenuItem Header="_Info" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Info" />
</MenuItem>
<MenuItem Header="_Hilfe">
<MenuItem Header="_Info" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Info" />
</MenuItem>
</Menu>
<!-- ══════════════════════════ Seitenleiste ══════════════════════════ -->
<Border Grid.Column="0" Classes="sidebar" Width="{Binding SidebarWidth}">
<DockPanel>
<!-- Statusleiste -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Classes="caption" Text="{Binding StatusText}" />
<!-- Marke und Einklapp-Schalter -->
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,0,1" Padding="14">
<DockPanel>
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="20" Height="20"
Foreground="{DynamicResource AccentBrush}"
DockPanel.Dock="Left" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right" Classes="flat"
Command="{Binding ToggleSidebarCommand}"
ToolTip.Tip="Seitenleiste ein-/ausklappen"
Padding="4" VerticalAlignment="Center">
<ctrl:StrokeIcon Width="14" Height="14"
Data="{Binding IsSidebarCollapsed,
Converter={x:Static conv:SidebarChevronConverter.Instance}}" />
</Button>
<TextBlock Classes="h2" Text="ClawdDotNet" Margin="8,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding IsSidebarExpanded}" />
</DockPanel>
</Border>
<!-- Fußzeile: Version -->
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="16,10"
Text="{Binding VersionText}"
IsVisible="{Binding IsSidebarExpanded}" />
<!-- Instanz-Schalter: der einzige Knopf mit Akzentrahmen, weil er als
einziger die ganze Anwendung umschaltet. -->
<Button DockPanel.Dock="Top" Classes="instance" Margin="12,12,12,0"
Command="{Binding OpenInstanceManagerCommand}"
ToolTip.Tip="Instanz wechseln">
<DockPanel>
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="15" Height="15"
DockPanel.Dock="Left" VerticalAlignment="Center" />
<ctrl:StrokeIcon Data="{StaticResource IconChevronDown}" Width="14" Height="14"
DockPanel.Dock="Right" VerticalAlignment="Center"
IsVisible="{Binding IsSidebarExpanded}" />
<StackPanel Margin="8,0,0,0" IsVisible="{Binding IsSidebarExpanded}">
<TextBlock Text="INSTANZ" FontSize="9" LetterSpacing="1"
Foreground="{DynamicResource AccentBrush}" />
<TextBlock Classes="h2" FontSize="13" Text="{Binding InstanceName}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
</Button>
<!-- Navigation -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="10,14">
<ItemsControl ItemsSource="{Binding NavGroups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="18" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:NavGroupViewModel">
<StackPanel Spacing="2">
<TextBlock Classes="navgroup" Text="{Binding Title}"
IsVisible="{Binding IsTitleVisible}" />
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:NavItemViewModel">
<Button Classes="nav"
Classes.selected="{Binding IsSelected}"
Classes.collapsed="{Binding IsCollapsed}"
Command="{Binding SelectCommand}"
ToolTip.Tip="{Binding Title}">
<StackPanel Orientation="Horizontal" Spacing="10">
<ctrl:StrokeIcon Width="17" Height="17"
VerticalAlignment="Center"
Data="{Binding IconKey,
Converter={x:Static conv:IconConverter.Instance}}" />
<TextBlock Text="{Binding Title}" VerticalAlignment="Center"
IsVisible="{Binding !IsCollapsed}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- Bereiche (Seitenleiste links) -->
<TabControl ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage}"
TabStripPlacement="Left">
<!-- ══════════════════════════ Hauptbereich ══════════════════════════ -->
<DockPanel Grid.Column="1">
<TabControl.ItemTemplate>
<DataTemplate DataType="vm:PageViewModel">
<TextBlock Text="{Binding Title}" Margin="4,8" />
</DataTemplate>
</TabControl.ItemTemplate>
<!-- Kopfzeile -->
<Border DockPanel.Dock="Top" Classes="topbar">
<DockPanel>
<TextBlock Classes="h1" Text="{Binding PageTitle}" VerticalAlignment="Center" />
<TabControl.ContentTemplate>
<DataTemplate DataType="vm:PageViewModel">
<ContentControl Content="{Binding}" />
</DataTemplate>
</TabControl.ContentTemplate>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="10"
HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Classes="caption" Text="{Binding ThemeLabel}" VerticalAlignment="Center" />
<Button Classes="icon" Command="{Binding ToggleThemeCommand}"
ToolTip.Tip="Dunkelmodus umschalten">
<ctrl:StrokeIcon Width="16" Height="16"
Data="{Binding ThemeIconKey,
Converter={x:Static conv:IconConverter.Instance}}" />
</Button>
</StackPanel>
</DockPanel>
</Border>
</TabControl>
<!-- Benachrichtigungsleiste: nimmt keinen Platz, solange nichts anliegt -->
<ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding Notifications}"
IsVisible="{Binding HasNotifications}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:NotificationViewModel">
<Panel>
<!-- Zwei Grundflächen statt eines umgerechneten Pinsels: So bleiben die
Farben an DynamicResource gebunden und wechseln mit dem Thema mit. -->
<Border Background="{DynamicResource DangerBarBrush}"
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="3,0,0,0"
IsVisible="{Binding IsError}" />
<Border Background="{DynamicResource WarningSoftBrush}"
BorderBrush="{DynamicResource WarningBrush}" BorderThickness="3,0,0,0"
IsVisible="{Binding IsWarning}" />
</DockPanel>
<DockPanel Margin="24,8">
<ctrl:StrokeIcon Data="{StaticResource IconAlert}" Width="15" Height="15"
DockPanel.Dock="Left" VerticalAlignment="Center"
Margin="0,0,10,0" />
<Button DockPanel.Dock="Right" Classes="flat" Padding="2"
Command="{Binding DismissCommand}" ToolTip.Tip="Ausblenden"
VerticalAlignment="Center">
<ctrl:StrokeIcon Data="{StaticResource IconClose}" Width="13" Height="13" />
</Button>
<TextBlock FontSize="12.5" VerticalAlignment="Center" TextWrapping="Wrap">
<Run Text="{Binding InstanceName}" FontWeight="SemiBold" /><Run Text=": " /><Run Text="{Binding Text}" />
</TextBlock>
</DockPanel>
</Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Statusleiste -->
<Border DockPanel.Dock="Bottom" Classes="statusbar">
<DockPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="caption" Text="{Binding InstanceName, StringFormat='Instanz: {0}'}" />
<TextBlock Classes="caption" Text="·" />
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{DynamicResource SuccessBrush}"
IsVisible="{Binding IsApiReachable}" />
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{DynamicResource DangerBrush}"
IsVisible="{Binding !IsApiReachable}" />
<TextBlock Classes="caption" Text="{Binding ApiStatusText, StringFormat='API {0}'}" />
</StackPanel>
<TextBlock Classes="caption" Text="·"
IsVisible="{Binding UsageText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="caption" Text="{Binding UsageText}"
ToolTip.Tip="{Binding UsageTooltip}"
IsVisible="{Binding UsageText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Der Trenner gehoert zum Abschnitt dahinter: Ohne Guthabenangabe stuende
sonst ein Punkt am Ende der Zeile, der nichts mehr trennt. -->
<TextBlock Classes="caption" Text="·"
IsVisible="{Binding CreditsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="caption" Text="{Binding CreditsText}"
IsVisible="{Binding CreditsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock DockPanel.Dock="Right" Classes="caption" HorizontalAlignment="Right"
Text="{Binding ThemeLabel}" />
</DockPanel>
</Border>
<!-- Inhalt -->
<ContentControl Content="{Binding SelectedPage}" Margin="20" />
</DockPanel>
</Grid>
</Window>
@@ -13,16 +13,12 @@ public partial class MainWindow : Window
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel vm)
{
vm.RequestExit += Close;
vm.RequestInstanceManagerDialog += OnOpenInstanceManager;
}
}
private async void OnOpenInstanceManager()
{
var dialog = new InstancePickerWindow();
await dialog.ShowDialog(this);
// Der Instanzwechsel haengt nicht mehr hier, sondern in App: Er braucht den
// Aufbau und die Lebensdauer der Anwendung, und beides kennt das Fenster nicht.
// Vorher wurde hier ein InstancePickerWindow ganz ohne DataContext geoeffnet —
// ein leerer Dialog, dessen Auswahl niemand ausgewertet hat.
if (DataContext is MainWindowViewModel vm)
vm.RequestExit += Close;
}
}
@@ -3,94 +3,128 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="740"
x:Class="ClawdDotNet.Desktop.Views.SettingsPageView"
x:DataType="vm:SettingsPageViewModel">
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Verwerfen / Zurücksetzen" Command="{Binding LoadFromHostCommand}" />
<Button Content="Speichern" Command="{Binding SaveSettingsCommand}" Classes="accent" />
<!-- Aktionen unten: Der Entwurf setzt flache Felder ohne Karten, damit bleibt am
unteren Rand nur die Zeile mit Verwerfen und Speichern. -->
<Border DockPanel.Dock="Bottom" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1,0,0" Padding="0,12,0,0" Margin="0,16,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="Verwerfen" Command="{Binding LoadFromHostCommand}" />
<Button Classes="primary" Command="{Binding SaveSettingsCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
<TextBlock Text="Speichern" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<ScrollViewer>
<StackPanel Spacing="16">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="26" MaxWidth="680" HorizontalAlignment="Left">
<TextBlock Text="Anwendungs- &amp; Instanz-Einstellungen" Classes="heading" FontSize="20" />
<!-- ══════════════════ Instanz ══════════════════ -->
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="INSTANZ" />
<!-- Card: Instanz-Einstellungen -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Aktuelle Instanz" Classes="heading" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Instanz-Name:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="OpenRouter API-Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding OpenRouterApiKey}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Tagesbudget ($ USD):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding InstanceDailyCostUsd}" Minimum="0" Maximum="10000" FormatString="F2" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tagesbudget (Tokens):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding InstanceDailyTokens}" Minimum="0" Maximum="100000000" FormatString="0" Width="180" HorizontalAlignment="Left" Margin="0,8,0,0" />
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="Instanz-Name" />
<TextBox Text="{Binding InstanceName}" />
</StackPanel>
</Border>
<!-- Card: Allgemein & Pfade -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Allgemein &amp; Pfade" Classes="heading" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Log-Verzeichnis:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding LogDirectory}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Instanzen-Verzeichnis:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding InstancesDirectory}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Minimaler Log-Level:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding LogLevels}" SelectedItem="{Binding MinimumLogLevel}" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="OpenRouter Base-URL:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding OpenRouterBaseUrl}" Margin="0,8,0,0" />
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="OpenRouter API-Key" />
<TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" />
</StackPanel>
</Border>
<!-- Card: Deploymentcenter & Lizenz -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Deploymentcenter &amp; Lizenz" Classes="heading" />
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Tagesbudget ($ USD)" />
<TextBox Text="{Binding InstanceDailyCostUsd}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Tagesbudget (Tokens)" />
<TextBox Text="{Binding InstanceDailyTokens}" />
</StackPanel>
</Grid>
</StackPanel>
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Server-URL:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding DeploymentcenterUrl}" />
<Border Classes="hr" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Master-Token:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DeploymentcenterToken}" PasswordChar="*" Margin="0,8,0,0" />
<!-- ══════════════════ Allgemein ══════════════════ -->
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="ALLGEMEIN" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Update-Kanal:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding UpdateChannels}" SelectedItem="{Binding UpdateChannel}" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Umgebung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="3" Grid.Column="1" ItemsSource="{Binding Environments}" SelectedItem="{Binding DeploymentcenterEnvironment}" Width="160" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Lizenzschlüssel:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding LicenseKey}" PasswordChar="*" Margin="0,8,0,0" />
</Grid>
<CheckBox Content="Fehler automatisch an Deploymentcenter melden" IsChecked="{Binding ErrorReportingEnabled}" Margin="0,4,0,0" />
<CheckBox Content="Beim Start automatisch auf Updates prüfen" IsChecked="{Binding UpdateCheckEnabled}" />
<StackPanel>
<TextBlock Classes="label" Text="Log-Verzeichnis" />
<TextBox Text="{Binding LogDirectory}" />
</StackPanel>
</Border>
<StackPanel>
<TextBlock Classes="label" Text="Instanzen-Verzeichnis" />
<TextBox Text="{Binding InstancesDirectory}" />
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Minimaler Log-Level" />
<ComboBox ItemsSource="{Binding LogLevels}" SelectedItem="{Binding MinimumLogLevel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="OpenRouter Base-URL" />
<TextBox Text="{Binding OpenRouterBaseUrl}" />
</StackPanel>
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Deploymentcenter ══════════════════ -->
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="DEPLOYMENTCENTER &amp; LIZENZ" />
<StackPanel>
<TextBlock Classes="label" Text="Server-URL" />
<TextBox Text="{Binding DeploymentcenterUrl}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Master-Token" />
<TextBox Text="{Binding DeploymentcenterToken}" PasswordChar="•" />
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Update-Kanal" />
<ComboBox ItemsSource="{Binding UpdateChannels}" SelectedItem="{Binding UpdateChannel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Umgebung" />
<ComboBox ItemsSource="{Binding Environments}"
SelectedItem="{Binding DeploymentcenterEnvironment}"
HorizontalAlignment="Stretch" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="Lizenzschlüssel" />
<TextBox Text="{Binding LicenseKey}" PasswordChar="•" />
</StackPanel>
<StackPanel Spacing="10" Margin="0,4,0,0">
<CheckBox Content="Fehler automatisch an Deploymentcenter melden"
IsChecked="{Binding ErrorReportingEnabled}" />
<CheckBox Content="Beim Start automatisch auf Updates prüfen"
IsChecked="{Binding UpdateCheckEnabled}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
+229 -74
View File
@@ -3,100 +3,255 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
xmlns:models="using:ClawdDotNet.App.Models"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="740"
x:Class="ClawdDotNet.Desktop.Views.TasksPageView"
x:DataType="vm:TasksPageViewModel">
<DockPanel>
<!-- Statusbar unten -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<!-- Segmentleiste statt Registerkarten. Die Knöpfe überlappen sich um einen
Bildpunkt, damit zwischen ihnen nur eine Linie steht und nicht zwei. -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
HorizontalAlignment="Left" Margin="0,0,0,14">
<ToggleButton Content="Geplante Jobs" Margin="0,0,-1,0"
IsChecked="{Binding IsJobsTab, Mode=OneWay}"
Command="{Binding SelectTabCommand}" CommandParameter="Jobs" />
<ToggleButton Content="Dienste" Margin="0,0,-1,0"
IsChecked="{Binding IsServicesTab, Mode=OneWay}"
Command="{Binding SelectTabCommand}" CommandParameter="Services" />
<ToggleButton Content="Verlauf"
IsChecked="{Binding IsHistoryTab, Mode=OneWay}"
Command="{Binding SelectTabCommand}" CommandParameter="History" />
</StackPanel>
<TabControl>
<!-- Statuszeile des Bereichs -->
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
Text="{Binding StatusText}" TextWrapping="Wrap" />
<!-- TAB 1: Jobs (Scheduled / Worker) -->
<TabItem Header="Geplante Jobs">
<DockPanel Margin="0,8,0,0">
<Panel>
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Job hinzufügen" Command="{Binding AddJobCommand}" Classes="accent" />
<Button Content="Bearbeiten" Command="{Binding EditJobCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Entfernen" Command="{Binding RemoveJobCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="▶ Jetzt ausführen" Command="{Binding RunJobNowCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" Margin="12,0,0,0" />
<Button Content="Aktualisieren" Command="{Binding RefreshJobListCommand}" />
</StackPanel>
<!-- ══════════════════ Geplante Jobs ══════════════════ -->
<DockPanel IsVisible="{Binding IsJobsTab}">
<!-- Jobs DataGrid -->
<DataGrid ItemsSource="{Binding JobEntries}" SelectedItem="{Binding SelectedJob}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Typ" Binding="{Binding JobType}" Width="120" />
<DataGridTextColumn Header="Agent" Binding="{Binding AgentName}" Width="150" />
<DataGridTextColumn Header="Tool / Nachricht" Binding="{Binding TaskMessage}" Width="*" />
<DataGridTextColumn Header="Cron-Ausdruck" Binding="{Binding CronExpression}" Width="120" />
<DataGridTextColumn Header="Nächster Lauf" Binding="{Binding NextRun}" Width="140" />
<DataGridTextColumn Header="Letzter Lauf" Binding="{Binding LastRun}" Width="140" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="100" />
</DataGrid.Columns>
</DataGrid>
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar primary" Command="{Binding AddJobCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="Job hinzufügen" />
</StackPanel>
</Button>
</DockPanel>
</TabItem>
<Button Classes="toolbar" Command="{Binding EditJobCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
<TextBlock Text="Bearbeiten" />
</StackPanel>
</Button>
<!-- TAB 2: Dienste (Services) -->
<TabItem Header="Dienste (Services)">
<DockPanel Margin="0,8,0,0">
<Button Classes="toolbar" Command="{Binding RemoveJobCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="Entfernen" />
</StackPanel>
</Button>
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Dienst hinzufügen" Command="{Binding AddServiceCommand}" Classes="accent" />
<Button Content="Entfernen" Command="{Binding RemoveServiceCommand}" IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Aktualisieren" Command="{Binding RefreshServiceListCommand}" />
</StackPanel>
<Button Classes="toolbar" Command="{Binding ToggleJobEnabledCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPower}" Width="14" Height="14" />
<TextBlock Text="Aktivieren/Deaktivieren" />
</StackPanel>
</Button>
<!-- Services DataGrid -->
<DataGrid ItemsSource="{Binding ServiceEntries}" SelectedItem="{Binding SelectedService}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Dienstname" Binding="{Binding Name}" Width="180" />
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="100" />
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="80" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="110" />
<DataGridTextColumn Header="Beschreibung" Binding="{Binding Description}" Width="*" />
</DataGrid.Columns>
</DataGrid>
<Border Classes="sep" />
</DockPanel>
</TabItem>
<Button Classes="toolbar" Command="{Binding RunJobNowCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="Jetzt ausführen" />
</StackPanel>
</Button>
<!-- TAB 3: Verlauf (Job History) -->
<TabItem Header="Verlauf (History)">
<DockPanel Margin="0,8,0,0">
<Button Classes="toolbar" Command="{Binding RefreshJobListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="Aktualisieren" />
</StackPanel>
</Button>
</WrapPanel>
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Verlauf aktualisieren" Command="{Binding RefreshJobHistoryCommand}" />
</StackPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.1*,1.3*,2.2*,1.1*,1.3*,1.3*,0.9*">
<TextBlock Grid.Column="0" Classes="th" Text="TYP" />
<TextBlock Grid.Column="1" Classes="th" Text="AGENT" />
<TextBlock Grid.Column="2" Classes="th" Text="TOOL / NACHRICHT" />
<TextBlock Grid.Column="3" Classes="th" Text="CRON" />
<TextBlock Grid.Column="4" Classes="th" Text="NÄCHSTER LAUF" />
<TextBlock Grid.Column="5" Classes="th" Text="LETZTER LAUF" />
<TextBlock Grid.Column="6" Classes="th" Text="STATUS" />
</Grid>
</Border>
<!-- History DataGrid -->
<DataGrid ItemsSource="{Binding JobHistoryEntries}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeitpunkt" Binding="{Binding Time, StringFormat='{}{0:g}'}" Width="140" />
<DataGridTextColumn Header="Job-Name" Binding="{Binding JobName}" Width="140" />
<DataGridTextColumn Header="Agent" Binding="{Binding Agent}" Width="140" />
<DataGridTextColumn Header="Beschreibung" Binding="{Binding JobDescription}" Width="220" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="100" />
<DataGridTextColumn Header="Details / Info" Binding="{Binding Info}" Width="*" />
</DataGrid.Columns>
</DataGrid>
<ListBox Classes="table" ItemsSource="{Binding JobEntries}"
SelectedItem="{Binding SelectedJob}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:JobDisplayItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.1*,1.3*,2.2*,1.1*,1.3*,1.3*,0.9*">
<TextBlock Grid.Column="0" Classes="td" Text="{Binding JobType}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding AgentName}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding TaskMessage}" />
<TextBlock Grid.Column="3" Classes="td mono" Text="{Binding CronExpression}" />
<TextBlock Grid.Column="4" Classes="td" Text="{Binding NextRun}" />
<TextBlock Grid.Column="5" Classes="td" Text="{Binding LastRun}" />
</DockPanel>
</TabItem>
<Border Grid.Column="6" Classes="tag"
Classes.ok="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Aktiv}">
<TextBlock Text="{Binding Status}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</TabControl>
<!-- ══════════════════ Dienste ══════════════════ -->
<DockPanel IsVisible="{Binding IsServicesTab}">
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar primary" Command="{Binding AddServiceCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="Dienst hinzufügen" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RemoveServiceCommand}"
IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="Entfernen" />
</StackPanel>
</Button>
<Border Classes="sep" />
<!-- Der Entwurf zeigt hier „Start"/„Stop". Umgelegt wird aber das Kennzeichen
in der Konfiguration, nicht ein laufender Prozess — deshalb die ehrlichere
Beschriftung. -->
<Button Classes="toolbar" Command="{Binding ToggleServiceEnabledCommand}"
IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPower}" Width="14" Height="14" />
<TextBlock Text="Aktivieren/Deaktivieren" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RefreshServiceListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="Aktualisieren" />
</StackPanel>
</Button>
</WrapPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.8*,1*,0.7*,1*,3*">
<TextBlock Grid.Column="0" Classes="th" Text="DIENSTNAME" />
<TextBlock Grid.Column="1" Classes="th" Text="TYP" />
<TextBlock Grid.Column="2" Classes="th" Text="PORT" />
<TextBlock Grid.Column="3" Classes="th" Text="STATUS" />
<TextBlock Grid.Column="4" Classes="th" Text="BESCHREIBUNG" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding ServiceEntries}"
SelectedItem="{Binding SelectedService}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:ServiceDisplayItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.8*,1*,0.7*,1*,3*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding Name}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding Type}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding Port}" />
<Border Grid.Column="3" Classes="tag"
Classes.ok="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Bereit}">
<TextBlock Text="{Binding Status}" />
</Border>
<TextBlock Grid.Column="4" Classes="td dim" Text="{Binding Description}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<!-- ══════════════════ Verlauf ══════════════════ -->
<DockPanel IsVisible="{Binding IsHistoryTab}">
<StackPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar" Command="{Binding RefreshJobHistoryCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="Verlauf aktualisieren" />
</StackPanel>
</Button>
</StackPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.3*,1.6*,1.4*,2*,0.9*,2.4*">
<TextBlock Grid.Column="0" Classes="th" Text="ZEITPUNKT" />
<TextBlock Grid.Column="1" Classes="th" Text="JOB" />
<TextBlock Grid.Column="2" Classes="th" Text="AGENT" />
<TextBlock Grid.Column="3" Classes="th" Text="BESCHREIBUNG" />
<TextBlock Grid.Column="4" Classes="th" Text="STATUS" />
<TextBlock Grid.Column="5" Classes="th" Text="DETAILS" />
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding JobHistoryEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:JobHistoryEntry">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.3*,1.6*,1.4*,2*,0.9*,2.4*">
<TextBlock Grid.Column="0" Classes="td"
Text="{Binding Time, StringFormat='{}{0:dd.MM. HH:mm}'}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding JobName}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding Agent}" />
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding JobDescription}" />
<Border Grid.Column="4" Classes="tag"
Classes.ok="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Success}"
Classes.bad="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Error}">
<TextBlock Text="{Binding Status}" />
</Border>
<TextBlock Grid.Column="5" Classes="td dim" Text="{Binding Info}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Panel>
</DockPanel>
</UserControl>
@@ -0,0 +1,35 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="420"
x:Class="ClawdDotNet.Desktop.Views.TextEditorWindow"
Icon="/Assets/app.ico"
Title="Text bearbeiten"
Width="600" Height="420"
MinWidth="420" MinHeight="300"
WindowStartupLocation="CenterOwner">
<!-- Der Rahmen mit Eckmarken sitzt hier wie im Entwurf um den ganzen Dialog. Die
Marken ragen nach aussen, deshalb 12 Punkte Abstand zum Fensterrand. -->
<ctrl:BlueprintFrame Margin="12" Padding="16">
<DockPanel>
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Classes="h1"
Text="Text bearbeiten" Margin="0,0,0,12" TextWrapping="Wrap" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="Abbrechen" IsCancel="True" Click="OnCancel" />
<Button Classes="primary" Content="Speichern" IsDefault="True" Click="OnSave" />
</StackPanel>
<TextBox x:Name="Editor" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</ctrl:BlueprintFrame>
</Window>
@@ -0,0 +1,48 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace ClawdDotNet.Desktop.Views;
/// <summary>
/// Kleiner Editor für die langen Textfelder eines Agenten (Identity, Soul).
///
/// <para>Ohne eigenes Ansichtsmodell: Der Dialog hat einen Titel, ein Textfeld und zwei
/// Schaltflächen. Ein Ansichtsmodell dafür wäre eine Datei mehr, ohne dass irgendetwas
/// prüfbar würde.</para>
/// </summary>
public partial class TextEditorWindow : Window
{
private string? _result;
public TextEditorWindow()
{
InitializeComponent();
}
/// <summary>
/// Zeigt den Editor und liefert den bearbeiteten Text — oder <c>null</c>, wenn
/// abgebrochen wurde. Das Schließen über das Fensterkreuz gilt als Abbruch: Sonst
/// wäre ein weggeklickter Dialog dasselbe wie ein bestätigter.
/// </summary>
public static async Task<string?> ShowAsync(Window owner, string title, string text)
{
var window = new TextEditorWindow { Title = title };
window.TitleText.Text = title;
window.Editor.Text = text;
await window.ShowDialog(owner);
return window._result;
}
private void OnSave(object? sender, RoutedEventArgs e)
{
_result = Editor.Text ?? "";
Close();
}
private void OnCancel(object? sender, RoutedEventArgs e)
{
_result = null;
Close();
}
}
@@ -0,0 +1,114 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.TokenUsagePageView"
x:DataType="vm:TokenUsagePageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="20">
<!-- Kennzahlen -->
<StackPanel Orientation="Horizontal" Spacing="12">
<Border Classes="card" MinWidth="160">
<StackPanel Spacing="6">
<TextBlock Classes="kicker" Text="TOKENS (7 TAGE)" />
<TextBlock Classes="h2" Text="{Binding TotalTokens}" />
</StackPanel>
</Border>
<Border Classes="card" MinWidth="160">
<StackPanel Spacing="6">
<TextBlock Classes="kicker" Text="KOSTEN (7 TAGE)" />
<TextBlock Classes="h2" Text="{Binding TotalCost}" />
</StackPanel>
</Border>
<Border Classes="card" MinWidth="160">
<StackPanel Spacing="6">
<TextBlock Classes="kicker" Text="RUNS (7 TAGE)" />
<TextBlock Classes="h2" Text="{Binding TotalRuns}" />
</StackPanel>
</Border>
<Button Classes="toolbar" Command="{Binding RefreshCommand}" VerticalAlignment="Bottom">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="Aktualisieren" />
</StackPanel>
</Button>
</StackPanel>
<!-- Hinweis, solange nichts verzeichnet ist -->
<TextBlock Classes="caption" Text="{Binding StatusText}" TextWrapping="Wrap"
IsVisible="{Binding StatusText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Tagesbalken -->
<StackPanel Spacing="12" IsVisible="{Binding !IsEmpty}">
<TextBlock Classes="h2" Text="Tokens pro Tag" />
<Border BorderBrush="{DynamicResource DividerBrush}" BorderThickness="0,0,0,1"
Padding="0,0,0,2">
<ItemsControl ItemsSource="{Binding Days7}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TokenDayItem">
<StackPanel Spacing="6" VerticalAlignment="Bottom"
HorizontalAlignment="Center" Margin="8,0">
<TextBlock Classes="caption" Text="{Binding TokensLabel}"
HorizontalAlignment="Center" />
<Rectangle Width="28" Height="{Binding BarHeight}"
Fill="{DynamicResource AccentBrush}"
VerticalAlignment="Bottom" />
<TextBlock FontSize="11.5" Text="{Binding DayLabel}"
HorizontalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
<!-- Aufschlüsselung -->
<StackPanel Spacing="10" MaxWidth="860" HorizontalAlignment="Left"
IsVisible="{Binding !IsEmpty}">
<TextBlock Classes="h2" Text="Aufschlüsselung nach Agent &amp; Modell" />
<Border Classes="thead">
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*,0.8*">
<TextBlock Grid.Column="0" Classes="th" Text="AGENT" />
<TextBlock Grid.Column="1" Classes="th" Text="MODELL" />
<TextBlock Grid.Column="2" Classes="th" Text="TOKENS" />
<TextBlock Grid.Column="3" Classes="th" Text="KOSTEN" />
<TextBlock Grid.Column="4" Classes="th" Text="RUNS" />
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding ByAgent}" Margin="0,-10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TokenAgentItem">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*,0.8*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold"
Text="{Binding AgentName}" />
<TextBlock Grid.Column="1" Classes="td dim" Text="{Binding Model}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding TokensLabel}" />
<TextBlock Grid.Column="3" Classes="td" Text="{Binding CostLabel}" />
<TextBlock Grid.Column="4" Classes="td" Text="{Binding Runs}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class TokenUsagePageView : UserControl
{
public TokenUsagePageView() => InitializeComponent();
}
@@ -0,0 +1,239 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="420"
x:Class="ClawdDotNet.Desktop.Views.ToolSettingsView"
x:DataType="vm:ToolSettingsViewModel">
<!--
Die Felder eines Werkzeugs — im Entwurf unten rechts auf der Agenten-Seite, nicht
mehr in einem eigenen Fenster. Deshalb ein UserControl und kein Window: Der Inhalt
ist derselbe, der Rahmen drumherum entscheidet der Aufrufer.
Flach statt in Karten und ohne verschachtelte Registerkarten (siehe
Implementierungsleitfaden im Mockup-Verzeichnis): Beschriftung ueber dem Feld,
hoechstens zwei Spalten.
-->
<StackPanel Spacing="12" MaxWidth="520" HorizontalAlignment="Left">
<!-- 1. FTP -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FTP}">
<StackPanel>
<TextBlock Classes="label" Text="Host" />
<TextBox Text="{Binding FtpHost}" PlaceholderText="ftp.example.com" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Port" />
<TextBox Text="{Binding FtpPort}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Benutzername" />
<TextBox Text="{Binding FtpUsername}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="Passwort" />
<TextBox Text="{Binding FtpPassword}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Root-Pfad" />
<TextBox Text="{Binding FtpRootPath}" />
</StackPanel>
</StackPanel>
<!-- 2. Mail -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Mail}">
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Benutzername" />
<TextBox Text="{Binding MailUsername}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Passwort" />
<TextBox Text="{Binding MailPassword}" PasswordChar="•" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="IMAP-Host" />
<TextBox Text="{Binding MailImapHost}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="IMAP-Port" />
<TextBox Text="{Binding MailImapPort}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="SMTP-Host" />
<TextBox Text="{Binding MailSmtpHost}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="SMTP-Port" />
<TextBox Text="{Binding MailSmtpPort}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="Erlaubte Empfänger" />
<TextBox Text="{Binding MailAllowedRecipients}"
PlaceholderText="adresse1@domain.de, adresse2@domain.de" />
</StackPanel>
</StackPanel>
<!-- 3. Database -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Database}">
<StackPanel>
<TextBlock Classes="label" Text="Typ" />
<ComboBox ItemsSource="{Binding DbTypes}" SelectedItem="{Binding DbType}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Connection-String" />
<TextBox Text="{Binding DbConnectionString}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Zugriffsebene" />
<ComboBox ItemsSource="{Binding DbAccessLevels}" SelectedItem="{Binding DbAccessLevel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Erlaubte Tabellen" />
<TextBox Text="{Binding DbAllowedTables}" PlaceholderText="tabelle1, tabelle2" />
</StackPanel>
</StackPanel>
<!-- 4. Telegram -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Telegram}">
<StackPanel>
<TextBlock Classes="label" Text="Bot-Token" />
<TextBox Text="{Binding TelegramBotToken}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Standard Chat-ID" />
<TextBox Text="{Binding TelegramDefaultChatId}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Erlaubte Chat-IDs" />
<TextBox Text="{Binding TelegramAllowedChatIds}" PlaceholderText="id1, id2" />
</StackPanel>
</StackPanel>
<!-- 5. DirectAPI -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=DirectAPI}">
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Standard-Provider" />
<TextBox Text="{Binding DirectApiDefaultProvider}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Cache TTL (Sekunden)" />
<TextBox Text="{Binding DirectApiCacheTtlSeconds}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="Twelve Data Key" />
<TextBox Text="{Binding DirectApiTwelveDataKey}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Alpha Vantage Key" />
<TextBox Text="{Binding DirectApiAlphaVantageKey}" PasswordChar="•" />
</StackPanel>
</StackPanel>
<!-- 6. WebFetch -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=WebFetch}">
<StackPanel>
<TextBlock Classes="label" Text="Erlaubte Domains" />
<TextBox Text="{Binding WebFetchAllowedDomains}"
PlaceholderText="reuters.com, coindesk.com" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Max. Antwortgröße (KB)" />
<TextBox Text="{Binding WebFetchMaxResponseKb}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="User Agent" />
<TextBox Text="{Binding WebFetchUserAgent}" />
</StackPanel>
</Grid>
</StackPanel>
<!-- 7. SocialMediaManager -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=SocialMediaManager}">
<StackPanel>
<TextBlock Classes="label" Text="X (Twitter) API-Key" />
<TextBox Text="{Binding SmmXApiKey}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="X Accounts" />
<TextBox Text="{Binding SmmXWatchAccounts}" PlaceholderText="account1, account2" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Subreddits" />
<TextBox Text="{Binding SmmRedditWatchSubreddits}" PlaceholderText="stocks, investing" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Reddit Post Limit" />
<TextBox Text="{Binding SmmRedditPostLimit}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="OpenRouter Key" />
<TextBox Text="{Binding SmmOpenRouterApiKey}" PasswordChar="•" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="STT-Modell" />
<TextBox Text="{Binding SmmSttModel}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="YouTube-Kanäle" />
<TextBox Text="{Binding SmmYoutubeChannels}" PlaceholderText="URL1, URL2" />
</StackPanel>
</Grid>
</StackPanel>
<!-- 8. FileRW -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FileRW}">
<StackPanel>
<TextBlock Classes="label" Text="Erlaubte Dateiendungen (persönlich)" />
<TextBox Text="{Binding FileRwPersonalAllowedExtensions}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Zugriffsebene (geteilt)" />
<ComboBox ItemsSource="{Binding FileRwAccessLevels}"
SelectedItem="{Binding FileRwSharedAccessLevel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Erlaubte Dateiendungen (geteilt)" />
<TextBox Text="{Binding FileRwSharedAllowedExtensions}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Geschützte Pfade" />
<TextBox Text="{Binding FileRwProtectedPaths}" />
</StackPanel>
</StackPanel>
<!-- Werkzeuge ohne weitere Einstellungen -->
<TextBlock Classes="muted" FontStyle="Italic" TextWrapping="Wrap"
Text="Dieses Werkzeug benötigt keine weiteren Einstellungen."
IsVisible="{Binding SettingsObject, Converter={x:Static ObjectConverters.IsNull}}" />
</StackPanel>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class ToolSettingsView : UserControl
{
public ToolSettingsView() => InitializeComponent();
}
@@ -1,180 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:models="using:ClawdDotNet.Models"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="560" d:DesignHeight="480"
Width="580" Height="500"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Werkzeug-Einstellungen"
x:Class="ClawdDotNet.Desktop.Views.ToolSettingsWindow"
x:DataType="vm:ToolSettingsViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Speichern" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<ScrollViewer>
<StackPanel Spacing="12">
<TextBlock Text="{Binding ToolName, StringFormat='Einstellungen für {0}'}" Classes="heading" FontSize="18" />
<!-- Forms for each Tool -->
<StackPanel Spacing="8">
<!-- 1. FTP -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FTP}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Host:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FtpHost}" PlaceholderText="ftp.example.com" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding FtpPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Benutzername:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FtpUsername}" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Passwort:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FtpPassword}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Root-Pfad:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding FtpRootPath}" Margin="0,8,0,0" />
</Grid>
<!-- 2. Mail -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Mail}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Benutzername:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding MailUsername}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Passwort:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding MailPassword}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="IMAP-Host:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding MailImapHost}" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="IMAP-Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding MailImapPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="SMTP-Host:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding MailSmtpHost}" Margin="0,8,0,0" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="SMTP-Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="5" Grid.Column="1" Value="{Binding MailSmtpPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="Erlaubte Empfänger:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding MailAllowedRecipients}" PlaceholderText="email1@domain.de, email2@domain.de" Margin="0,8,0,0" />
</Grid>
<!-- 3. Database -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Database}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Typ:" FontWeight="Bold" VerticalAlignment="Center" />
<ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding DbTypes}" SelectedItem="{Binding DbType}" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Connection-String:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DbConnectionString}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Zugriffsebene:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding DbAccessLevels}" SelectedItem="{Binding DbAccessLevel}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Erlaubte Tabellen:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding DbAllowedTables}" PlaceholderText="tabelle1, tabelle2" Margin="0,8,0,0" />
</Grid>
<!-- 4. Telegram -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Telegram}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Bot-Token:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding TelegramBotToken}" PasswordChar="*" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Standard Chat-ID:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TelegramDefaultChatId}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Erlaubte Chat-IDs:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding TelegramAllowedChatIds}" PlaceholderText="id1, id2" Margin="0,8,0,0" />
</Grid>
<!-- 5. DirectAPI -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=DirectAPI}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Standard-Provider:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding DirectApiDefaultProvider}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Cache TTL (Sek.):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding DirectApiCacheTtlSeconds}" Minimum="0" Maximum="86400" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Twelve Data Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding DirectApiTwelveDataKey}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Alpha Vantage Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding DirectApiAlphaVantageKey}" PasswordChar="*" Margin="0,8,0,0" />
</Grid>
<!-- 6. WebFetch -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=WebFetch}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Erlaubte Domains:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding WebFetchAllowedDomains}" PlaceholderText="reuters.com, bloomberg.com" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Max Response KB:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding WebFetchMaxResponseKb}" Minimum="1" Maximum="10000" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="User Agent:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding WebFetchUserAgent}" Margin="0,8,0,0" />
</Grid>
<!-- 7. SocialMediaManager -->
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=SocialMediaManager}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="X (Twitter) API-Key:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding SmmXApiKey}" PasswordChar="*" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="X Accounts:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding SmmXWatchAccounts}" PlaceholderText="elonmusk, unusual_whales" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Subreddits:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SmmRedditWatchSubreddits}" PlaceholderText="wallstreetbets, stocks" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Reddit Post Limit:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding SmmRedditPostLimit}" Minimum="1" Maximum="100" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="OpenRouter Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding SmmOpenRouterApiKey}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="STT Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding SmmSttModel}" Margin="0,8,0,0" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="YouTube Kanäle:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding SmmYoutubeChannels}" PlaceholderText="URL1, URL2" Margin="0,8,0,0" />
</Grid>
<!-- 8. FileRW -->
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FileRW}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Personal Allowed Ext:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FileRwPersonalAllowedExtensions}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Shared Access Level:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding FileRwAccessLevels}" SelectedItem="{Binding FileRwSharedAccessLevel}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Shared Allowed Ext:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FileRwSharedAllowedExtensions}" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Geschützte Pfade:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FileRwProtectedPaths}" Margin="0,8,0,0" />
</Grid>
<!-- Fallback info for tools without extra config -->
<TextBlock Text="Dieses Werkzeug benötigt keine weiteren Einstellungen."
IsVisible="{Binding SettingsObject, Converter={x:Static ObjectConverters.IsNull}}"
FontStyle="Italic" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -1,21 +0,0 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class ToolSettingsWindow : Window
{
public ToolSettingsWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ToolSettingsViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"_comment": "Wird vom update-agent bei 'install' und 'configure' gelesen und liegt im Paket neben der manifest.json. Beschreibung des Formats: Deploymentcenter/docs/SETUP_INTEGRATION_GUIDE.md",
"schema": 1,
"displayName": "ClawdDotNet",
"description": "ClawdDotNet braucht die Adresse des Deploymentcenters, einen Lizenzschluessel und ein Token fuer Heartbeat und Fehlermeldungen. Das Token stellt der Server selbst aus.",
"_targets_comment": "location 'config' loest zu %APPDATA% bzw. $XDG_CONFIG_HOME auf - genau das, was AppPaths.ConfigDirectory erwartet. AppPaths schreibt den Ordner plattformabhaengig unterschiedlich: 'ClawdDotNet' unter Windows, 'clawddotnet' unter Linux. Beide Namen sind deshalb ausdruecklich angegeben, statt sich darauf zu verlassen, dass NTFS die Schreibweise ignoriert - auf einem per fsutil umgestellten Ordner oder unter APFS mit Beachtung der Schreibweise traegt das naemlich nicht mehr.",
"targets": [
{
"id": "app",
"file": "clawddotnet/Settings.json",
"fileWindows": "ClawdDotNet/Settings.json",
"fileLinux": "clawddotnet/Settings.json",
"location": "config",
"format": "json"
}
],
"fields": [
{
"key": "deploymentcenterUrl",
"label": "Deploymentcenter",
"help": "Adresse, von der auch dieses Paket geladen wurde.",
"type": "url",
"source": "detect:baseurl"
},
{
"key": "licenseKey",
"label": "Lizenzschluessel",
"help": "Fuenf Bloecke zu fuenf Zeichen. Oeffnet zugleich die Release-Ablage fuer kuenftige Updates.",
"type": "secret",
"required": true,
"validate": "^[A-Za-z0-9]{5}(-[A-Za-z0-9]{5}){4}$",
"validationMessage": "Erwartet werden fuenf Bloecke zu fuenf Zeichen, durch Bindestriche getrennt."
},
{
"key": "deploymentcenterToken",
"label": "Token dieser Installation",
"help": "Wird vom Server ausgestellt und eingetragen - nichts zu kopieren.",
"source": "provision",
"scopes": ["watchdog:ping", "bugtracker:report"]
},
{
"key": "updateChannel",
"label": "Update-Kanal",
"type": "enum",
"options": ["prod", "beta", "dev"],
"default": "prod",
"required": false
},
{
"key": "deploymentcenterEnvironment",
"label": "Umgebung",
"help": "Haengt an Fehler- und Bugtracker-Meldungen.",
"type": "enum",
"options": ["production", "development"],
"default": "production",
"required": false
}
]
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Tools.RocketChat</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,490 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace ClawdDotNet.Tools.RocketChat;
/// <summary>Ein Raum, wie ihn das Abonnement des Agenten beschreibt.</summary>
internal sealed record RoomSubscription(
string RoomId,
string Name,
string Type, // c = Kanal, p = private Gruppe, d = Direktnachricht
int Unread,
int UserMentions,
int GroupMentions,
bool Alert)
{
public bool IsDirect => Type == "d";
/// <summary>Menschenlesbare Bezeichnung für Ausgaben.</summary>
public string Label => IsDirect ? $"@{Name}" : $"#{Name}";
}
/// <summary>Eine angehängte Datei. <paramref name="FileId"/> ist der Griff zum Abholen.</summary>
internal sealed record MessageFile(string FileId, string Name, string ContentType, long Size)
{
public string Describe()
{
var size = Size >= 1024 * 1024 ? $"{Size / 1024.0 / 1024.0:0.#} MB"
: Size >= 1024 ? $"{Size / 1024.0:0.#} KB"
: $"{Size} B";
return $"{Name} ({ContentType}, {size}) — fileId: {FileId}";
}
}
/// <summary>
/// Ein Link samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat.
/// Titel und Beschreibung stammen von der verlinkten Seite — also fremder Text, der
/// niemals als Anweisung gelesen werden darf.
/// </summary>
internal sealed record MessageLink(string Url, string? Title, string? Description);
/// <summary>Eine Nachricht aus der Raum-Historie.</summary>
internal sealed record RoomMessage(
string Id,
DateTime Timestamp,
string Username,
string UserId,
string Text,
IReadOnlyList<string> Mentions,
string? SystemType,
string? ThreadId,
IReadOnlyList<MessageFile> Files,
IReadOnlyList<MessageLink> Links)
{
/// <summary>
/// Systemnachrichten („Benutzer ist beigetreten", Themenänderung …) tragen ein
/// <c>t</c>-Feld. Sie sind kein Gesprächsbeitrag und dürfen keinen Agenten wecken —
/// sonst antwortet er auf einen Raumbeitritt.
/// </summary>
public bool IsSystem => !string.IsNullOrEmpty(SystemType);
}
/// <summary>Fehler der Rocket.Chat-API, mit dem Fehlerschlüssel aus der Antwort.</summary>
internal sealed class RocketChatException(string message, string? errorType = null)
: Exception(message)
{
public string? ErrorType { get; } = errorType;
}
/// <summary>
/// Schmaler REST-Client für Rocket.Chat. Authentifiziert wird je Aufruf über die beiden
/// Kopfzeilen <c>X-Auth-Token</c> und <c>X-User-Id</c>; woher das Token stammt
/// (Personal Access Token oder vom Administrator erzeugt), ist dem Client gleich.
///
/// Geprüft gegen Rocket.Chat 8.7.
/// </summary>
internal sealed class RocketChatClient(RocketChatConfig config)
{
// Ein geteilter HttpClient — je Aufruf einen neuen zu erzeugen erschöpft unter Last
// die Sockets. Die Zugangsdaten hängen deshalb an der Anfrage, nicht am Client.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<JsonElement> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{
using var request = new HttpRequestMessage(method, $"{config.BaseUrl}{path}");
request.Headers.Add("X-Auth-Token", config.AuthToken);
request.Headers.Add("X-User-Id", config.UserId);
if (body is not null)
request.Content = JsonContent.Create(body, options: JsonOptions);
using var response = await Http.SendAsync(request, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
JsonElement json;
try
{
json = JsonDocument.Parse(raw).RootElement.Clone();
}
catch (JsonException)
{
throw new RocketChatException(
$"Rocket.Chat antwortete mit {(int)response.StatusCode} und keinem JSON: " +
Truncate(raw, 200));
}
var success = json.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
if (success)
return json;
var errorType = json.TryGetProperty("errorType", out var et) ? et.GetString() : null;
var message = json.TryGetProperty("error", out var e) ? e.GetString()
: json.TryGetProperty("message", out var m) ? m.GetString()
: $"HTTP {(int)response.StatusCode}";
throw new RocketChatException(message ?? "Unbekannter Fehler", errorType);
}
/// <summary>
/// Alle Räume des Agenten samt Ungelesen-Zählern — <b>ein</b> Aufruf, unabhängig von
/// der Zahl der Räume. Darauf steht der gesamte Poll-Entwurf: Erst dieser Sammelabruf
/// entscheidet, ob überhaupt eine Historie geholt werden muss.
/// </summary>
public async Task<IReadOnlyList<RoomSubscription>> GetRoomsAsync(CancellationToken ct)
{
var json = await SendAsync(HttpMethod.Get, "/api/v1/subscriptions.get", null, ct);
if (!json.TryGetProperty("update", out var update) || update.ValueKind != JsonValueKind.Array)
return [];
var rooms = new List<RoomSubscription>();
foreach (var item in update.EnumerateArray())
{
var roomId = GetString(item, "rid");
if (string.IsNullOrEmpty(roomId))
continue;
rooms.Add(new RoomSubscription(
RoomId: roomId,
Name: GetString(item, "name") ?? roomId,
Type: GetString(item, "t") ?? "c",
Unread: GetInt(item, "unread"),
UserMentions: GetInt(item, "userMentions"),
GroupMentions: GetInt(item, "groupMentions"),
Alert: item.TryGetProperty("alert", out var a) && a.ValueKind == JsonValueKind.True));
}
return rooms;
}
/// <summary>
/// Löst einen Raumnamen oder eine Raum-Id gegen die Abonnements des Agenten auf.
/// Was hier nicht auftaucht, ist ein Raum, in dem der Agent kein Mitglied ist — das
/// ist eine klarere Auskunft als der API-Fehler <c>invalid-channel</c>.
/// </summary>
public static RoomSubscription? Resolve(IReadOnlyList<RoomSubscription> rooms, string roomIdOrName)
{
var needle = roomIdOrName.TrimStart('#', '@');
return rooms.FirstOrDefault(r => string.Equals(r.RoomId, roomIdOrName, StringComparison.Ordinal))
?? rooms.FirstOrDefault(r => string.Equals(r.Name, needle, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Nachrichten eines Raums seit einem Zeitpunkt, älteste zuerst.</summary>
public async Task<IReadOnlyList<RoomMessage>> GetHistoryAsync(
RoomSubscription room, DateTime? since, int count, CancellationToken ct)
{
// Die Historie hat je Raumart einen eigenen Endpunkt.
var endpoint = room.Type switch
{
"p" => "groups.history",
"d" => "im.history",
_ => "channels.history"
};
var query = new StringBuilder($"/api/v1/{endpoint}?roomId={Uri.EscapeDataString(room.RoomId)}")
.Append("&count=").Append(count);
if (since is { } from)
{
query.Append("&oldest=").Append(Uri.EscapeDataString(
from.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
query.Append("&inclusive=false");
}
var json = await SendAsync(HttpMethod.Get, query.ToString(), null, ct);
if (!json.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
return [];
var result = new List<RoomMessage>();
foreach (var m in messages.EnumerateArray())
{
var id = GetString(m, "_id");
if (string.IsNullOrEmpty(id))
continue;
var user = m.TryGetProperty("u", out var u) ? u : default;
result.Add(new RoomMessage(
Id: id,
Timestamp: GetDate(m, "ts"),
Username: (user.ValueKind == JsonValueKind.Object ? GetString(user, "username") : null) ?? "unbekannt",
UserId: (user.ValueKind == JsonValueKind.Object ? GetString(user, "_id") : null) ?? "",
Text: GetString(m, "msg") ?? "",
Mentions: ReadMentions(m),
SystemType: GetString(m, "t"),
ThreadId: GetString(m, "tmid"),
Files: ReadFiles(m),
Links: ReadLinks(m)));
}
// Rocket.Chat liefert die jüngste Nachricht zuerst; für einen Verlauf ist die
// umgekehrte Reihenfolge die brauchbare.
return result.OrderBy(x => x.Timestamp).ToList();
}
/// <summary>
/// Öffnet (oder findet) den Direktnachrichten-Raum zu einem Benutzer. Nötig, weil ein
/// DM-Raum erst existiert, wenn jemand ihn eröffnet — ohne das könnte ein Agent nur
/// auf bestehende Gespräche antworten, aber keines beginnen.
/// </summary>
public async Task<RoomSubscription?> OpenDirectMessageAsync(string username, CancellationToken ct)
{
var json = await SendAsync(HttpMethod.Post, "/api/v1/im.create", new { username }, ct);
if (!json.TryGetProperty("room", out var room) || room.ValueKind != JsonValueKind.Object)
return null;
var roomId = GetString(room, "_id") ?? GetString(room, "rid");
return roomId is null
? null
: new RoomSubscription(roomId, username, "d", 0, 0, 0, false);
}
public async Task<string> PostMessageAsync(
string roomId, string text, string? threadId, CancellationToken ct)
{
object body = threadId is null
? new { roomId, text }
: new { roomId, text, tmid = threadId };
var json = await SendAsync(HttpMethod.Post, "/api/v1/chat.postMessage", body, ct);
return json.TryGetProperty("message", out var m) ? GetString(m, "_id") ?? "" : "";
}
/// <summary>
/// Lädt eine Datei in einen Raum. Das läuft in <b>zwei</b> Schritten, anders als der
/// Rest der API:
///
/// 1. <c>rooms.media</c> nimmt die Datei als <c>multipart/form-data</c> entgegen und
/// gibt eine Datei-Id zurück — sichtbar ist damit noch nichts.
/// 2. <c>rooms.mediaConfirm</c> veröffentlicht sie als Nachricht im Raum.
///
/// Der frühere Ein-Schritt-Endpunkt <c>rooms.upload</c> existiert in Rocket.Chat 8.7
/// nicht mehr (er antwortet mit einem nackten 404). Diese Umsetzung setzt daher eine
/// Version voraus, die <c>rooms.media</c> kennt (6.x und neuer).
/// </summary>
public async Task<string> UploadFileAsync(
string roomId, Stream content, string fileName, string contentType,
string? message, string? threadId, CancellationToken ct)
{
var room = Uri.EscapeDataString(roomId);
// Schritt 1 — die Datei selbst.
using var form = new MultipartFormDataContent();
var file = new StreamContent(content);
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
form.Add(file, "file", fileName);
using var request = new HttpRequestMessage(
HttpMethod.Post, $"{config.BaseUrl}/api/v1/rooms.media/{room}");
request.Headers.Add("X-Auth-Token", config.AuthToken);
request.Headers.Add("X-User-Id", config.UserId);
request.Content = form;
using var response = await Http.SendAsync(request, ct);
var raw = await response.Content.ReadAsStringAsync(ct);
JsonElement upload;
try
{
upload = JsonDocument.Parse(raw).RootElement.Clone();
}
catch (JsonException)
{
throw new RocketChatException(
$"Upload scheiterte mit {(int)response.StatusCode}: {Truncate(raw, 200)}"
+ (response.StatusCode == System.Net.HttpStatusCode.NotFound
? " — kennt diese Rocket.Chat-Version den Endpunkt rooms.media?"
: ""));
}
if (!upload.TryGetProperty("success", out var ok) || ok.ValueKind != JsonValueKind.True)
throw new RocketChatException(
(upload.TryGetProperty("error", out var e) ? e.GetString() : null)
?? "Upload fehlgeschlagen");
var fileId = upload.TryGetProperty("file", out var f) ? GetString(f, "_id") : null;
if (string.IsNullOrEmpty(fileId))
throw new RocketChatException("Upload lieferte keine Datei-Id zurück.");
// Schritt 2 — ohne ihn liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
object confirmBody = threadId is null
? new { msg = message ?? "" }
: new { msg = message ?? "", tmid = threadId };
var confirm = await SendAsync(
HttpMethod.Post, $"/api/v1/rooms.mediaConfirm/{room}/{Uri.EscapeDataString(fileId)}",
confirmBody, ct);
return confirm.TryGetProperty("message", out var msg) ? GetString(msg, "_id") ?? "" : "";
}
public async Task MarkReadAsync(string roomId, CancellationToken ct)
=> await SendAsync(HttpMethod.Post, "/api/v1/subscriptions.read", new { rid = roomId }, ct);
// ─── JSON-Lesehelfer ───
/// <summary>
/// Angehängte Dateien. Rocket.Chat führt sie in <c>files</c>; die Datei-Id daraus ist
/// alles, was zum späteren Abholen nötig ist.
/// </summary>
private static IReadOnlyList<MessageFile> ReadFiles(JsonElement message)
{
if (!message.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array)
return [];
var result = new List<MessageFile>();
foreach (var f in files.EnumerateArray())
{
var id = GetString(f, "_id");
if (string.IsNullOrEmpty(id))
continue;
result.Add(new MessageFile(
FileId: id,
Name: GetString(f, "name") ?? id,
ContentType: GetString(f, "type") ?? "application/octet-stream",
Size: f.TryGetProperty("size", out var s) && s.ValueKind == JsonValueKind.Number
&& s.TryGetInt64(out var n) ? n : 0));
}
return result;
}
/// <summary>
/// Links samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat. Das
/// erspart dem Agenten oft einen eigenen Abruf der Seite.
/// </summary>
private static IReadOnlyList<MessageLink> ReadLinks(JsonElement message)
{
if (!message.TryGetProperty("urls", out var urls) || urls.ValueKind != JsonValueKind.Array)
return [];
var result = new List<MessageLink>();
foreach (var u in urls.EnumerateArray())
{
var url = GetString(u, "url");
if (string.IsNullOrEmpty(url))
continue;
var meta = u.TryGetProperty("meta", out var m) && m.ValueKind == JsonValueKind.Object
? m
: default;
result.Add(new MessageLink(
Url: url,
Title: meta.ValueKind == JsonValueKind.Object
? GetString(meta, "pageTitle") ?? GetString(meta, "ogTitle")
: null,
Description: meta.ValueKind == JsonValueKind.Object
? GetString(meta, "description") ?? GetString(meta, "ogDescription")
: null));
}
return result;
}
/// <summary>
/// Holt eine angehängte Datei. Die Dateiablage liegt nicht unter <c>/api</c>, nimmt
/// aber dieselben Kopfzeilen; ohne sie antwortet der Server mit 403. Der Namensteil
/// der Adresse ist beliebig, muss aber vorhanden sein.
/// </summary>
public async Task<long> DownloadToFileAsync(
string fileId, string destinationPath, long maxBytes, CancellationToken ct)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{config.BaseUrl}/file-upload/{Uri.EscapeDataString(fileId)}/download");
request.Headers.Add("X-Auth-Token", config.AuthToken);
request.Headers.Add("X-User-Id", config.UserId);
using var response = await Http.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode)
throw new RocketChatException(response.StatusCode switch
{
System.Net.HttpStatusCode.Forbidden =>
"Zugriff auf die Datei verweigert — der Agent ist vermutlich kein Mitglied des Raums.",
System.Net.HttpStatusCode.NotFound => $"Keine Datei mit der Id {fileId}.",
_ => $"Datei konnte nicht geladen werden (HTTP {(int)response.StatusCode})."
});
if (response.Content.Headers.ContentLength is { } declared && declared > maxBytes)
throw new RocketChatException(
$"Die Datei ist {declared / 1024 / 1024} MB groß und überschreitet die Grenze von "
+ $"{maxBytes / 1024 / 1024} MB.");
await using var source = await response.Content.ReadAsStreamAsync(ct);
// Erst in eine Nebendatei schreiben: Bricht der Download ab oder reißt die Grenze,
// bleibt keine halbe Datei im Workspace liegen, die der Agent für vollständig hält.
var temp = destinationPath + ".part";
long written = 0;
try
{
await using (var target = File.Create(temp))
{
var buffer = new byte[81920];
int read;
while ((read = await source.ReadAsync(buffer, ct)) > 0)
{
written += read;
// Auch ohne Content-Length darf die Grenze nicht überschritten werden.
if (written > maxBytes)
throw new RocketChatException(
$"Die Datei überschreitet die Grenze von {maxBytes / 1024 / 1024} MB.");
await target.WriteAsync(buffer.AsMemory(0, read), ct);
}
}
File.Move(temp, destinationPath, overwrite: true);
return written;
}
catch
{
if (File.Exists(temp))
try { File.Delete(temp); } catch { /* Aufräumen darf den Fehler nicht verdecken */ }
throw;
}
}
private static IReadOnlyList<string> ReadMentions(JsonElement message)
{
if (!message.TryGetProperty("mentions", out var mentions)
|| mentions.ValueKind != JsonValueKind.Array)
return [];
return mentions.EnumerateArray()
.Select(x => GetString(x, "username"))
.Where(x => !string.IsNullOrEmpty(x))
.Select(x => x!)
.ToList();
}
private static string? GetString(JsonElement element, string name)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty(name, out var v)
&& v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static int GetInt(JsonElement element, string name)
=> element.TryGetProperty(name, out var v)
&& v.ValueKind == JsonValueKind.Number
&& v.TryGetInt32(out var i)
? i
: 0;
private static DateTime GetDate(JsonElement element, string name)
=> element.TryGetProperty(name, out var v)
&& v.ValueKind == JsonValueKind.String
&& DateTime.TryParse(v.GetString(), null,
System.Globalization.DateTimeStyles.AdjustToUniversal
| System.Globalization.DateTimeStyles.AssumeUniversal, out var d)
? d
: DateTime.UtcNow;
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..max] + "…";
}
@@ -0,0 +1,150 @@
using System.Text.Json;
namespace ClawdDotNet.Tools.RocketChat;
/// <summary>
/// Die Tool-Konfiguration eines Agenten, aus dem freien Wörterbuch gelesen.
///
/// Die Werte kommen je nach Weg als <see cref="JsonElement"/> (frisch deserialisiert) oder
/// als einfache .NET-Werte (nach einer Bearbeitung in der Oberfläche) an — deshalb liest
/// jeder Zugriff über die Helfer unten, nie über eine direkte Umwandlung.
/// </summary>
internal sealed record RocketChatConfig(
string BaseUrl,
string UserId,
string AuthToken,
string? Username,
IReadOnlyList<string> AllowedRooms,
string? DefaultRoom,
bool MentionOnly,
IReadOnlyList<string> AgentUsernames,
int MaxWakesPerRoomPerHour,
int MaxMessagesPerRoom,
TimeSpan InitialLookback,
long MaxUploadBytes,
long MaxDownloadBytes,
bool AllowDangerousDownloads)
{
/// <summary>
/// Liest die Konfiguration. Gibt eine sprechende Fehlermeldung zurück, statt mit einer
/// Ausnahme zu scheitern — das Modell soll aus dem Ergebnis erkennen, was fehlt.
/// </summary>
public static (RocketChatConfig? Config, string? Error) Read(
IReadOnlyDictionary<string, object?> raw)
{
var baseUrl = GetString(raw, "baseUrl")?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
return (null, "Konfigurationsfehler: 'baseUrl' fehlt (z. B. \"https://chat.example.org\").");
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
return (null, $"Konfigurationsfehler: 'baseUrl' ist keine gültige http(s)-Adresse: {baseUrl}");
var userId = GetString(raw, "userId");
if (string.IsNullOrWhiteSpace(userId))
return (null, "Konfigurationsfehler: 'userId' fehlt (die Rocket.Chat-Benutzer-Id dieses Agenten).");
var authToken = GetString(raw, "authToken");
if (string.IsNullOrWhiteSpace(authToken))
return (null, "Konfigurationsfehler: 'authToken' fehlt (Zugriffstoken des Agenten-Benutzers).");
return (new RocketChatConfig(
BaseUrl: baseUrl,
UserId: userId,
AuthToken: authToken,
Username: GetString(raw, "username"),
AllowedRooms: GetStringList(raw, "allowedRooms"),
DefaultRoom: GetString(raw, "defaultRoom"),
MentionOnly: GetBool(raw, "mentionOnly", defaultValue: true),
AgentUsernames: GetStringList(raw, "agentUsernames"),
MaxWakesPerRoomPerHour: GetInt(raw, "maxWakesPerRoomPerHour", defaultValue: 12),
MaxMessagesPerRoom: Math.Clamp(GetInt(raw, "maxMessagesPerRoom", defaultValue: 20), 1, 100),
InitialLookback: TimeSpan.FromMinutes(
Math.Clamp(GetInt(raw, "initialLookbackMinutes", defaultValue: 5), 1, 1440)),
MaxUploadBytes: Math.Clamp(
GetInt(raw, "maxUploadMb", defaultValue: 25), 1, 100) * 1024L * 1024L,
MaxDownloadBytes: Math.Clamp(
GetInt(raw, "maxDownloadMb", defaultValue: 25), 1, 100) * 1024L * 1024L,
AllowDangerousDownloads: GetBool(raw, "allowDangerousDownloads", defaultValue: false)),
null);
}
/// <summary>
/// Prüft, ob ein Raum benutzt werden darf. Eine leere Liste erlaubt jeden Raum — wer
/// keine Allowlist pflegt, hat sich dagegen entschieden; das Tool erfindet keine.
/// Verglichen wird gegen Raum-Id <b>und</b> Raumname, weil das Modell beides kennt.
/// </summary>
public bool IsRoomAllowed(string roomIdOrName)
{
if (AllowedRooms.Count == 0)
return true;
return AllowedRooms.Any(r =>
string.Equals(r, roomIdOrName, StringComparison.OrdinalIgnoreCase));
}
public bool IsAgentUser(string? username)
=> username is not null
&& AgentUsernames.Any(a => string.Equals(a, username, StringComparison.OrdinalIgnoreCase));
// ─── Lesehelfer für das freie Wörterbuch ───
private static string? GetString(IReadOnlyDictionary<string, object?> raw, string key)
=> raw.TryGetValue(key, out var v) ? AsString(v) : null;
private static string? AsString(object? value) => value switch
{
null => null,
string s => string.IsNullOrWhiteSpace(s) ? null : s,
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
JsonElement je => je.ToString(),
_ => value.ToString()
};
private static bool GetBool(IReadOnlyDictionary<string, object?> raw, string key, bool defaultValue)
{
if (!raw.TryGetValue(key, out var v) || v is null)
return defaultValue;
return v switch
{
bool b => b,
JsonElement { ValueKind: JsonValueKind.True } => true,
JsonElement { ValueKind: JsonValueKind.False } => false,
_ => bool.TryParse(AsString(v), out var parsed) ? parsed : defaultValue
};
}
private static int GetInt(IReadOnlyDictionary<string, object?> raw, string key, int defaultValue)
{
if (!raw.TryGetValue(key, out var v) || v is null)
return defaultValue;
return v switch
{
int i => i,
long l => (int)l,
JsonElement { ValueKind: JsonValueKind.Number } je when je.TryGetInt32(out var n) => n,
_ => int.TryParse(AsString(v), out var parsed) ? parsed : defaultValue
};
}
private static IReadOnlyList<string> GetStringList(
IReadOnlyDictionary<string, object?> raw, string key)
{
if (!raw.TryGetValue(key, out var v) || v is null)
return [];
return v switch
{
JsonElement { ValueKind: JsonValueKind.Array } je =>
je.EnumerateArray()
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s!)
.ToList(),
IEnumerable<string> list => list.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(),
_ => AsString(v) is { } single ? [single] : []
};
}
}
@@ -0,0 +1,687 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.RocketChat;
/// <summary>
/// Anbindung an eine selbstgehostete Rocket.Chat-Instanz. Jeder Agent tritt mit einem
/// eigenen Benutzer auf — dadurch funktionieren Direktnachrichten und Erwähnungen echt
/// und nicht als Alias eines Sammelkontos.
///
/// Der Rückkanal läuft über <see cref="IToolJobProvider"/>: Ein Takt prüft ohne Modell
/// (und damit kostenlos), ob etwas anliegt, und weckt den Agenten nur dann.
/// </summary>
public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
public string Name => "RocketChat";
public string Description => """
Kommunikation über Rocket.Chat: Nachrichten in Kanälen, privaten Gruppen und
Direktnachrichten lesen und schreiben.
Räume werden per Id ("GENERAL") oder Name ("#team") angegeben; list_rooms zeigt,
wo dieser Agent Mitglied ist. Mit "@benutzername" wird eine Direktnachricht
geschrieben auch an jemanden, mit dem noch kein Gespräch besteht.
Aktionen: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": ["send_message", "reply", "send_file", "download_file", "list_rooms", "read_room", "mark_read"],
"description": "send_message: Nachricht in einen Raum senden. reply: Antwort im Thread einer Nachricht. send_file: eine Datei aus dem Workspace in einen Raum schicken. download_file: eine empfangene Datei in den Workspace holen. list_rooms: eigene Räume mit Ungelesen-Zählern auflisten. read_room: letzte Nachrichten eines Raums lesen. mark_read: Raum als gelesen markieren."
},
"room": {
"type": "string",
"description": "Raum-Id oder -Name (z. B. \"GENERAL\", \"#team\", \"@person\"). Entfällt bei list_rooms; ohne Angabe gilt der konfigurierte Standardraum."
},
"text": {
"type": "string",
"description": "Nachrichtentext (für send_message und reply)."
},
"threadId": {
"type": "string",
"description": "Id der Nachricht, auf die geantwortet wird (nur für reply)."
},
"localPath": {
"type": "string",
"description": "Pfad der zu sendenden Datei im Workspace (nur für send_file): \"bericht.pdf\" oder \"personal:bericht.pdf\" im eigenen Workspace, \"shared:auswertung.csv\" im geteilten."
},
"filename": {
"type": "string",
"description": "Abweichender Dateiname für den Empfänger (nur für send_file). Ohne Angabe gilt der Name im Workspace."
},
"fileId": {
"type": "string",
"description": "Id einer empfangenen Datei (nur für download_file). Steht in der Nachricht als \"fileId: …\"."
},
"limit": {
"type": "integer",
"description": "Anzahl der zu lesenden Nachrichten bei read_room (Standard 20, höchstens 100)."
}
}
}
""").RootElement.Clone();
// ─── IAgentTool ───
public async Task<ToolResult> ExecuteAsync(
JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (!input.TryGetProperty("action", out var actionElement)
|| actionElement.GetString() is not { } action)
return ToolResult.Fail(
"'action' ist erforderlich. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.");
var (config, configError) = RocketChatConfig.Read(context.ToolConfig);
if (config is null)
return ToolResult.Fail(configError!);
var client = new RocketChatClient(config);
context.Logger.LogInformation("RocketChat: Aktion {Action}", action);
try
{
return action switch
{
"send_message" => await SendMessageAsync(client, config, input, threadAllowed: false, ct),
"reply" => await SendMessageAsync(client, config, input, threadAllowed: true, ct),
"send_file" => await SendFileAsync(client, config, input, context, ct),
"download_file" => await DownloadFileAsync(client, config, input, context, ct),
"list_rooms" => await ListRoomsAsync(client, ct),
"read_room" => await ReadRoomAsync(client, config, input, ct),
"mark_read" => await MarkReadAsync(client, config, input, ct),
_ => ToolResult.Fail(
$"Unbekannte Aktion '{action}'. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.")
};
}
catch (RocketChatException ex)
{
context.Logger.LogWarning("RocketChat meldet einen Fehler bei {Action}: {Message}", action, ex.Message);
return ToolResult.Fail($"Rocket.Chat: {ex.Message}");
}
catch (HttpRequestException ex)
{
context.Logger.LogWarning(ex, "Rocket.Chat ist nicht erreichbar");
return ToolResult.Fail($"Rocket.Chat ist nicht erreichbar: {ex.Message}");
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return ToolResult.Fail("Rocket.Chat hat nicht rechtzeitig geantwortet (Zeitüberschreitung).");
}
}
private static async Task<ToolResult> SendMessageAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input,
bool threadAllowed, CancellationToken ct)
{
var text = GetString(input, "text");
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("'text' ist erforderlich.");
var threadId = threadAllowed ? GetString(input, "threadId") : null;
if (threadAllowed && string.IsNullOrWhiteSpace(threadId))
return ToolResult.Fail("'threadId' ist für reply erforderlich (Id der Nachricht, auf die geantwortet wird).");
var rooms = await client.GetRoomsAsync(ct);
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
if (room is null)
return ToolResult.Fail(error!);
var messageId = await client.PostMessageAsync(room.RoomId, text, threadId, ct);
return ToolResult.Ok(JsonSerializer.Serialize(new
{
sent = true,
room = room.Label,
roomId = room.RoomId,
messageId,
inThread = threadId is not null
}));
}
/// <summary>
/// Schickt eine Datei aus dem Workspace des Agenten in einen Raum — der kurze Weg für
/// „stell mir das zusammen und schick es rüber", ohne Umweg über die Cloud.
/// </summary>
private static async Task<ToolResult> SendFileAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input,
AgentToolContext context, CancellationToken ct)
{
var localPath = GetString(input, "localPath");
if (string.IsNullOrWhiteSpace(localPath))
return ToolResult.Fail(
"'localPath' ist erforderlich — z. B. \"bericht.pdf\" (persönlicher Workspace) "
+ "oder \"shared:auswertung.csv\" (geteilter Workspace).");
var (file, pathError) = WorkspaceFile.Resolve(
localPath, context.WorkspacePath, context.SharedWorkspacePath);
if (file is null)
return ToolResult.Fail(pathError!);
if (!File.Exists(file.FullPath))
return ToolResult.Fail($"Datei nicht gefunden: {localPath}");
var info = new FileInfo(file.FullPath);
if (info.Length == 0)
return ToolResult.Fail($"Die Datei {localPath} ist leer.");
if (info.Length > config.MaxUploadBytes)
return ToolResult.Fail(
$"Die Datei ist {info.Length / 1024 / 1024} MB groß und überschreitet die Grenze von "
+ $"{config.MaxUploadBytes / 1024 / 1024} MB. Für Größeres eignet sich die Cloud-Ablage.");
var rooms = await client.GetRoomsAsync(ct);
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
if (room is null)
return ToolResult.Fail(error!);
var fileName = GetString(input, "filename") ?? Path.GetFileName(file.FullPath);
await using var stream = File.OpenRead(file.FullPath);
var messageId = await client.UploadFileAsync(
room.RoomId, stream, fileName,
WorkspaceFile.GuessContentType(fileName),
GetString(input, "text"),
GetString(input, "threadId"),
ct);
return ToolResult.Ok(JsonSerializer.Serialize(new
{
sent = true,
room = room.Label,
roomId = room.RoomId,
fileName,
bytes = info.Length,
workspace = file.Label,
messageId
}));
}
/// <summary>
/// Holt eine angehängte Datei in den Workspace des Agenten — das Gegenstück zu
/// <c>send_file</c>. Die <c>fileId</c> steht in der Weckmeldung bzw. in read_room.
/// </summary>
private static async Task<ToolResult> DownloadFileAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input,
AgentToolContext context, CancellationToken ct)
{
var fileId = GetString(input, "fileId");
if (string.IsNullOrWhiteSpace(fileId))
return ToolResult.Fail(
"'fileId' ist erforderlich — sie steht bei jeder Datei in der Nachricht "
+ "(\"Datei: … — fileId: …\").");
// Zielname: Angabe des Modells, sonst der Name aus der Nachricht. Beides ist
// fremdbestimmt, deshalb wird der reine Dateiname genommen und jede Pfadangabe
// darin verworfen, bevor überhaupt aufgelöst wird.
var wanted = GetString(input, "localPath") ?? GetString(input, "filename") ?? fileId;
// Den Workspace-Präfix zuerst abtrennen: Auf Windows ist ':' kein Pfadtrenner,
// sondern der Marker für NTFS-Alternativdatenströme. Bliebe "shared:" im Namen,
// schriebe Path.GetFileName ihn unverändert durch — und die Datei landete als
// Datenstrom am Verzeichnis statt als eigene Datei.
var prefix = "";
var rest = wanted;
if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
{
prefix = "shared:";
rest = rest[7..];
}
else if (rest.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
{
rest = rest[9..];
}
var targetName = Path.GetFileName(rest.Replace('\\', '/'));
if (string.IsNullOrWhiteSpace(targetName))
return ToolResult.Fail($"'{wanted}' ergibt keinen gültigen Dateinamen.");
var extension = Path.GetExtension(targetName);
if (!config.AllowDangerousDownloads && DangerousExtensions.Contains(extension))
return ToolResult.Fail(
$"Dateien vom Typ '{extension}' werden nicht heruntergeladen. "
+ "Bei Bedarf 'allowDangerousDownloads' in der Tool-Konfiguration setzen.");
// Das Verzeichnis darf das Modell wählen (personal:/shared:), den Dateinamen nicht
// frei zusammensetzen — deshalb Präfix und bereinigter Name getrennt.
var (file, pathError) = WorkspaceFile.Resolve(
prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath);
if (file is null)
return ToolResult.Fail(pathError!);
Directory.CreateDirectory(Path.GetDirectoryName(file.FullPath)!);
var bytes = await client.DownloadToFileAsync(fileId, file.FullPath, config.MaxDownloadBytes, ct);
return ToolResult.Ok(JsonSerializer.Serialize(new
{
downloaded = true,
fileId,
savedAs = $"{(file.Label == "shared" ? "shared:" : "")}{targetName}",
workspace = file.Label,
bytes
}));
}
/// <summary>
/// Endungen, die nicht ungefragt im Workspace landen sollen. Die Datei kommt von
/// außen — der Dateiname ist eine Behauptung des Absenders, kein Befund.
/// </summary>
private static readonly HashSet<string> DangerousExtensions =
new(StringComparer.OrdinalIgnoreCase)
{
".exe", ".dll", ".bat", ".cmd", ".com", ".scr", ".msi", ".ps1",
".vbs", ".jar", ".lnk", ".reg", ".sh"
};
private static async Task<ToolResult> ListRoomsAsync(RocketChatClient client, CancellationToken ct)
{
var rooms = await client.GetRoomsAsync(ct);
return ToolResult.Ok(JsonSerializer.Serialize(new
{
fetchedAt = DateTime.UtcNow,
source = "rocketchat",
data = new
{
count = rooms.Count,
rooms = rooms.Select(r => new
{
roomId = r.RoomId,
name = r.Name,
type = r.Type switch { "c" => "channel", "p" => "private", "d" => "direct", _ => r.Type },
unread = r.Unread,
mentions = r.UserMentions
})
}
}));
}
private static async Task<ToolResult> ReadRoomAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
{
var rooms = await client.GetRoomsAsync(ct);
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
if (room is null)
return ToolResult.Fail(error!);
var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 100);
var messages = await client.GetHistoryAsync(room, since: null, count: limit, ct);
var payload = JsonSerializer.Serialize(new
{
fetchedAt = DateTime.UtcNow,
source = $"rocketchat_{room.RoomId}",
data = new
{
room = room.Label,
roomId = room.RoomId,
count = messages.Count(m => !m.IsSystem),
messages = messages.Where(m => !m.IsSystem).Select(m => new
{
messageId = m.Id,
at = m.Timestamp,
from = m.Username,
text = m.Text,
threadId = m.ThreadId,
files = m.Files.Count == 0 ? null : m.Files.Select(f => new
{
fileId = f.FileId,
name = f.Name,
contentType = f.ContentType,
size = f.Size
}),
links = m.Links.Count == 0 ? null : m.Links.Select(l => new
{
url = l.Url,
title = l.Title,
description = l.Description is null ? null : Shorten(l.Description, 300)
})
})
}
});
// Fremder Text — als Daten gerahmt, nicht als Anweisung (K2).
return ToolResult.Ok(Untrusted(payload));
}
private static async Task<ToolResult> MarkReadAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
{
var rooms = await client.GetRoomsAsync(ct);
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
if (room is null)
return ToolResult.Fail(error!);
await client.MarkReadAsync(room.RoomId, ct);
return ToolResult.Ok($"Raum {room.Label} als gelesen markiert.");
}
/// <summary>
/// Bestimmt den Zielraum aus Eingabe oder Standardwert und prüft die Allowlist.
/// Schlägt die Auflösung fehl, nennt die Meldung die tatsächlich verfügbaren Räume —
/// das Modell soll sich korrigieren können, statt zu raten.
/// </summary>
private static async Task<(RoomSubscription? Room, string? Error)> ResolveRoomAsync(
RocketChatClient client, IReadOnlyList<RoomSubscription> rooms,
RocketChatConfig config, JsonElement input, CancellationToken ct)
{
var wanted = GetString(input, "room") ?? config.DefaultRoom;
if (string.IsNullOrWhiteSpace(wanted))
return (null, "'room' ist erforderlich (kein Standardraum konfiguriert). "
+ Available(rooms));
var room = RocketChatClient.Resolve(rooms, wanted);
// Noch kein Gespräch mit dieser Person: Ein DM-Raum entsteht erst, wenn ihn jemand
// eröffnet. Nur bei ausdrücklicher @-Schreibweise — sonst würde ein vertippter
// Kanalname stillschweigend zu einer Direktnachricht.
if (room is null && wanted.StartsWith('@'))
{
var username = wanted[1..];
if (!config.IsRoomAllowed(username))
return (null, $"Direktnachrichten an '{username}' sind für diesen Agenten nicht "
+ "freigegeben (siehe 'allowedRooms' in der Tool-Konfiguration).");
try
{
room = await client.OpenDirectMessageAsync(username, ct);
}
catch (RocketChatException ex)
{
return (null, $"Direktnachricht an '{username}' nicht möglich: {ex.Message}");
}
if (room is null)
return (null, $"Direktnachricht an '{username}' konnte nicht geöffnet werden.");
return (room, null);
}
if (room is null)
return (null, $"Kein Raum '{wanted}' — dieser Agent ist dort kein Mitglied. " + Available(rooms)
+ " Für eine neue Direktnachricht '@benutzername' angeben.");
// Gegen beide Schreibweisen prüfen: Die Allowlist darf Ids oder Namen enthalten.
if (!config.IsRoomAllowed(room.RoomId) && !config.IsRoomAllowed(room.Name))
return (null, $"Zugriff auf Raum '{wanted}' ist für diesen Agenten nicht freigegeben "
+ "(siehe 'allowedRooms' in der Tool-Konfiguration).");
return (room, null);
}
private static string Available(IReadOnlyList<RoomSubscription> rooms)
=> rooms.Count == 0
? "Der Agent ist in keinem Raum Mitglied."
: "Verfügbar: " + string.Join(", ", rooms.Select(r => $"{r.Label} ({r.RoomId})"));
// ─── IToolJobProvider ───
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
[
new("rocketchat_poll", "Rocket.Chat Abruf",
"Prüft auf neue Erwähnungen und Direktnachrichten und weckt den Agenten nur, wenn etwas anliegt.")
];
public async Task<ToolJobResult> ExecuteJobAsync(
string jobTypeId,
IReadOnlyDictionary<string, object?> toolConfig,
IStateStore stateStore,
ILogger logger,
CancellationToken ct,
string? agentId = null,
string? workspacePath = null)
{
if (jobTypeId != "rocketchat_poll")
return ToolJobResult.NoAction($"Unbekannter Job-Typ: {jobTypeId}");
var (config, configError) = RocketChatConfig.Read(toolConfig);
if (config is null)
return ToolJobResult.NoAction(configError);
var client = new RocketChatClient(config);
var agent = agentId ?? "default";
try
{
var rooms = await client.GetRoomsAsync(ct);
// Vorfilter aus dem Sammelabruf: Nur Räume mit etwas Ungelesenem sind
// überhaupt einen zweiten Aufruf wert.
var candidates = rooms.Where(r => r.Unread > 0 || r.Alert).ToList();
logger.LogDebug(
"RocketChat: mentionOnly={MentionOnly}; Räume: {Rooms}",
config.MentionOnly,
string.Join(" | ", rooms.Select(r =>
$"{r.Label} t={r.Type} unread={r.Unread} mentions={r.UserMentions}/{r.GroupMentions} alert={r.Alert}")));
if (config.MentionOnly)
{
// Ohne diese Regel antworten in einem Raum mit drei Agenten alle drei auf
// jede Nachricht. Direktnachrichten sind immer gemeint.
candidates = candidates
.Where(r => r.IsDirect || r.UserMentions > 0 || r.GroupMentions > 0)
.ToList();
}
candidates = candidates
.Where(r => config.IsRoomAllowed(r.RoomId) || config.IsRoomAllowed(r.Name))
.ToList();
if (candidates.Count == 0)
return ToolJobResult.NoAction("Nichts Neues");
var blocks = new List<string>();
var total = 0;
foreach (var room in candidates)
{
var messages = await CollectNewMessagesAsync(client, config, stateStore, agent, room, logger, ct);
if (messages.Count > 0
&& await TryConsumeWakeBudgetAsync(stateStore, agent, room, config, logger, ct))
{
total += messages.Count;
blocks.Add(FormatRoom(room, messages));
}
// Jeden geprüften Raum als gelesen markieren — auch wenn nichts zu wecken
// war. Sonst bliebe eine alte, nie gelesene Erwähnung stehen und der Raum
// wäre bei jedem Takt erneut ein Kandidat.
try
{
await client.MarkReadAsync(room.RoomId, ct);
}
catch (RocketChatException ex)
{
logger.LogWarning("RocketChat: Raum {Room} konnte nicht als gelesen markiert werden: {Message}",
room.Label, ex.Message);
}
}
if (blocks.Count == 0)
return ToolJobResult.NoAction("Nichts Neues (nach Filterung)");
logger.LogInformation("RocketChat: {Count} neue Nachricht(en) in {Rooms} Raum/Räumen",
total, blocks.Count);
return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} neue Nachricht(en)");
}
catch (RocketChatException ex)
{
logger.LogWarning("RocketChat-Abruf fehlgeschlagen: {Message}", ex.Message);
return ToolJobResult.NoAction($"Abruf-Fehler: {ex.Message}");
}
catch (HttpRequestException ex)
{
logger.LogWarning("Rocket.Chat ist nicht erreichbar: {Message}", ex.Message);
return ToolJobResult.NoAction($"Nicht erreichbar: {ex.Message}");
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return ToolJobResult.NoAction("Zeitüberschreitung beim Abruf");
}
}
/// <summary>
/// Holt die neuen Nachrichten eines Raums und filtert alles heraus, was keinen Agenten
/// wecken darf: eigene Beiträge, Systemmeldungen und — sofern nicht namentlich
/// erwähnt — Beiträge anderer Agenten. Der letzte Punkt ist die Bremse gegen
/// Agentenschleifen: Ohne ihn antwortet A auf B auf A.
/// </summary>
private static async Task<IReadOnlyList<RoomMessage>> CollectNewMessagesAsync(
RocketChatClient client, RocketChatConfig config, IStateStore stateStore,
string agent, RoomSubscription room, ILogger logger, CancellationToken ct)
{
var markerKey = $"rocketchat:{agent}:{room.RoomId}:lastTs";
var markerRaw = await stateStore.GetAsync(markerKey, ct);
DateTime since;
if (string.IsNullOrWhiteSpace(markerRaw))
{
// Erster Abruf für diesen Raum. Nicht überspringen — ein Raum wird erst dann
// zum Kandidaten, wenn Verkehr da ist, und genau die auslösende Nachricht
// würde sonst verschluckt. Stattdessen begrenzt zurückschauen: neu genug, um
// das Aktuelle zu erwischen, kurz genug, um beim Einrichten nicht die halbe
// Raumgeschichte in den Kontext zu kippen.
since = DateTime.UtcNow - config.InitialLookback;
logger.LogInformation(
"RocketChat: erster Abruf für {Room} — {Minutes} Minuten Rückschau",
room.Label, config.InitialLookback.TotalMinutes);
}
else
{
since = DateTime.TryParse(markerRaw, null,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: DateTime.UtcNow - config.InitialLookback;
}
var history = await client.GetHistoryAsync(room, since, config.MaxMessagesPerRoom, ct);
if (history.Count == 0)
return [];
// Die Marke wandert auf die jüngste *gesehene* Nachricht — auch auf gefilterte.
// Sonst würde eine ignorierte Agentennachricht bei jedem Takt erneut geprüft.
await stateStore.SetAsync(markerKey, history[^1].Timestamp.ToString("O"), ct);
return history
.Where(m => !m.IsSystem)
.Where(m => m.UserId != config.UserId)
// Die Erwähnungsprüfung gehört an die einzelne Nachricht, nicht an den Raum:
// Der Zähler aus subscriptions.get zählt *ungelesene* Erwähnungen und bleibt
// stehen, solange nichts gelesen wurde. Eine einzige alte Erwähnung würde den
// Raum sonst dauerhaft „heiß" halten und jede weitere Nachricht ausliefern.
.Where(m => !config.MentionOnly || room.IsDirect || IsMentioned(m, config))
.Where(m => !config.IsAgentUser(m.Username) || IsMentioned(m, config))
.ToList();
}
/// <summary>
/// Erwähnungen liefert Rocket.Chat als Benutzernamen — verglichen wird deshalb gegen
/// <c>username</c>, nicht gegen die Benutzer-Id.
///
/// Ist kein Benutzername konfiguriert, gilt eine Nachricht als nicht erwähnt. Das ist
/// die vorsichtige Richtung: Im Zweifel weckt die Nachricht eines anderen Agenten
/// diesen hier nicht, statt eine Antwortschleife zu riskieren.
/// </summary>
private static bool IsMentioned(RoomMessage message, RocketChatConfig config)
=> message.Mentions.Any(m =>
// @all und @here meinen ausdrücklich auch diesen Agenten.
string.Equals(m, "all", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m, "here", StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrWhiteSpace(config.Username)
&& string.Equals(m, config.Username, StringComparison.OrdinalIgnoreCase)));
/// <summary>
/// Deckelt, wie oft ein Raum den Agenten je Stunde wecken darf. Jeder Weckvorgang ist
/// ein bezahlter Lauf; ohne Deckel wird ein lebhafter Raum zur Kostenfalle.
/// </summary>
private static async Task<bool> TryConsumeWakeBudgetAsync(
IStateStore stateStore, string agent, RoomSubscription room,
RocketChatConfig config, ILogger logger, CancellationToken ct)
{
if (config.MaxWakesPerRoomPerHour <= 0)
return true;
var bucket = DateTime.UtcNow.ToString("yyyyMMddHH");
var key = $"rocketchat:{agent}:{room.RoomId}:wakes:{bucket}";
var used = int.TryParse(await stateStore.GetAsync(key, ct), out var n) ? n : 0;
if (used >= config.MaxWakesPerRoomPerHour)
{
logger.LogWarning(
"RocketChat: Weckgrenze für {Room} erreicht ({Max}/Stunde) — Nachrichten bleiben ungelesen",
room.Label, config.MaxWakesPerRoomPerHour);
return false;
}
await stateStore.SetAsync(key, (used + 1).ToString(), ct);
return true;
}
private static string FormatRoom(RoomSubscription room, IReadOnlyList<RoomMessage> messages)
{
var sb = new StringBuilder();
sb.AppendLine($"Raum: {room.Label} (roomId: {room.RoomId})");
foreach (var m in messages)
{
sb.AppendLine($"[{m.Timestamp:HH:mm}] @{m.Username} (messageId: {m.Id}): {m.Text}");
// Ohne diese Zeilen sähe der Agent bei einer reinen Dateisendung nur einen
// leeren Beitrag — die Datei liegt nicht im Text, sondern daneben.
foreach (var f in m.Files)
sb.AppendLine($" Datei: {f.Describe()}");
foreach (var l in m.Links)
sb.AppendLine($" Link: {l.Url}"
+ (l.Title is null ? "" : $" — {Shorten(l.Title, 120)}"));
}
return sb.ToString().TrimEnd();
}
private static string Shorten(string value, int max)
{
var flat = value.ReplaceLineEndings(" ").Trim();
return flat.Length <= max ? flat : flat[..max] + "…";
}
private static string BuildWakeMessage(IReadOnlyList<string> blocks)
=> $"""
Neue Nachrichten in Rocket.Chat.
{Untrusted(string.Join("\n\n", blocks))}
Wenn du antworten möchtest, nutze das Tool RocketChat mit der roomId aus dem
jeweiligen Block (send_message), oder reply mit der messageId als threadId.
""";
private static string Untrusted(string content)
=> $"<untrusted_content>\n{content}\n</untrusted_content>";
// ─── Eingabe-Helfer ───
private static string? GetString(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static int? GetInt(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
&& v.TryGetInt32(out var i)
? i
: null;
}
@@ -0,0 +1,105 @@
namespace ClawdDotNet.Tools.RocketChat;
/// <summary>
/// Löst einen Workspace-Pfad mit den im Projekt üblichen Prefixen auf
/// (<c>personal:</c>, <c>shared:</c>, ohne Prefix = persönlicher Workspace) — gleiche
/// Schreibweise wie beim FileRW- und FTP-Tool, damit ein Agent nicht je Tool umdenken muss.
///
/// Die Prüfung gegen einen Ausbruch aus dem Verzeichnis ist der eigentliche Zweck: Ohne sie
/// könnte ein Agent per <c>../../</c> beliebige Dateien des Rechners in einen Chatraum
/// schieben.
/// </summary>
internal static class WorkspaceFile
{
public sealed record Resolved(string FullPath, string Label);
public static (Resolved? File, string? Error) Resolve(
string path, string? personalRoot, string? sharedRoot)
{
string relative;
string? root;
string label;
if (path.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
{
relative = path[7..];
root = sharedRoot;
label = "shared";
}
else if (path.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
{
relative = path[9..];
root = personalRoot;
label = "personal";
}
else
{
relative = path;
root = personalRoot;
label = "personal";
}
if (string.IsNullOrWhiteSpace(root))
return (null, $"Kein {label}-Workspace konfiguriert.");
if (string.IsNullOrWhiteSpace(relative))
return (null, "Es wurde kein Dateiname angegeben.");
// Auf Windows trennt ':' einen NTFS-Alternativdatenstrom ab. Ein Pfad wie
// "bericht.txt:versteckt" schreibt dann neben die sichtbare Datei, und die
// Verzeichnisprüfung unten würde ihn trotzdem durchwinken.
if (relative.Contains(':'))
return (null, "Doppelpunkte sind im Pfad nicht erlaubt.");
var basePath = Path.GetFullPath(root);
string fullPath;
try
{
fullPath = Path.GetFullPath(Path.Combine(basePath, relative));
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return (null, $"Ungültiger Pfad: {path}");
}
// Der abschließende Trenner verhindert, dass "…/workspace-geheim" als Treffer
// für "…/workspace" durchgeht.
var fence = basePath.EndsWith(Path.DirectorySeparatorChar)
? basePath
: basePath + Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(fence, StringComparison.OrdinalIgnoreCase))
return (null, $"Zugriff außerhalb des {label}-Workspace verweigert.");
return (new Resolved(fullPath, label), null);
}
/// <summary>
/// MIME-Typ nach Dateiendung. Rocket.Chat zeigt Bilder und PDFs nur dann in der
/// Vorschau, wenn der Typ stimmt — <c>application/octet-stream</c> landet immer als
/// stummer Download.
/// </summary>
public static string GuessContentType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".txt" or ".log" => "text/plain",
".md" => "text/markdown",
".csv" => "text/csv",
".json" => "application/json",
".xml" => "application/xml",
".html" or ".htm" => "text/html",
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".zip" => "application/zip",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".odt" => "application/vnd.oasis.opendocument.text",
".ods" => "application/vnd.oasis.opendocument.spreadsheet",
_ => "application/octet-stream"
};
}
@@ -0,0 +1,87 @@
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Deploymentcenter;
public sealed class WatchdogHeartbeatServiceTests
{
/// <summary>Merkt sich, was gesendet wurde, ohne Netzwerk.</summary>
private sealed class RecordingClient : IWatchdogClient
{
public List<(string Status, string? Message)> Heartbeats { get; } = [];
public List<(string Kind, string? Message)> Events { get; } = [];
public Task<WatchdogPingResult> SendHeartbeatAsync(
InstanceHealth health, int intervalSeconds, CancellationToken ct)
{
Heartbeats.Add((health.Status, health.Message));
return Task.FromResult(new WatchdogPingResult("up", []));
}
public Task SendEventAsync(
string kind, string severity, string? message, object? meta, CancellationToken ct)
{
Events.Add((kind, message));
return Task.CompletedTask;
}
}
private sealed class FixedHealth(InstanceHealth health) : IInstanceHealthProvider
{
public Task<InstanceHealth> GetAsync(CancellationToken ct) => Task.FromResult(health);
}
private static WatchdogHeartbeatService Build(RecordingClient client) =>
new(client, new FixedHealth(InstanceHealth.Ok("läuft")), 3600, NullLogger.Instance);
[Fact]
public async Task Regulaeres_Ende_meldet_stopped()
{
var client = new RecordingClient();
var service = Build(client);
service.Start();
await service.DisposeAsync();
client.Heartbeats[^1].Status.ShouldBe(WatchdogStatus.Stopped);
client.Events[^1].Kind.ShouldBe(WatchdogEventKind.StoppedGraceful);
}
/// <summary>
/// Beim Update ist die Instanz gleich weg, kommt aber wieder. <c>stopped</c> wäre
/// die falsche Auskunft: Der Monitor bliebe liegen, bis jemand ihn anfasst.
/// </summary>
[Fact]
public async Task Angekuendigte_Wartung_meldet_maintenance()
{
var client = new RecordingClient();
var service = Build(client);
service.Start();
service.AnnounceMaintenance("Update auf 0.1.1 wird eingespielt.");
await service.DisposeAsync();
var last = client.Heartbeats[^1];
last.Status.ShouldBe(WatchdogStatus.Maintenance);
last.Message.ShouldContain("0.1.1");
client.Events[^1].Kind.ShouldBe(WatchdogEventKind.MaintenanceStart);
}
/// <summary>Der erste Beat geht sofort raus, nicht erst nach einem vollen Intervall.</summary>
[Fact]
public async Task Erster_Heartbeat_kommt_sofort()
{
var client = new RecordingClient();
var service = Build(client);
service.Start();
await service.DisposeAsync();
// Start-Ereignis, mindestens ein Beat, dann die Abmeldung.
client.Events[0].Kind.ShouldBe(WatchdogEventKind.Started);
client.Heartbeats.Count.ShouldBeGreaterThanOrEqualTo(2);
client.Heartbeats[0].Status.ShouldBe(WatchdogStatus.Ok);
}
}
@@ -76,7 +76,14 @@ public sealed class SecretProtectorTests : IDisposable
[Fact]
public void Klartext_aus_alten_Konfigurationen_wird_unveraendert_gelesen()
{
// Bestehende Installationen müssen ohne Zutun weiterlaufen.
// Zwei Gründe, nicht nur einer:
//
// 1. Bestehende Installationen müssen ohne Zutun weiterlaufen.
// 2. Die Erstinstallation über den update-agent schreibt Lizenzschlüssel und
// Deploymentcenter-Token als Klartext in die Settings.json — der Installer
// kennt unsere Verschlüsselung nicht. Beim ersten Speichern werden sie
// übernommen und verschlüsselt. Wer diesen Zweig entfernt, weil er nach
// Altlast aussieht, sperrt jede frisch installierte Instanz aus.
SecretProtector.Unprotect("sk-or-alter-klartext").ShouldBe("sk-or-alter-klartext");
}