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
31 changes: 15 additions & 16 deletions apps/desktop/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface PermissionContext {
const fromTrustedPage = ({ origin, fromMainWindow, trustedOrigins }: PermissionContext): boolean =>
fromMainWindow && trustedOrigins.some((trusted) => origin === trusted || origin.startsWith(`${trusted}/`));

/** The one permission Archivist grants: the microphone (and nothing else, not the camera either), to its own page. */
/** The microphone (not the camera, alone or together with it), for Archivist's own page. */
export function allowsMicrophoneRequest({ permission, mediaTypes, ...context }: PermissionContext & { permission: string; mediaTypes?: string[] }): boolean {
return permission === 'media' && mediaTypes?.length === 1 && mediaTypes[0] === 'audio' && fromTrustedPage(context);
}
Expand All @@ -22,23 +22,22 @@ export function allowsMicrophoneCheck({ permission, mediaType, ...context }: Per
return permission === 'media' && mediaType === 'audio' && fromTrustedPage(context);
}

/** Denies every permission (camera, location …) except the microphone for the main window's own pages (speech input in the chat). */
/** Writing to the clipboard (copying a chat answer); reading it stays denied, pasting needs no permission. */
export function allowsClipboardWrite({ permission, ...context }: PermissionContext & { permission: string }): boolean {
return permission === 'clipboard-sanitized-write' && fromTrustedPage(context);
}

/** Denies every permission (camera, location, reading the clipboard …) except the microphone and clipboard writes for the main window's own pages. */
export function restrictPermissions(
session: Pick<Session, 'setPermissionRequestHandler' | 'setPermissionCheckHandler'>,
{ trustedOrigins, isMainWindow }: { trustedOrigins: string[]; isMainWindow: (contents: WebContents | null) => boolean },
): void {
session.setPermissionRequestHandler((contents, permission, respond, details) =>
respond(
allowsMicrophoneRequest({
permission,
mediaTypes: 'mediaTypes' in details ? details.mediaTypes : undefined,
origin: details.requestingUrl,
fromMainWindow: isMainWindow(contents),
trustedOrigins,
}),
),
);
session.setPermissionCheckHandler((contents, permission, requestingOrigin, details) =>
allowsMicrophoneCheck({ permission, mediaType: details.mediaType, origin: requestingOrigin, fromMainWindow: isMainWindow(contents), trustedOrigins }),
);
session.setPermissionRequestHandler((contents, permission, respond, details) => {
const context = { permission, origin: details.requestingUrl, fromMainWindow: isMainWindow(contents), trustedOrigins };
respond(allowsMicrophoneRequest({ ...context, mediaTypes: 'mediaTypes' in details ? details.mediaTypes : undefined }) || allowsClipboardWrite(context));
});
session.setPermissionCheckHandler((contents, permission, requestingOrigin, details) => {
const context = { permission, origin: requestingOrigin, fromMainWindow: isMainWindow(contents), trustedOrigins };
return allowsMicrophoneCheck({ ...context, mediaType: details.mediaType }) || allowsClipboardWrite(context);
});
}
49 changes: 49 additions & 0 deletions apps/renderer/components/chat/answer-tools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import { useEffect, useState } from 'react';
import { Check, Code, Copy } from 'lucide-react';
import { IconAction } from '@/components/common/icon-action';
import { useToast } from '@/lib/toast';

const COPIED_FEEDBACK_MS = 2000;

/** The answer as written (Markdown source), selectable and unformatted. */
export function RawAnswer({ text }: { text: string }) {
return (
<pre className="whitespace-pre-wrap break-words rounded-lg border bg-muted/40 p-3 font-mono text-xs" data-testid="chat-message-raw">
{text}
</pre>
);
}

/** „Antwort kopieren“ and the switch between formatted and raw text below an answer; both use the text as written. */
export function AnswerTools({ text, raw, onToggleRaw }: { text: string; raw: boolean; onToggleRaw: () => void }) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);

useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
return () => clearTimeout(timer);
}, [copied]);

async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
toast({ variant: 'error', title: 'Kopieren fehlgeschlagen', description: 'Markiere den Text und kopiere ihn mit Strg+C.' });
}
}

return (
<div className="-ml-1.5 -mt-2 flex items-center gap-0.5">
<IconAction label={copied ? 'Kopiert' : 'Antwort kopieren'} onClick={() => void copy()} data-testid="chat-copy">
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
</IconAction>
<IconAction label="Als Rohtext anzeigen" aria-pressed={raw} onClick={onToggleRaw} data-testid="chat-raw-toggle">
<Code aria-hidden />
</IconAction>
</div>
);
}
5 changes: 4 additions & 1 deletion apps/renderer/components/chat/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AlertCircle, ExternalLink, FileText, HelpCircle } from 'lucide-react';
import { RunSummary } from '@/components/agent/run-summary';
import { ActionCard } from '@/components/common/action-card';
import { DocumentDialog } from '@/components/documents/document-dialog';
import { AnswerTools, RawAnswer } from './answer-tools';
import { SentAttachments } from './attachment-chips';
import { isLinkSuggestion, LinkSuggestions } from './link-suggestions';
import { EntityIcon } from '@/components/common/entity-chip';
Expand Down Expand Up @@ -91,6 +92,7 @@ export function ChatBubble({
onQuickReply?: (text: string) => void;
}) {
const isUser = message.role === 'user';
const [raw, setRaw] = useState(false);
return (
<div className={cn('flex w-full', isUser ? 'justify-end' : 'justify-start')}>
<div
Expand All @@ -106,7 +108,8 @@ export function ChatBubble({
</>
) : (
<div className="flex flex-col gap-3">
{message.content && <Markdown text={message.content} />}
{message.content && (raw ? <RawAnswer text={message.content} /> : <Markdown text={message.content} />)}
{message.content && !pending && <AnswerTools text={message.content} raw={raw} onToggleRaw={() => setRaw((shown) => !shown)} />}
{message.errorMessage && (
<div role="alert" className="flex gap-2 rounded-lg border border-destructive/40 bg-destructive/8 p-3" data-testid="chat-error">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/aktionsstufen.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Einstellungen → Datenschutz. Bedienung: [Datenschutz einstellen](../how-to/dat
## Electron

- `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, kein `eval`.
- Navigation und `window.open` gesperrt, Berechtigungsanfragen abgelehnt. **Einzige Ausnahme:** das Mikrofon (genau `audio`, nie die Kamera) für das Hauptfenster auf `app://archivist` – die Spracheingabe im Chat. Der Renderer fragt erst nach einem Klick auf das Mikrofon; die Aufnahme bleibt im Renderer-Speicher und geht nur über `speech:transcribe` an den lokalen Worker.
- Navigation und `window.open` gesperrt, Berechtigungsanfragen abgelehnt. **Zwei Ausnahmen**, beide nur für das Hauptfenster auf `app://archivist`: das Mikrofon (genau `audio`, nie die Kamera) für die Spracheingabe im Chat und das Schreiben in die Zwischenablage (`clipboard-sanitized-write`) für „Antwort kopieren“. Der Renderer fragt erst nach einem Klick auf das Mikrofon; die Aufnahme bleibt im Renderer-Speicher und geht nur über `speech:transcribe` an den lokalen Worker. Die Zwischenablage lesen darf der Renderer nicht; Einfügen mit Strg+V braucht keine Berechtigung.
- Auslieferung über ein eigenes `app://`-Protokoll (kein HTTP-Server, kein `file://`) mit strenger CSP: `default-src 'none'`, Skripte nur `self` + SHA-256-Hashes der von Next.js erzeugten Inline-Skripte, `connect-src 'self'`.
- IPC: explizite Kanal-Allowlist, Absender-Prüfung (Frame-URL + WebContents), Zod-Validierung von Ein- **und** Ausgaben.
- Der Renderer hat keinen Zugriff auf Node, Dateisystem, Datenbank, Shell oder Credential Store. Dateien öffnet nur der Main-Prozess und nur solche, die Archivist kennt.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/funktionen.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Der Chat ist die zentrale Schnittstelle.
- **Wissensgraph in Antworten**: Bestätigte Verknüpfungen der besten Treffer bringen weitere Quellen mit (z. B. die Entscheidung, die ein gefundenes Dokument stützt); unter der Quelle steht, über welche Verknüpfung sie dazukam. Doppelte Dokumente (gleiche Datei, gleicher oder nahezu gleicher Text oder bestätigte Duplikat-Verknüpfung) belegen nur einen Antwortplatz.
- **Spracheingabe**: Das Mikrofon neben „Senden“ nimmt auf (höchstens 2 Minuten) und hängt den erkannten Text an das Eingabefeld an; gesendet wird nichts von selbst. Die Erkennung (Whisper, Deutsch) läuft lokal in einem eigenen Worker-Thread, den Archivist beim ersten Diktat startet und nach 5 Minuten Pause wieder beendet. Das Modell wählst du unter Einstellungen → Archiv → Spracheingabe aus einer festen Liste (`small`, `medium`, `turbo`; Einstellung `speech.model`). Archivist lädt das gewählte Modell einmalig nach einer Bestätigung nach `index/models/<Modell>/` herunter, mit festgelegter Version (Commit) und Prüfsumme je Datei; ein heruntergeladenes Modell lässt sich dort auch wieder löschen. Im Modus „nur lokal“ geschieht der Download nicht. Stille ergibt keinen Text, ohne das Modell zu fragen (Whisper erfindet sonst welchen). Aufnahmen werden nicht gespeichert. Anleitung: [Spracheingabe einrichten](../how-to/spracheingabe-einrichten.md).
- **Dateien und Bilder anhängen**: Die Büroklammer im Chat (oder Dateien, die du auf den Chat ziehst oder mit Strg+V ins Eingabefeld einfügst) hängt Dateien an deine nächste Nachricht, höchstens 10 je Nachricht; du bleibst dabei im Chat. Jede Datei wird sofort wie ein normaler Import übernommen (Kopie in `inbox/`, Prüfsumme, Duplikaterkennung, Änderungsprotokoll, Analyse-Auftrag, Vorschlag in der Inbox) – nur ohne Import-Karte und ohne Benachrichtigung je Datei: Was nicht übernommen werden konnte (nicht unterstützter Typ, Quarantäne, Ordner), steht mit Grund am Anhang über dem Eingabefeld (`chat:attach`). Ein Duplikat hängt das vorhandene Dokument an. Ein Bild aus der Zwischenablage (z. B. ein Screenshot) heißt „Eingefügtes Bild ‹Datum› ‹Uhrzeit›.png“; wie Dateien ohne Speicherort (z. B. aus dem Browser gezogen) schickt das Fenster seinen Inhalt (höchstens 50 MB, `chat:attachContent`), Archivist legt ihn kurz in einen temporären Ordner, importiert ihn ohne Ursprungspfad und löscht die temporäre Datei wieder. Enthält die Zwischenablage auch Text (wie beim Kopieren aus Word oder Excel), fügt Strg+V nur den Text ein. Entfernst du einen Anhang vor dem Senden, bleibt das Dokument in der Inbox. Gesendet werden darf auch nur der Anhang ohne Text. Die Nachricht speichert ihre Anhänge (`messages.attachments`) und zeigt sie in der Sprechblase. Der Agent bekommt je Anhang Name, Typ, Größe und den Anfang des Textes (6 000 Zeichen, als Daten markiert und maskiert; ist das Dokument noch nicht analysiert, liest Archivist den Text dafür sofort selbst) und bei Bildern (PNG/JPG) zusätzlich das Bild selbst – siehe [LLM-Schnittstelle](llm-schnittstelle.md#bilder-im-chat). Ohne LLM wertet der regelbasierte Chat nur den Text der Nachricht aus; die Anhänge sind trotzdem importiert.
- **Antwort kopieren und als Rohtext anzeigen**: Unter jeder Antwort kopiert „Antwort kopieren“ ihren Text so, wie Archivist ihn geschrieben hat (Markdown, z. B. `**fett**`), in die Zwischenablage; das Häkchen zeigt kurz, dass es geklappt hat. „Als Rohtext anzeigen“ schaltet die Antwort zwischen formatierter Ansicht und unformatiertem Text um, den du auch in Teilen markieren und kopieren kannst. Die Umschaltung gilt nur für diese Antwort und nur, bis du den Chat verlässt.
- **Abbrechen**: Eine laufende Anfrage lässt sich abbrechen; Erledigtes bleibt, der Rest entfällt.
- **Ausfall-Schutz**: Nach einer Zeitüberschreitung oder einem unerreichbaren Endpunkt scheitern LLM-Anfragen 60 s lang sofort, statt erneut zu warten (der Verbindungstest geht immer durch).
- **Bestätigen per „ja“**: bestätigt nur Vorschläge, die **in diesem Gespräch** als Karte angezeigt werden und noch offen sind; sind es mehrere, fragt Archivist nach.
Expand Down
30 changes: 30 additions & 0 deletions tests/e2e/chat-copy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from './fixture';

test.describe('chat: copying an answer and showing it as raw text', () => {
const answer = 'Der Mietvertrag läuft **bis Ende 2026**.\n\n- Kündigung schriftlich\n- Frist: `3 Monate`';

test.beforeEach(async ({ llm, on, page }) => {
llm.agentTurns = [{ text: answer }];
await on(page).setup.do.complete(llm.url);
await on(page).chat.do.send('Wie lange läuft der Mietvertrag?');
await expect(on(page).chat.do.lastReply()).toContainText('bis Ende 2026');
});

test('„Antwort kopieren“ puts the answer as written on the clipboard', async ({ electronApp, on, page }) => {
await on(page).chat.do.copyLastReply();

await expect.poll(() => electronApp.evaluate(({ clipboard }) => clipboard.readText())).toBe(answer);
});

test('the raw-text switch shows the unformatted answer and switches back', async ({ on, page }) => {
const chat = on(page).chat;

await chat.do.toggleRawText();
await expect(chat.locators.answer.raw).toHaveText(answer);
await expect(chat.locators.answer.rawToggle).toHaveAttribute('aria-pressed', 'true');

await chat.do.toggleRawText();
await expect(chat.locators.answer.raw).toHaveCount(0);
await expect(chat.do.lastReply().locator('strong')).toHaveText('bis Ende 2026');
});
});
13 changes: 13 additions & 0 deletions tests/e2e/pages/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export function initChat(page: Page) {
sourceOpenFile: root.getByTestId('chat-source-open'),
/** The document dialog a source card opens in place. */
sourceDialog: page.getByTestId('document-dialog'),
/** Below an answer: „Antwort kopieren“, the raw-text switch and the answer as written while it is on. */
answer: {
copy: root.getByTestId('chat-copy'),
rawToggle: root.getByTestId('chat-raw-toggle'),
raw: root.getByTestId('chat-message-raw'),
},
/** In-app links in an answer (weekly review, agent answers). */
appLinks: root.getByTestId('app-link'),
conversationSelect: root.getByTestId('conversation-select'),
Expand Down Expand Up @@ -89,6 +95,13 @@ export function initChat(page: Page) {
await page.keyboard.press('Control+V');
},
lastReply: () => locators.messages.last(),
copyLastReply: async () => {
await locators.messages.last().getByTestId('chat-copy').click();
},
/** Switches the last answer between formatted and raw text. */
toggleRawText: async () => {
await locators.messages.last().getByTestId('chat-raw-toggle').click();
},
/** „Bestätigen“ on the last proposal card. */
approveLastAction: async () => {
await locators.actionCards.last().getByTestId('action-approve').click();
Expand Down
27 changes: 26 additions & 1 deletion tests/unit/desktop-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Session } from 'electron';
import { allowsMicrophoneCheck, allowsMicrophoneRequest, restrictPermissions } from '../../apps/desktop/src/permissions';
import { allowsClipboardWrite, allowsMicrophoneCheck, allowsMicrophoneRequest, restrictPermissions } from '../../apps/desktop/src/permissions';

const own = { origin: 'app://archivist/chat/', fromMainWindow: true, trustedOrigins: ['app://archivist'] };

Expand Down Expand Up @@ -52,6 +52,22 @@ describe('microphone permission', () => {
});
});

describe('clipboard permission', () => {
it('lets the main window’s own pages write to the clipboard', () => {
expect(allowsClipboardWrite({ ...own, permission: 'clipboard-sanitized-write' })).toBe(true);
});

it('never lets a page read the clipboard', () => {
expect(allowsClipboardWrite({ ...own, permission: 'clipboard-read' })).toBe(false);
expect(allowsClipboardWrite({ ...own, permission: 'deprecated-sync-clipboard-read' })).toBe(false);
});

it('refuses other windows and pages that are not Archivist’s own', () => {
expect(allowsClipboardWrite({ ...own, fromMainWindow: false, permission: 'clipboard-sanitized-write' })).toBe(false);
expect(allowsClipboardWrite({ ...own, origin: 'https://example.com', permission: 'clipboard-sanitized-write' })).toBe(false);
});
});

describe('the session permission handlers', () => {
type RequestHandler = (contents: unknown, permission: string, respond: (granted: boolean) => void, details: object) => void;
type CheckHandler = (contents: unknown, permission: string, origin: string, details: object) => boolean;
Expand Down Expand Up @@ -81,4 +97,13 @@ describe('the session permission handlers', () => {
expect(check(mainWindow, 'media', 'app://archivist', { mediaType: 'audio' })).toBe(true);
expect(check(null, 'media', 'app://archivist', { mediaType: 'audio' })).toBe(false);
});

it('let only the main window’s pages write to the clipboard, never read it', () => {
const { mainWindow, request, check } = install();
expect(request(mainWindow, 'clipboard-sanitized-write', {})).toBe(true);
expect(request({ id: 'other' }, 'clipboard-sanitized-write', {})).toBe(false);
expect(request(mainWindow, 'clipboard-read', {})).toBe(false);
expect(check(mainWindow, 'clipboard-sanitized-write', 'app://archivist', {})).toBe(true);
expect(check(mainWindow, 'clipboard-read', 'app://archivist', {})).toBe(false);
});
});
Loading