feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).
Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.
Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+1119
-563
No files matched your search
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { AgentMark } from '../lib/types';
|
||||
|
||||
interface AgentBadgeProps {
|
||||
mark: AgentMark | undefined;
|
||||
// sm: task rows and the task detail; xs: Kanban cards and list items
|
||||
size?: 'sm' | 'xs';
|
||||
}
|
||||
|
||||
const TITLES: Record<AgentMark, string> = {
|
||||
created: 'Von einem KI-Agenten per MCP angelegt. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
|
||||
edited: 'Zuletzt von einem KI-Agenten per MCP geändert. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
|
||||
};
|
||||
|
||||
// Marks entries an AI agent created or changed via MCP that the user has not touched since
|
||||
// (Doku/ui-patterns.md)
|
||||
export const AgentBadge: React.FC<AgentBadgeProps> = ({ mark, size = 'sm' }) => {
|
||||
if (mark !== 'created' && mark !== 'edited') return null;
|
||||
const label = mark === 'created' ? 'KI' : 'KI geändert';
|
||||
return (
|
||||
<span
|
||||
className={`px-1.5 border border-accent/40 bg-accent/10 text-accent rounded-btn font-medium inline-flex items-center gap-1 flex-shrink-0 ${
|
||||
size === 'xs' ? 'py-0.2 text-[9px]' : 'py-0.5 text-[10px]'
|
||||
}`}
|
||||
title={TITLES[mark]}
|
||||
aria-label={TITLES[mark]}
|
||||
>
|
||||
<Bot className="w-2.5 h-2.5" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Bot, Webhook } from 'lucide-react';
|
||||
import { Origin } from '../lib/types';
|
||||
|
||||
interface OriginBadgeProps {
|
||||
origin: Origin | undefined;
|
||||
// When the entry was created, for the details
|
||||
createdAt?: string;
|
||||
// xs: Kanban cards and list items; sm: task rows, inbox, detail view; md: triage cards
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
}
|
||||
|
||||
const VIA_LABEL = { api: 'API', mcp: 'MCP' } as const;
|
||||
|
||||
const formatWhen = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// originDetails: who created and who last changed the entry, as one line for tooltip and detail view
|
||||
export const originDetails = (origin: Origin | undefined, createdAt?: string): string => {
|
||||
if (!origin || (!origin.createdVia && !origin.changedVia)) return '';
|
||||
const parts: string[] = [];
|
||||
const created = formatWhen(createdAt);
|
||||
if (origin.createdVia) {
|
||||
const key = origin.createdKey ? ` (${origin.createdKey})` : '';
|
||||
parts.push(`Angelegt per ${VIA_LABEL[origin.createdVia]}${key}${created ? ` am ${created}` : ''}`);
|
||||
} else {
|
||||
parts.push(`In der App angelegt${created ? ` am ${created}` : ''}`);
|
||||
}
|
||||
if (origin.changedVia) {
|
||||
const key = origin.changedKey ? ` (${origin.changedKey})` : '';
|
||||
const when = formatWhen(origin.changedAt);
|
||||
parts.push(`zuletzt geändert per ${VIA_LABEL[origin.changedVia]}${key}${when ? ` am ${when}` : ''}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: 'px-1.5 py-0.2 text-[9px]',
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
md: 'px-2 py-0.5 text-[11px]',
|
||||
};
|
||||
|
||||
// One badge for entries an API key created or changed (Doku/ui-patterns.md): icon for the access
|
||||
// (webhook = REST, robot = MCP) and the key's name. Hover shows the details; a tap opens them, so it
|
||||
// works on phones too. It stays for good.
|
||||
export const OriginBadge: React.FC<OriginBadgeProps> = ({ origin, createdAt, size = 'sm' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: Event) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
return () => document.removeEventListener('pointerdown', close);
|
||||
}, [open]);
|
||||
|
||||
const via = origin?.createdVia || origin?.changedVia;
|
||||
if (!origin || !via) return null;
|
||||
const key = origin.createdVia ? origin.createdKey : origin.changedKey;
|
||||
const label = key || VIA_LABEL[via];
|
||||
const details = originDetails(origin, createdAt);
|
||||
const Icon = via === 'mcp' ? Bot : Webhook;
|
||||
|
||||
return (
|
||||
<span ref={ref} className="relative inline-flex flex-shrink-0 max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
title={details}
|
||||
aria-label={details}
|
||||
aria-expanded={open}
|
||||
className={`relative border border-border bg-canvas text-muted rounded-btn font-medium inline-flex items-center gap-1 max-w-[12rem] cursor-pointer hover:text-main before:absolute before:content-[''] before:inset-x-0 before:-inset-y-3 sm:before:hidden ${SIZE_CLASS[size]}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="absolute left-0 top-full mt-1 z-40 w-64 max-w-[80vw] p-2 bg-panel border border-border rounded-card shadow-panel text-[11px] leading-snug text-main whitespace-normal"
|
||||
>
|
||||
{details}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AgentBadge } from '../AgentBadge';
|
||||
|
||||
describe('AgentBadge', () => {
|
||||
it('shows nothing for entries of the user', () => {
|
||||
expect(render(<AgentBadge mark={undefined} />).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('tells created and edited entries apart', () => {
|
||||
const created = render(<AgentBadge mark="created" />).container.firstChild as HTMLElement;
|
||||
expect(created.textContent).toBe('KI');
|
||||
expect(created.getAttribute('title')).toContain('angelegt');
|
||||
const edited = render(<AgentBadge mark="edited" />).container.firstChild as HTMLElement;
|
||||
expect(edited.textContent).toBe('KI geändert');
|
||||
expect(edited.getAttribute('title')).toContain('geändert');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { OriginBadge, originDetails } from '../OriginBadge';
|
||||
|
||||
describe('OriginBadge', () => {
|
||||
it('shows nothing for entries no key touched', () => {
|
||||
expect(render(<OriginBadge origin={undefined} />).container.firstChild).toBeNull();
|
||||
expect(render(<OriginBadge origin={{}} />).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('names the key that created the entry, else the one that changed it', () => {
|
||||
const created = render(<OriginBadge origin={{ createdVia: 'api', createdKey: 'Home Assistant', changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||
expect(created.container.textContent).toBe('Home Assistant');
|
||||
const changed = render(<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||
expect(changed.container.textContent).toBe('Claude Code');
|
||||
const old = render(<OriginBadge origin={{ createdVia: 'mcp' }} />);
|
||||
expect(old.container.textContent).toBe('MCP');
|
||||
});
|
||||
|
||||
it('opens the details on tap', () => {
|
||||
const { getByRole, queryByRole } = render(
|
||||
<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code', changedAt: '2026-11-01T09:40:00Z' }} createdAt="2026-10-31T08:00:00Z" />
|
||||
);
|
||||
expect(queryByRole('tooltip')).toBeNull();
|
||||
fireEvent.click(getByRole('button'));
|
||||
const tip = getByRole('tooltip').textContent || '';
|
||||
expect(tip).toContain('In der App angelegt');
|
||||
expect(tip).toContain('zuletzt geändert per MCP (Claude Code)');
|
||||
});
|
||||
|
||||
it('describes both created and changed', () => {
|
||||
const text = originDetails({ createdVia: 'api', createdKey: 'HA', changedVia: 'mcp', changedKey: 'Claude Code' }, '2026-11-01T08:12:00Z');
|
||||
expect(text).toMatch(/^Angelegt per API \(HA\) am .* · zuletzt geändert per MCP \(Claude Code\)$/);
|
||||
});
|
||||
});
|
||||
@@ -492,8 +492,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
{
|
||||
id: 'triage-webhooks',
|
||||
category: 'triage',
|
||||
title: 'Webhooks, API & KI-Agenten (MCP)',
|
||||
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen; Aufgaben und Listen für Claude Code, Codex & Co.',
|
||||
title: 'API: curl, Smart Home & KI-Agenten (MCP)',
|
||||
subtitle: 'Aufgaben und Listen per curl oder Home Assistant lesen und ändern, Gedanken einliefern, KI-Agenten wie Claude Code anbinden.',
|
||||
icon: Bell,
|
||||
keywords: [
|
||||
'webhooks',
|
||||
@@ -513,16 +513,19 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
content: (
|
||||
<div className="space-y-2 text-muted leading-relaxed">
|
||||
<p>
|
||||
Unter <em className="text-main">Kontoeinstellungen → Webhooks & KI-Agenten</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie.
|
||||
MindSift hat eine API. Unter <em className="text-main">Kontoeinstellungen → API</em> erzeugst du persönliche Schlüssel und legst mit Schaltern fest, was jeder darf: Aufgaben lesen, anlegen oder ändern, Listen lesen, ergänzen oder ändern und Gedanken in die Inbox liefern. Diese Rechte gelten für beide Zugänge gleich. Notizen und Einstellungen erreicht ein Schlüssel nie.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, anlegen, abhaken und löschen, neue Aufgabenlisten und Listen anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist. Was ein Agent angelegt oder geändert hat, trägt das Zeichen <strong className="text-main">KI</strong> bzw. <strong className="text-main">KI geändert</strong>, bis du den Eintrag selbst änderst.
|
||||
<strong className="text-main">curl und Smart Home:</strong> Mit Leserecht liefert z. B. <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">GET /api/v1/tasks?due=today&status=open</code> alles, was heute ansteht, etwa für einen Home-Assistant-Sensor. Weitere Filter: <code className="font-mono">due=overdue</code>, ein Datum, <code className="font-mono">taskListId</code>. Listen kommen über <code className="font-mono">GET /api/v1/lists</code>. Mit Änderungsrecht hakst du per <code className="font-mono">PATCH /api/v1/thoughts/ID</code> ab. Die Einstellungen zeigen fertige Beispiele.
|
||||
</p>
|
||||
<p>
|
||||
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
|
||||
<strong className="text-main">Einliefern:</strong> <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> nimmt Gedanken aus iOS-Kurzbefehlen, Home Assistant oder n8n an. Mit dem Feld <code className="font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>).
|
||||
</p>
|
||||
<p>
|
||||
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
|
||||
<strong className="text-main">KI-Agenten:</strong> Über MCP (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>) lesen, anlegen, ändern, abhaken und löschen Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten deine Aufgaben und Listen. Die Einstellungen zeigen den fertigen Befehl für jeden Agenten; ein Agent sieht nur die Werkzeuge, die der Schlüssel erlaubt.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-main">Herkunft:</strong> Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Fährst du darüber oder tippst darauf, siehst du, wer wann angelegt und zuletzt geändert hat.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { AgentBadge } from '../../components/AgentBadge';
|
||||
import { OriginBadge } from '../../components/OriginBadge';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
@@ -1391,7 +1391,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AgentBadge mark={item.agentMark} size="xs" />
|
||||
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||||
{renderItemAuthor(item)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1501,7 +1501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentBadge mark={item.agentMark} size="xs" />
|
||||
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||||
{renderItemAuthor(item)}
|
||||
{item.completedByName && (
|
||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||
|
||||
@@ -18,24 +18,31 @@ interface ApiKeysPanelProps {
|
||||
onIngested?: () => void;
|
||||
}
|
||||
|
||||
// What a key may do: deliver data (webhooks) or read and change tasks and lists for AI agents (MCP)
|
||||
const SCOPES: { id: ApiKeyScope; label: string; hint: string; group: 'webhook' | 'agent' }[] = [
|
||||
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'webhook' },
|
||||
{ id: 'webhook:task', label: '📋 Aufgaben anlegen', hint: 'Aufgaben direkt anlegen', group: 'webhook' },
|
||||
{ id: 'webhook:list', label: '📝 Listeneinträge anlegen', hint: 'Einträge zu Listen hinzufügen', group: 'webhook' },
|
||||
{ id: 'tasks:read', label: '📋 Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'agent' },
|
||||
{ id: 'tasks:write', label: '✅ Aufgaben ändern', hint: 'Aufgaben anlegen, ändern und abhaken', group: 'agent' },
|
||||
{ id: 'lists:read', label: '📝 Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'agent' },
|
||||
{ id: 'lists:write', label: '🛒 Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'agent' },
|
||||
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
|
||||
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
|
||||
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
|
||||
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
|
||||
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
|
||||
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
|
||||
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
|
||||
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
|
||||
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
|
||||
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
|
||||
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
|
||||
];
|
||||
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
||||
const isAgentScope = (sc: string) => SCOPES.find((s) => s.id === sc)?.group === 'agent';
|
||||
const SCOPE_GROUPS: [ScopeGroup, string][] = [
|
||||
['tasks', '📋 Aufgaben'],
|
||||
['lists', '🛒 Listen'],
|
||||
['inbox', '📥 Inbox'],
|
||||
];
|
||||
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
|
||||
|
||||
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||
type McpClient = 'claude' | 'codex' | 'json';
|
||||
type Access = 'rest' | 'mcp';
|
||||
|
||||
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
|
||||
// examples (settings → Webhooks & KI-Agenten)
|
||||
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
|
||||
// delivery simulator) or AI agents over MCP (settings → API)
|
||||
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
@@ -43,6 +50,7 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
|
||||
const [access, setAccess] = useState<Access>('rest');
|
||||
|
||||
// Live Webhook Simulator State
|
||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||
@@ -179,10 +187,12 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">API-Schlüssel für Webhooks und KI-Agenten</h3>
|
||||
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
|
||||
<p className="text-xs text-muted">
|
||||
Verbinde Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit MindSift. Was ein
|
||||
Key darf, legst du pro Key fest; Notizen bleiben für Keys immer unerreichbar.
|
||||
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
|
||||
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und
|
||||
Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als
|
||||
Badge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,18 +211,10 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<span>API-Key erzeugen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label="Berechtigungen des Keys">
|
||||
{(
|
||||
[
|
||||
['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'],
|
||||
['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'],
|
||||
] as const
|
||||
).map(([group, heading, path]) => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label="Berechtigungen des Schlüssels">
|
||||
{SCOPE_GROUPS.map(([group, heading]) => (
|
||||
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
|
||||
<p className="text-[11px] text-muted pt-1 pb-0.5 flex flex-wrap justify-between gap-x-2">
|
||||
<span className="font-semibold">{heading}</span>
|
||||
<code className="font-mono">{path}</code>
|
||||
</p>
|
||||
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
|
||||
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
|
||||
const on = newKeyScopes.includes(sc.id);
|
||||
return (
|
||||
@@ -221,7 +223,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<span className="block text-xs text-main">{sc.label}</span>
|
||||
<span className="block text-[11px] text-muted">{sc.hint}</span>
|
||||
</div>
|
||||
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.label} />
|
||||
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.chip} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -257,13 +259,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((sc) => (
|
||||
<span
|
||||
key={sc}
|
||||
className={`px-1.5 py-0.5 rounded-btn border text-[10px] ${
|
||||
isAgentScope(sc) ? 'border-accent/40 text-accent' : 'border-border text-muted'
|
||||
}`}
|
||||
>
|
||||
{scopeLabel(sc)}
|
||||
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-muted text-[10px]">
|
||||
{scopeChip(sc)}
|
||||
</span>
|
||||
))}
|
||||
{k.lastUsedAt && (
|
||||
@@ -285,186 +282,250 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI agents (MCP) */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
||||
<p className="text-xs text-muted">
|
||||
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten können offene
|
||||
Aufgaben lesen, abhaken und anlegen sowie Listen führen. Dafür braucht der Key die Agenten-Berechtigungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
||||
{/* How to connect: one API, two accesses */}
|
||||
<div className="pt-4 border-t border-border space-y-4">
|
||||
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
|
||||
{(
|
||||
[
|
||||
['claude', 'Claude Code'],
|
||||
['codex', 'OpenAI Codex'],
|
||||
['json', 'Claude Desktop / Cursor'],
|
||||
['rest', 'curl / Smart Home', Terminal],
|
||||
['mcp', 'KI-Agenten (MCP)', Bot],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
).map(([value, label, Icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mcpClient === value}
|
||||
onClick={() => setMcpClient(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
role="tab"
|
||||
aria-selected={access === value}
|
||||
onClick={() => setAccess(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
|
||||
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
||||
<p className="mt-2 text-[11px] text-faint">
|
||||
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">list_tasks</code>,{' '}
|
||||
<code className="font-mono">complete_task</code>, <code className="font-mono">create_task</code>,{' '}
|
||||
<code className="font-mono">update_task</code>, <code className="font-mono">list_lists</code>,{' '}
|
||||
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||
<code className="font-mono">check_list_item</code>, <code className="font-mono">search</code>,{' '}
|
||||
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht nur die Werkzeuge, für die der Key
|
||||
berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
||||
werden noch nicht unterstützt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Simulator */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
<Webhook className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
|
||||
<p className="text-xs text-muted">
|
||||
Teste den Webhook-Endpunkt direkt hier
|
||||
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{access === 'rest' && (
|
||||
<div className="space-y-5">
|
||||
{/* Reading: e.g. a smart home sensor for what is due today */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted mb-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“):</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||
{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
|
||||
-H "X-API-Key: ${exampleToken}"`}
|
||||
</pre>
|
||||
<p className="mt-2 text-[11px] text-faint">
|
||||
Filter: <code className="font-mono">due=today</code>, <code className="font-mono">due=overdue</code> oder ein
|
||||
Datum (<code className="font-mono">JJJJ-MM-TT</code>, fällig bis einschließlich), <code className="font-mono">status=open|done</code>,{' '}
|
||||
<code className="font-mono">taskListId</code>. Listen: <code className="font-mono">GET /api/v1/lists</code> und{' '}
|
||||
<code className="font-mono">/lists/ID</code>. Mit „Ändern“ auch <code className="font-mono">PATCH /api/v1/thoughts/ID</code>{' '}
|
||||
(z. B. <code className="font-mono">{'{"isCompleted": true}'}</code>).
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted mt-3 mb-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Home Assistant: Sensor „Heute fällig“ (configuration.yaml)</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||
{`rest:
|
||||
- resource: "${baseUrl}/api/v1/tasks?due=today&status=open"
|
||||
headers:
|
||||
X-API-Key: "${exampleToken}"
|
||||
scan_interval: 900
|
||||
sensor:
|
||||
- name: "MindSift heute fällig"
|
||||
value_template: "{{ value_json | length }}"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
|
||||
{(
|
||||
[
|
||||
['triage', '📥 Inbox'],
|
||||
['task', '📋 Aufgabe'],
|
||||
['list_item', '📝 Listeneintrag'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={simTarget === value}
|
||||
onClick={() => setSimTarget(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{simTarget === 'list_item' && (
|
||||
<select
|
||||
value={simListId}
|
||||
onChange={(e) => setSimListId(e.target.value)}
|
||||
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||
aria-label="Liste"
|
||||
>
|
||||
<option value="">Liste wählen…</option>
|
||||
{lists.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.icon} {l.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{simTarget === 'task' && (
|
||||
<select
|
||||
value={simTaskListId}
|
||||
onChange={(e) => setSimTaskListId(e.target.value)}
|
||||
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||
aria-label="Aufgabenliste"
|
||||
>
|
||||
<option value="">Ohne Aufgabenliste</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
<Webhook className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">Einliefern testen (Webhook)</h3>
|
||||
<p className="text-xs text-muted">
|
||||
Teste das Einliefern per <code className="font-mono">POST /api/v1/webhooks/ingest</code> direkt hier
|
||||
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simTarget === 'triage' && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{[
|
||||
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
||||
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||
].map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSimTitle(preset.text);
|
||||
setSimPriority(preset.prio);
|
||||
}}
|
||||
className="btn-action text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
|
||||
{(
|
||||
[
|
||||
['triage', '📥 Inbox'],
|
||||
['task', '📋 Aufgabe'],
|
||||
['list_item', '📝 Listeneintrag'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={simTarget === value}
|
||||
onClick={() => setSimTarget(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{simTarget === 'list_item' && (
|
||||
<select
|
||||
value={simListId}
|
||||
onChange={(e) => setSimListId(e.target.value)}
|
||||
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||
aria-label="Liste"
|
||||
>
|
||||
<option value="">Liste wählen…</option>
|
||||
{lists.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.icon} {l.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{simTarget === 'task' && (
|
||||
<select
|
||||
value={simTaskListId}
|
||||
onChange={(e) => setSimTaskListId(e.target.value)}
|
||||
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||
aria-label="Aufgabenliste"
|
||||
>
|
||||
<option value="">Ohne Aufgabenliste</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={simTitle}
|
||||
onChange={(e) => setSimTitle(e.target.value)}
|
||||
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSimulateWebhook}
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Webhook jetzt abfeuern</span>
|
||||
</button>
|
||||
</div>
|
||||
{simTarget === 'triage' && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{[
|
||||
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
||||
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||
].map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSimTitle(preset.text);
|
||||
setSimPriority(preset.prio);
|
||||
}}
|
||||
className="btn-action text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simStatus && (
|
||||
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={simTitle}
|
||||
onChange={(e) => setSimTitle(e.target.value)}
|
||||
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSimulateWebhook}
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Webhook jetzt abfeuern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code Example Snippets */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>cURL / Home Assistant REST Command:</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||
{simStatus && (
|
||||
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
|
||||
)}
|
||||
|
||||
{/* Code Example Snippets */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>cURL / Home Assistant REST Command:</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
|
||||
-H "X-API-Key: ${exampleToken}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${examplePayload}'`}
|
||||
</pre>
|
||||
<p className="text-[11px] text-faint">
|
||||
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
|
||||
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</pre>
|
||||
<p className="text-[11px] text-faint">
|
||||
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
|
||||
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{access === 'mcp' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
||||
<p className="text-xs text-muted">
|
||||
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
|
||||
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
||||
{(
|
||||
[
|
||||
['claude', 'Claude Code'],
|
||||
['codex', 'OpenAI Codex'],
|
||||
['json', 'Claude Desktop / Cursor'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mcpClient === value}
|
||||
onClick={() => setMcpClient(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
||||
<p className="mt-2 text-[11px] text-faint">
|
||||
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
|
||||
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
|
||||
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
|
||||
<code className="font-mono">complete_task</code>, <code className="font-mono">delete_task</code>,{' '}
|
||||
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
|
||||
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
|
||||
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
|
||||
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
||||
werden noch nicht unterstützt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -583,7 +583,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten, Export & Import
|
||||
Persönliches Profil, Design-Themes, Aufgaben-Alarme, API, Export & Import
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -652,8 +652,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
>
|
||||
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Webhooks & KI-Agenten</p>
|
||||
<p className="text-[10px] text-faint">API-Keys, MCP & Curl</p>
|
||||
<p className="font-semibold">API</p>
|
||||
<p className="text-[10px] text-faint">Schlüssel, curl, Smart Home & KI-Agenten</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1865,15 +1865,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */}
|
||||
{/* SECTION 4: API (KEYS, CURL / SMART HOME, AI AGENTS) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'webhooks' && (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">Webhooks & KI-Agenten</h2>
|
||||
<h2 className="text-sm font-bold text-main">API</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken
|
||||
einzuspeisen oder KI-Agenten wie Claude Code und Codex an deine Aufgaben und Listen zu lassen.
|
||||
Mit persönlichen API-Schlüsseln lesen und ändern Skripte, dein Smart Home oder KI-Agenten wie Claude Code
|
||||
und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,28 +21,32 @@ import { ApiKeysPanel } from '../ApiKeysPanel';
|
||||
describe('ApiKeysPanel', () => {
|
||||
beforeEach(() => createAPIKey.mockClear());
|
||||
|
||||
it('shows the scopes of existing keys as badges', async () => {
|
||||
it('shows the rights of existing keys as badges', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
||||
expect(screen.getAllByText('📋 Aufgaben anlegen').length).toBeGreaterThan(1); // switch label + badge
|
||||
expect(screen.getByText('Aufgaben anlegen')).toBeTruthy();
|
||||
expect(screen.getByText('Inbox')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates a key with the chosen scopes and never without one', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
const triage = screen.getByRole('switch', { name: /Triage/ });
|
||||
const triage = screen.getByRole('switch', { name: 'Inbox' });
|
||||
fireEvent.click(triage); // last remaining scope cannot be switched off
|
||||
expect(triage.getAttribute('aria-checked')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Listeneinträge anlegen/ }));
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Listeneinträge hinzufügen' }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
||||
});
|
||||
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
||||
});
|
||||
|
||||
it('offers agent scopes and shows how to connect an MCP client', async () => {
|
||||
it('shows curl for scripts and, in the second tab, how to connect an MCP client', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Aufgaben ändern/ }));
|
||||
expect(screen.getByRole('switch', { name: /Aufgaben ändern/ }).getAttribute('aria-checked')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Aufgaben ändern' }));
|
||||
expect(screen.getByRole('switch', { name: 'Aufgaben ändern' }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/claude mcp add/)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
|
||||
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
||||
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
UserPlus,
|
||||
Repeat,
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { AgentBadge } from '../../components/AgentBadge';
|
||||
import { OriginBadge, originDetails } from '../../components/OriginBadge';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
@@ -239,13 +238,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
<span>Nur Lesen</span>
|
||||
</span>
|
||||
)}
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
)}
|
||||
<AgentBadge mark={task.agentMark} />
|
||||
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -895,8 +888,8 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
<div className="text-[11px] text-faint">
|
||||
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
|
||||
<div className="text-[11px] text-faint min-w-0 pr-3">
|
||||
{originDetails(task.origin, task.createdAt) || `Erstellt: ${new Date(task.createdAt).toLocaleDateString('de-DE')}`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Settings,
|
||||
Folder,
|
||||
Filter,
|
||||
Webhook,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
@@ -35,7 +34,7 @@ import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { AgentBadge } from '../../components/AgentBadge';
|
||||
import { OriginBadge } from '../../components/OriginBadge';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
@@ -1362,16 +1361,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0"
|
||||
title="Erstellt über Webhook"
|
||||
>
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
)}
|
||||
<AgentBadge mark={task.agentMark} />
|
||||
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||||
|
||||
{/* Note indicator */}
|
||||
{task.notes && (
|
||||
@@ -2436,16 +2426,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
⚠️ Wichtig
|
||||
</button>
|
||||
)}
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span
|
||||
className="px-1.5 py-0.2 border border-border bg-canvas text-muted rounded-btn text-[9px] font-medium flex items-center gap-1"
|
||||
title="Erstellt über Webhook"
|
||||
>
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
)}
|
||||
<AgentBadge mark={task.agentMark} size="xs" />
|
||||
<OriginBadge origin={task.origin} createdAt={task.createdAt} size="xs" />
|
||||
{task.dueDate && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Mic,
|
||||
Webhook,
|
||||
Keyboard,
|
||||
Tag,
|
||||
Clock,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
import { SharedList, Thought } from '../../lib/types';
|
||||
import { addDays, formatDue } from '../../lib/due';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { AgentBadge } from '../../components/AgentBadge';
|
||||
import { OriginBadge } from '../../components/OriginBadge';
|
||||
|
||||
interface TriageDeckProps {
|
||||
deck: Thought[];
|
||||
@@ -149,17 +148,12 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
|
||||
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||
{item.content}
|
||||
</span>
|
||||
{item.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
|
||||
</span>
|
||||
)}
|
||||
{item.source?.toLowerCase() === 'voice' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||
<Mic className="w-2.5 h-2.5" /> Diktat
|
||||
</span>
|
||||
)}
|
||||
<AgentBadge mark={item.agentMark} />
|
||||
<OriginBadge origin={item.origin} createdAt={item.createdAt} />
|
||||
</div>
|
||||
|
||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||
@@ -441,13 +435,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (s === 'webhook') {
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
|
||||
<Webhook className="w-3 h-3 text-accent" /> Webhook
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -523,7 +510,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="flex items-center gap-1.5">
|
||||
{priorityBadge(nextCard.priority)}
|
||||
{sourceBadge(nextCard.source)}
|
||||
<AgentBadge mark={nextCard.agentMark} />
|
||||
<OriginBadge origin={nextCard.origin} createdAt={nextCard.createdAt} size="md" />
|
||||
</div>
|
||||
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
||||
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
||||
@@ -593,7 +580,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="flex items-center gap-1.5">
|
||||
{priorityBadge(activeCard.priority)}
|
||||
{sourceBadge(activeCard.source)}
|
||||
<AgentBadge mark={activeCard.agentMark} />
|
||||
<OriginBadge origin={activeCard.origin} createdAt={activeCard.createdAt} size="md" />
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
|
||||
@@ -148,8 +148,15 @@ export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, string> = {
|
||||
yearly: 'jährlich',
|
||||
};
|
||||
|
||||
// Set while the last change came from an AI agent via MCP and the user has not touched the entry since
|
||||
export type AgentMark = 'created' | 'edited';
|
||||
// Which API key created an entry and which one changed it last, over which access (REST "api" or
|
||||
// "mcp"). Stays for good; key names come resolved from the server.
|
||||
export interface Origin {
|
||||
createdVia?: 'api' | 'mcp';
|
||||
createdKey?: string;
|
||||
changedVia?: 'api' | 'mcp';
|
||||
changedKey?: string;
|
||||
changedAt?: string;
|
||||
}
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
@@ -176,7 +183,7 @@ export interface Thought {
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
triagedAt?: string;
|
||||
agentMark?: AgentMark;
|
||||
origin?: Origin;
|
||||
}
|
||||
|
||||
export interface KanbanColumn {
|
||||
@@ -542,7 +549,7 @@ export interface ListItem {
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
agentMark?: AgentMark;
|
||||
origin?: Origin;
|
||||
}
|
||||
|
||||
export interface ListShare {
|
||||
|
||||
Reference in new issue
Block a user