Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions web-ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,39 @@ When you add or change any user-facing string:

Do **not** hardcode user-facing strings in components. See `messages/README.md`
for the full key-naming convention, ICU placeholders, and `t.rich` usage.

## String-handling checklist (read before touching any component)

This rule was violated ~860 times before PR #447 swept the codebase clean.
Keep it clean:

1. **No user-facing literals in `.ts`/`.tsx` — in any language.** Everything a
user can see goes through the catalog: JSX text, placeholders, `aria-label`,
`title` attributes, toasts, error messages, empty states, badge labels.
2. **German belongs only in `messages/de.json`.** If you are typing German
words in a component file, stop — add a key instead.
3. **Error messages are user-facing strings too.** Never render a raw
`ApiError`/exception message as the primary UI text; give it a catalog key
and put the technical detail behind it.
4. **No hardcoded locale formatting.** No `toLocaleString('de-DE')`, no
`Intl.*` with a fixed locale, no hardcoded `Europe/Berlin` — use
next-intl's `useFormatter()` so numbers, dates, and timezones follow the
active locale.
5. **Self-check your diff before committing** — flag any added string literal
containing German (umlauts or common words):

```bash
git diff --cached -U0 -- '*.ts' '*.tsx' | grep -E '^\+' \
| grep -E '[äöüÄÖÜß]|"(Speichern|Abbrechen|Fehler|Keine|Laden|Bitte|Wird)'
```

Hits in comments or the exceptions below are fine; hits in string literals
are not — convert them before committing.
6. **Deliberate exceptions — do not "fix" them, and do not cite them as
precedent for new hardcoded strings:**
- `app/global-error.tsx` — intentionally bilingual; it renders when the
intl provider itself has failed.
- `MOCK_KG_WALK` in `app/chat/page.tsx` — dev-only fixture behind `?kgmock=1`.
- `app/_lib/personaTemplates.ts`, `app/_lib/toolTemplates.ts`,
`app/_lib/composeFixPrompt.ts` — persona/tool/prompt *content* compiled
into agents, not UI chrome.
48 changes: 31 additions & 17 deletions web-ui/app/_components/chat/CaptureDisclosure.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
'use client';

import { useTranslations } from 'next-intl';

import type { CaptureDisclosure as CaptureDisclosureData } from '../../_lib/chatSessions';

interface CaptureDisclosureProps {
disclosure: CaptureDisclosureData;
className?: string;
}

type TFn = (key: string, values?: Record<string, string | number>) => string;

/**
* Per-turn audit row showing what the orchestrator persisted into the
* Palaia / knowledge-graph layer. Native `<details>` element — same
Expand All @@ -22,10 +26,11 @@ export function CaptureDisclosure({
disclosure,
className,
}: CaptureDisclosureProps): React.ReactElement | null {
const summary = summarise(disclosure);
const t = useTranslations('chat.captureDisclosure');
const summary = summarise(disclosure, t);
if (summary === null) return null;

const facts = collectFacts(disclosure);
const facts = collectFacts(disclosure, t);

return (
<details
Expand All @@ -36,7 +41,7 @@ export function CaptureDisclosure({
].join(' ')}
>
<summary className="cursor-pointer select-none px-2 py-1 font-medium text-[color:var(--success)]">
🧠 Memory-Auswirkung · {summary}
🧠 {t('heading')} · {summary}
</summary>
<div className="space-y-2 px-2 pb-2 pt-1 text-[color:var(--success)]">
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1">
Expand All @@ -52,7 +57,7 @@ export function CaptureDisclosure({
{disclosure.reasons.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-[color:var(--success)]/80">
Begründung
{t('reasonsHeading')}
</div>
<ul className="mt-1 space-y-0.5">
{disclosure.reasons.map((reason, i) => (
Expand All @@ -69,7 +74,9 @@ export function CaptureDisclosure({
{disclosure.graphRefs && disclosure.graphRefs.entityNodeIds.length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-[color:var(--success)]/80">
Verknüpfte Entitäten ({disclosure.graphRefs.entityNodeIds.length})
{t('linkedEntities', {
count: disclosure.graphRefs.entityNodeIds.length,
})}
</div>
<ul className="mt-1 max-h-32 space-y-0.5 overflow-y-auto">
{disclosure.graphRefs.entityNodeIds.map((id) => (
Expand All @@ -93,18 +100,18 @@ interface Fact {
value: string;
}

function summarise(d: CaptureDisclosureData): string | null {
function summarise(d: CaptureDisclosureData, t: TFn): string | null {
if (!d.persisted) {
if (d.significance !== null) {
return `verworfen · score ${d.significance.toFixed(2)}`;
return t('summaryDiscardedScore', { score: d.significance.toFixed(2) });
}
return 'verworfen';
return t('summaryDiscarded');
}
const parts: string[] = ['persistiert'];
const parts: string[] = [t('summaryPersisted')];
if (d.entryType) parts.push(d.entryType);
if (d.significance !== null) parts.push(d.significance.toFixed(2));
if (d.privacyBlocksStripped > 0) {
parts.push(`–${String(d.privacyBlocksStripped)}×privat`);
parts.push(t('summaryPrivateStripped', { count: d.privacyBlocksStripped }));
}
// Quiet fall-through: pre-OB-71 stub disclosures with no actionable data
// (default-persist, no strip, no score) collapse to just "persistiert" —
Expand All @@ -114,28 +121,35 @@ function summarise(d: CaptureDisclosureData): string | null {
return parts.join(' · ');
}

function collectFacts(d: CaptureDisclosureData): Fact[] {
function collectFacts(d: CaptureDisclosureData, t: TFn): Fact[] {
const facts: Fact[] = [
{ title: 'Persistiert', value: d.persisted ? '✓ ja' : '✗ verworfen' },
{
title: t('factPersisted'),
value: d.persisted ? t('factPersistedYes') : t('factPersistedNo'),
},
];
if (d.entryType) facts.push({ title: 'Eintrag-Typ', value: d.entryType });
if (d.visibility) facts.push({ title: 'Sichtbarkeit', value: d.visibility });
if (d.entryType) {
facts.push({ title: t('factEntryType'), value: d.entryType });
}
if (d.visibility) {
facts.push({ title: t('factVisibility'), value: d.visibility });
}
if (d.significance !== null) {
facts.push({ title: 'Significance', value: d.significance.toFixed(2) });
}
facts.push({
title: 'Embedding',
value: d.embedded ? '✓ vektorisiert' : '— kein Vektor',
value: d.embedded ? t('factEmbedded') : t('factNotEmbedded'),
});
if (d.privacyBlocksStripped > 0) {
facts.push({
title: '<private>-Blöcke',
title: t('factPrivateBlocks'),
value: String(d.privacyBlocksStripped),
});
}
if (d.hintTagsProcessed > 0) {
facts.push({
title: '<palaia-hint>-Tags',
title: t('factHintTags'),
value: String(d.hintTagsProcessed),
});
}
Expand Down
4 changes: 3 additions & 1 deletion web-ui/app/_components/store/ActionStatusBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useTranslations } from 'next-intl';

import type { PluginActionStatus } from '../../_lib/storeTypes';

Expand All @@ -19,6 +20,7 @@ export function ActionStatusBanner({
pluginId: string;
initial?: PluginActionStatus;
}): React.ReactElement | null {
const t = useTranslations('store.actionStatus');
const [status, setStatus] = useState<PluginActionStatus | undefined>(initial);

useEffect(() => {
Expand Down Expand Up @@ -66,7 +68,7 @@ export function ActionStatusBanner({
/>
<div className="min-w-0">
<p className="text-[14px] font-semibold text-[color:var(--fg-strong)]">
{status.title ?? (isError ? 'Fehler' : 'Aktion erforderlich')}
{status.title ?? (isError ? t('errorTitle') : t('actionRequired'))}
</p>
{status.detail ? (
<p className="mt-1 text-[13px] leading-relaxed text-[color:var(--fg-muted)]">
Expand Down
38 changes: 24 additions & 14 deletions web-ui/app/_components/store/AuditModeSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,32 @@

import { useCallback, useEffect, useState } from 'react';
import { ShieldAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';

import { ApiError, listInstalledSecretKeys, setAuditMode } from '../../_lib/api';
import type { AuditMode } from '../../_lib/storeTypes';

const MODES: ReadonlyArray<{ value: AuditMode; label: string; help: string }> = [
/** Mode labels are technical identifiers (same in every locale); the help
* copy lives in the catalog under `store.auditMode.<helpKey>`. */
const MODES: ReadonlyArray<{
value: AuditMode;
label: string;
helpKey: string;
}> = [
{
value: 'single-host',
label: 'Single-Host',
help: 'Nur die im Manifest deklarierten Hosts.',
helpKey: 'singleHostHelp',
},
{
value: 'allowlist',
label: 'Allowlist',
help: 'Manifest-Hosts plus die vom Operator gepflegte Host-Liste.',
helpKey: 'allowlistHelp',
},
{
value: 'public-web',
label: 'Public-Web',
help: 'Beliebige öffentliche Hosts. Private Netzbereiche und Cloud-Metadata-Endpoints bleiben blockiert.',
helpKey: 'publicWebHelp',
},
];

Expand All @@ -51,6 +58,7 @@ export function AuditModeSwitch({
}: {
pluginId: string;
}): React.ReactElement {
const t = useTranslations('store.auditMode');
const [mode, setMode] = useState<AuditMode>('single-host');
const [status, setStatus] = useState<Status>({ kind: 'loading' });

Expand Down Expand Up @@ -86,8 +94,8 @@ export function AuditModeSwitch({
if (widening) {
const ok = window.confirm(
next === 'public-web'
? 'Public-Web erlaubt diesem Plugin, beliebige öffentliche Hosts zu kontaktieren. Private Netzbereiche und Cloud-Metadata-Endpoints bleiben blockiert. Fortfahren?'
: 'Allowlist erlaubt diesem Plugin zusätzlich die operator-gepflegte Host-Liste. Fortfahren?',
? t('confirmPublicWeb')
: t('confirmAllowlist'),
);
if (!ok) return;
}
Expand All @@ -108,7 +116,7 @@ export function AuditModeSwitch({
});
}
},
[mode, pluginId],
[mode, pluginId, t],
);

const busy = status.kind === 'loading' || status.kind === 'saving';
Expand All @@ -118,8 +126,10 @@ export function AuditModeSwitch({
<div className="flex items-start gap-2 text-[13px] text-[color:var(--fg-muted)]">
<ShieldAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<p>
Steuert, welche Hosts dieses Audit-Plugin über <code>ctx.http</code>{' '}
erreichen darf. Standard ist <strong>Single-Host</strong>.
{t.rich('intro', {
code: (chunks) => <code>{chunks}</code>,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
<div className="flex flex-col gap-2">
Expand Down Expand Up @@ -147,7 +157,7 @@ export function AuditModeSwitch({
{m.label}
</span>
<span className="text-[12px] text-[color:var(--fg-muted)]">
{m.help}
{t(m.helpKey)}
</span>
</span>
</label>
Expand All @@ -156,20 +166,20 @@ export function AuditModeSwitch({
<div className="min-h-[20px] text-[12px]">
{status.kind === 'loading' && (
<span className="inline-flex items-center gap-2 text-[color:var(--fg-muted)]">
<span className="lume-busy-dots" aria-hidden /> lädt …
<span className="lume-busy-dots" aria-hidden /> {t('loading')}
</span>
)}
{status.kind === 'saving' && (
<span className="inline-flex items-center gap-2 text-[color:var(--fg-muted)]">
<span className="lume-busy-dots" aria-hidden /> speichert …
<span className="lume-busy-dots" aria-hidden /> {t('saving')}
</span>
)}
{status.kind === 'saved' && (
<span className="text-[color:var(--accent)]">Modus gespeichert.</span>
<span className="text-[color:var(--accent)]">{t('saved')}</span>
)}
{status.kind === 'error' && (
<span className="text-[color:var(--danger)]">
Fehler: {status.message}
{t('error', { message: status.message })}
</span>
)}
</div>
Expand Down
Loading
Loading