From c6f3b2d6e33afaadf65683a3d9219b102a1df5e6 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 30 Aug 2026 20:52:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(agenten):=20Runde=209=20=E2=80=94=20statis?= =?UTF-8?q?ches=20Sitzungszimmer,=20kompakte=20Tabelle,=20Chats,=20KI-Hinw?= =?UTF-8?q?eis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Sitzungszimmer statisch.** Die Kamera folgte dem Zeiger um bis zu 7,5 Zentimeter — als Belebung gedacht, als Wackeln wahrgenommen. Entfernt ist nur der Aufruf der Parallaxe samt der nun unbenutzten Methode; Kameraposition, Blickziel, Sitzordnung, Bilder und Beschriftungen sind unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf seinen Platz bleiben — das ist keine Bewegung. **«Meine Objekte» ohne Querscrollen.** Bei automatischer Spaltenbreite richtet sich jede Spalte nach ihrem längsten Inhalt; bei elf Spalten summierte sich das über die Fensterbreite hinaus. Jetzt feste Anteile, die zusammen 100 % ergeben, schmaleres Zellenpolster und Wortumbruch statt `nowrap` beim Mieter. Keine Spalte entfernt, kein Wert gekürzt. **Chats antworten.** Bisher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt beantwortet jeder Agent — Thomas eingeschlossen — drei Fragetypen aus dem Bearbeitungsverlauf seiner eigenen Seite: Rolle, Anzahl Vorgänge im Log, Vorgänge im Vormonat. Schlüsselwortlogik statt Sprachmodell, wie gefordert. Die Zeitfrage wird vor der Mengenfrage geprüft: «Wie viele Aufträge wurden letzten Monat bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als Frage nach dem Log gelesen. Findet sich im Zeitraum nichts, sagt der Agent das und nennt zum Vergleich den Gesamtbestand — statt eine plausible Zahl zu erfinden. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort: sie sieht aus wie eine Auskunft. Der Verlauf lebt nur, solange die Seite offen ist. Ein Verlauf, der Tage überdauert, weckte die Erwartung, der Agent erinnere sich. **KI-Hinweis überall gleich.** «KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben» steht als eine Konstante und eine Komponente in allen sechs Detailansichten. Bei Livia ersetzt er den Quellenvermerk und den Block «Empfohlene nächste Handlung»; die Herkunft steht ohnehin oben bei der Quelle. Geprüft im Browser gegen den Produktionsbau: Szene reagiert nicht mehr auf Mausbewegung (Pixelvergleich), Tabelle 11 Spalten ohne Querscrollen (scrollWidth = clientWidth), alle sechs Chats antworten agentenspezifisch mit Zahlen, die den Mockdaten entsprechen, unbekannte Fragen werden sauber abgewiesen, Hinweis in allen sechs Detailansichten vorhanden, 0 Laufzeitfehler. 423/423 Tests grün, tsc und eslint sauber. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/market-leads/LeadDetail.tsx | 27 +-- src/components/research/ResearchLeadCard.tsx | 24 +-- src/components/staff/MeetingRoom3D.tsx | 17 +- src/components/staff/meetingRoomPalette.ts | 9 +- src/components/staff/meetingRoomScene.ts | 13 -- .../supply/EditableObjectOverview.tsx | 9 + src/components/supply/PropertyTable.tsx | 42 ++++- .../supply/ReminderDetailDrawer.tsx | 3 + src/components/team/AgentChatTranscript.tsx | 64 +++++++ src/components/team/AgentWorkspaceHero.tsx | 3 + src/components/team/WorkItemDetailDrawer.tsx | 3 + src/components/team/index.ts | 3 + src/components/team/useAgentChat.ts | 29 ++- src/components/ui/AiDisclaimer.tsx | 38 ++++ src/components/ui/index.ts | 3 + .../visits/VisitPreparationDrawer.tsx | 3 + src/hooks/useAgentChatAnswer.ts | 45 +++++ src/lib/constants.ts | 10 + src/services/agentChatService.ts | 178 ++++++++++++++++++ src/stores/agentChatStore.ts | 24 ++- 20 files changed, 481 insertions(+), 66 deletions(-) create mode 100644 src/components/team/AgentChatTranscript.tsx create mode 100644 src/components/ui/AiDisclaimer.tsx create mode 100644 src/hooks/useAgentChatAnswer.ts create mode 100644 src/services/agentChatService.ts diff --git a/src/components/market-leads/LeadDetail.tsx b/src/components/market-leads/LeadDetail.tsx index 1a795e8..95c0d33 100644 --- a/src/components/market-leads/LeadDetail.tsx +++ b/src/components/market-leads/LeadDetail.tsx @@ -5,9 +5,10 @@ import type { MarketLead } from '../../hooks/useMarketLeads' import { AgentAvatar, ObjectDeepLink } from '../team' import { agentWorkspaceById } from '../../lib/agentWorkspaces' import { SOURCE_TYPE_LABELS } from '../../lib/constants' -import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal' +import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal' import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers' import { LeadSignalTypeBadge } from './LeadSignalTypeBadge' +import { AiDisclaimer } from '../ui' import { ContactRow, SignalPropertyRow } from './MarketLeadPanels' import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' @@ -50,7 +51,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching const signalType = leadSignalTypeOf(signal) const risiko = isRisiko(signal) const rationale = signalRationaleOf(signal) - const action = recommendedActionOf(signal) // Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt. const relevant = useMemo( @@ -215,16 +215,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching )} - {/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */} - - - Empfohlene nächste Handlung - - - {action} - - - {/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */} {showMatching && ( <> @@ -248,11 +238,14 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching )} - {signal.disclaimer && ( - - {signal.disclaimer} - - )} + {/* + An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals + («Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen + KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle, + und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche + Fehler soll aber überall gleich lauten (§4, §5). + */} + ) diff --git a/src/components/research/ResearchLeadCard.tsx b/src/components/research/ResearchLeadCard.tsx index 78fab97..61c57a6 100644 --- a/src/components/research/ResearchLeadCard.tsx +++ b/src/components/research/ResearchLeadCard.tsx @@ -3,6 +3,7 @@ import { Box, Chip, Typography } from '@mui/material' import { ExternalLink, MapPin } from 'lucide-react' import type { ResearchLead } from '../../domain/researchLead' import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants' +import { AiDisclaimer } from '../ui' import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' /** @@ -74,23 +75,16 @@ export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: > Warum relevant - + {lead.reason} - {lead.recommendedAction && ( - <> - - Empfohlene nächste Handlung - - - {lead.recommendedAction} - - - )} + {/* + Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen. + Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt — + entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete + beschränkt bleibt und nicht als Anweisung gelesen wird. + */} + + ) }) diff --git a/src/components/staff/MeetingRoom3D.tsx b/src/components/staff/MeetingRoom3D.tsx index be01f90..ba8f791 100644 --- a/src/components/staff/MeetingRoom3D.tsx +++ b/src/components/staff/MeetingRoom3D.tsx @@ -159,7 +159,19 @@ export function MeetingRoom3D() { } }, [positionLabels]) - // ── Maus: Parallaxe und Trefferprüfung ── + /** + * Maus: nur noch Trefferprüfung, keine Bewegung mehr (Runde 9, §1). + * + * Bis hierher folgte die Kamera dem Zeiger — eine leichte Parallaxe, die den + * Raum lebendig wirken lassen sollte. Gewünscht ist jetzt eine vollständig + * ruhige Szene: Wer die Agenten anschaut, soll sie anschauen und nicht ein + * Bild, das unter dem Zeiger wegkippt. + * + * Entfernt ist ausschliesslich der Aufruf von `applyParallax` — die Szene + * selbst, ihre Kameraposition, Sitzordnung und Beschriftungen bleiben + * unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf + * seinen Platz sind keine Bewegung und bleiben deshalb. + */ const handlePointerMove = useCallback((e: React.PointerEvent) => { const s = sceneRef.current const wrap = wrapRef.current @@ -169,8 +181,6 @@ export function MeetingRoom3D() { const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1 const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1) - s.applyParallax(nx, ny) - const ray = new THREE.Raycaster() ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera) const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0] @@ -187,7 +197,6 @@ export function MeetingRoom3D() { const handlePointerLeave = useCallback(() => { const s = sceneRef.current if (!s) return - s.applyParallax(0, 0) hoveredRef.current = null s.setHovered(null) setHovered(null) diff --git a/src/components/staff/meetingRoomPalette.ts b/src/components/staff/meetingRoomPalette.ts index cb70195..7f631dd 100644 --- a/src/components/staff/meetingRoomPalette.ts +++ b/src/components/staff/meetingRoomPalette.ts @@ -65,11 +65,12 @@ export const CAMERA = { maxHFov: 84, /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */ lookAt: [0, 1.24, -5.2] as const, - /** - * Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die - * Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20). + /* + * Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger + * um bis zu 7,5 Zentimeter, was den Raum beleben sollte und stattdessen als + * Wackeln wahrgenommen wurde. Kameraposition und Blickziel bleiben exakt + * dieselben — nur bewegt sich nichts mehr. */ - parallax: 0.075, } as const /** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */ diff --git a/src/components/staff/meetingRoomScene.ts b/src/components/staff/meetingRoomScene.ts index d5d119c..d23b851 100644 --- a/src/components/staff/meetingRoomScene.ts +++ b/src/components/staff/meetingRoomScene.ts @@ -41,7 +41,6 @@ export interface MeetingRoomScene { renderer: THREE.WebGLRenderer seats: SeatHandle[] /** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */ - applyParallax: (dx: number, dy: number) => void setSize: (width: number, height: number) => void render: () => void setHovered: (id: string | null) => void @@ -549,9 +548,6 @@ export function createMeetingRoomScene( addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe), ) - const baseCam = camera.position.clone() - const lookTarget = new THREE.Vector3(...CAMERA.lookAt) - function render() { renderer.render(scene, camera) } @@ -562,15 +558,6 @@ export function createMeetingRoomScene( renderer, seats: handles, - applyParallax(dx, dy) { - camera.position.set( - baseCam.x + dx * CAMERA.parallax, - baseCam.y + dy * CAMERA.parallax * 0.6, - baseCam.z, - ) - camera.lookAt(lookTarget) - }, - setSize(width, height) { renderer.setSize(width, height, false) const aspect = width / Math.max(height, 1) diff --git a/src/components/supply/EditableObjectOverview.tsx b/src/components/supply/EditableObjectOverview.tsx index 49a7352..4883f34 100644 --- a/src/components/supply/EditableObjectOverview.tsx +++ b/src/components/supply/EditableObjectOverview.tsx @@ -4,6 +4,7 @@ import { } from '@mui/material' import { X } from 'lucide-react' import type { Property, UpdatePropertyInput } from '../../domain/property' +import { AiDisclaimer } from '../ui' import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' import { useToastStore } from '../../stores/toastStore' @@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) { ) })} + + {/* + Der KI-Hinweis steht innerhalb des scrollenden Bereichs. Ausserhalb + läge er hinter dem `overflow: hidden` des äusseren Rahmens und wäre + nie zu sehen — der Aktionsbalken darunter ist bewusst der einzige + Teil, der fix am unteren Rand klebt. + */} + {/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */} diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx index a96ffae..92b60c2 100644 --- a/src/components/supply/PropertyTable.tsx +++ b/src/components/supply/PropertyTable.tsx @@ -115,7 +115,43 @@ export function PropertyTable({ return ( - +
+ + {/* Objekt */} + {/* Typ */} + {/* Standort */} + {/* Fläche */} + {/* Miete */} + {/* Aktueller Mieter */} + {/* Mietlaufzeit */} + {/* Breakoutoption */} + {/* Breakoutoption Zeitpunkt */} + {/* Datenqualität */} + {/* Aktionen */} + Objekt @@ -205,7 +241,7 @@ export function PropertyTable({ {/* Aktueller Mieter */} - + {p.currentTenant ?? –} @@ -249,7 +285,7 @@ export function PropertyTable({ title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'} placement="top" > - + )} + + )} diff --git a/src/components/team/AgentChatTranscript.tsx b/src/components/team/AgentChatTranscript.tsx new file mode 100644 index 0000000..e31baf3 --- /dev/null +++ b/src/components/team/AgentChatTranscript.tsx @@ -0,0 +1,64 @@ +import { Box, Typography } from '@mui/material' +import { useAgentChatStore } from '../../stores/agentChatStore' +import { AgentAvatar } from './AgentAvatar' +import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds' + +/** + * Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3). + * + * Bewusst schlicht: Frage rechts, Antwort links mit dem Porträt des Agenten, + * keine Zeitstempel, keine Sprechblasen mit Spitzen, kein Tippindikator. Der + * Chat beantwortet drei Fragetypen aus Mockdaten — eine Oberfläche, die nach + * Messenger aussieht, verspräche mehr, als dahinter steckt. + * + * Vor der ersten Frage erscheint gar nichts. Der grosszügige Weissraum unter + * dem Eingabefeld ist Teil des Einstiegs und soll nicht durch einen leeren + * Verlaufsrahmen ersetzt werden. + */ +export function AgentChatTranscript() { + const messages = useAgentChatStore(s => s.messages) + const agent = useAgentChatStore(s => s.agent) + + if (messages.length === 0 || !agent) return null + + return ( + + {messages.map((m, i) => ( + m.from === 'user' ? ( + + + + {m.text} + + + + ) : ( + + + + + + + {m.text} + + + + ) + ))} + + ) +} diff --git a/src/components/team/AgentWorkspaceHero.tsx b/src/components/team/AgentWorkspaceHero.tsx index d27ccb4..57f698e 100644 --- a/src/components/team/AgentWorkspaceHero.tsx +++ b/src/components/team/AgentWorkspaceHero.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material' import { SendHorizonal } from 'lucide-react' import { AgentAvatar } from './AgentAvatar' +import { AgentChatTranscript } from './AgentChatTranscript' import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat' import { AGENT_CHAT_PROMPT } from '../../lib/constants' import { DS_BORDER, DS_TEXT } from '../../lib/ds' @@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent )} + + ) } diff --git a/src/components/team/WorkItemDetailDrawer.tsx b/src/components/team/WorkItemDetailDrawer.tsx index 5a32be9..f9ed601 100644 --- a/src/components/team/WorkItemDetailDrawer.tsx +++ b/src/components/team/WorkItemDetailDrawer.tsx @@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material' import { HelpCircle, X } from 'lucide-react' import { useNavigate } from 'react-router' import { useTeamStore } from '../../stores/teamStore' +import { AiDisclaimer } from '../ui' import { useAgentWorkItem } from '../../hooks/useAgentWorkItems' import { useTeamAgents } from '../../hooks/useTeamAgents' import { AgentAvatar } from './AgentAvatar' @@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() { {item.rejectionReason} )} + + )} diff --git a/src/components/team/index.ts b/src/components/team/index.ts index 2f84bf9..e1dec67 100644 --- a/src/components/team/index.ts +++ b/src/components/team/index.ts @@ -87,3 +87,6 @@ export { PROTOCOL_STATUS_SURFACE, PROTOCOL_STATUS_TEXT, } from './teamTokens' + +// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg. +export { AgentChatTranscript } from './AgentChatTranscript' diff --git a/src/components/team/useAgentChat.ts b/src/components/team/useAgentChat.ts index d712b54..44e5329 100644 --- a/src/components/team/useAgentChat.ts +++ b/src/components/team/useAgentChat.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { FocusEvent, KeyboardEvent } from 'react' import { useAgentChatStore } from '../../stores/agentChatStore' -import { useToastStore } from '../../stores/toastStore' +import { useAgentChatAnswer } from '../../hooks/useAgentChatAnswer' /** * Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen @@ -29,7 +29,10 @@ interface Options { * vergessen. * * Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und - * eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung. + * Seit Runde 9 (§3) antwortet der Chat tatsächlich: aus den Beständen der + * jeweiligen Agentenseite, per Schlüsselwortlogik, ohne Backend und ohne + * Sprachmodell. Was er nicht belegen kann, sagt er nicht — eine erfundene Zahl + * wäre schlimmer als ein «das habe ich nicht verstanden». */ export function useAgentChat({ active = true }: Options = {}) { const agent = useAgentChatStore(s => s.agent) @@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) { const focused = useAgentChatStore(s => s.focused) const setDraft = useAgentChatStore(s => s.setDraft) const setFocused = useAgentChatStore(s => s.setFocused) - const showToast = useToastStore(s => s.showToast) + const append = useAgentChatStore(s => s.append) + const answerFor = useAgentChatAnswer() const inputRef = useRef(null) @@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) { el.setSelectionRange(end, end) }, [active, focused]) + /** + * Frage absenden und beantworten (Runde 9, §3). + * + * Bis hierher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt + * antwortet der Agent aus den Beständen seiner eigenen Seite — ohne Backend + * und ohne Sprachmodell, wie es der Auftrag verlangt. + * + * Frage und Antwort landen zusammen im Verlauf. Der Toast entfällt: Wer eine + * Antwort im Chat sieht, braucht daneben keine Einblendung, die dasselbe + * Ereignis noch einmal meldet. + */ const send = useCallback(() => { const text = draft.trim() if (!text || !agent) return setDraft('') - showToast( - `Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, - 'info', - ) - }, [draft, agent, setDraft, showToast]) + append({ from: 'user', text }) + append({ from: 'agent', text: answerFor(agent, text) }) + }, [draft, agent, setDraft, append, answerFor]) const handleKeyDown = useCallback( (e: KeyboardEvent) => { diff --git a/src/components/ui/AiDisclaimer.tsx b/src/components/ui/AiDisclaimer.tsx new file mode 100644 index 0000000..2be0265 --- /dev/null +++ b/src/components/ui/AiDisclaimer.tsx @@ -0,0 +1,38 @@ +import { memo } from 'react' +import { Box, Typography } from '@mui/material' +import { Info } from 'lucide-react' +import { AI_DISCLAIMER } from '../../lib/constants' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' + +/** + * Der einheitliche KI-Hinweis am Fuss jeder Agenten-Detailansicht (Runde 9, §5). + * + * Eine einzige Komponente für alle sechs Detailansichten. Der Wortlaut ist + * vorgegeben und steht deshalb als Konstante in `constants.ts` — stünde er + * sechsmal im Code, hiesse er nach der ersten Korrektur an fünf Stellen anders, + * und ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner. + * + * Bewusst dezent: grau, klein, mit dünner Trennlinie darüber. Er soll gelesen + * werden können, ohne die Aussage darüber zu übertönen — ein roter Warnkasten + * an jeder Detailansicht stumpft nach dem dritten Mal ab und wird dann gar + * nicht mehr gelesen. + */ +export const AiDisclaimer = memo(function AiDisclaimer() { + return ( + + + + {AI_DISCLAIMER} + + + ) +}) diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 9173e00..f33c7b7 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider' export { ConfirmDialog } from './ConfirmDialog' export { DecisionContextPanel } from './DecisionContextPanel' export type { DecisionMetric, DecisionAction } from './DecisionContextPanel' + +// Runde 9, §5: einheitlicher KI-Hinweis in allen Agenten-Detailansichten. +export { AiDisclaimer } from './AiDisclaimer' diff --git a/src/components/visits/VisitPreparationDrawer.tsx b/src/components/visits/VisitPreparationDrawer.tsx index 6fa9362..cee5b87 100644 --- a/src/components/visits/VisitPreparationDrawer.tsx +++ b/src/components/visits/VisitPreparationDrawer.tsx @@ -1,6 +1,7 @@ import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material' import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react' import type { VisitAssignment } from '../../domain/visitAssignment' +import { AiDisclaimer } from '../ui' import { VisitRequestType } from '../../domain/visitAssignment' import { needsReportWarning } from '../../services/visitAssignmentService' import { useRequestVisitReport } from '../../hooks/useVisitAssignments' @@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) { )} + + )} diff --git a/src/hooks/useAgentChatAnswer.ts b/src/hooks/useAgentChatAnswer.ts new file mode 100644 index 0000000..6b5a2b9 --- /dev/null +++ b/src/hooks/useAgentChatAnswer.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react' +import { useAgentWorkItems } from './useAgentWorkItems' +import { useTeamAgents } from './useTeamAgents' +import { answerAgentQuestion } from '../services/agentChatService' +import type { AgentChatSubject } from '../stores/agentChatStore' + +/** + * Verbindet den Chat mit den Beständen, die auf der Agentenseite ohnehin + * angezeigt werden (Runde 9, §3). + * + * Der Hook holt nur die Daten; was daraus wird, entscheidet + * `agentChatService` — Zählweise, Zeitraum und Formulierungen sind fachliche + * Festlegungen und gehören nicht in die Oberflächenschicht (CLAUDE.md §4.2). + * + * Bewusst dieselbe Quelle wie der Bearbeitungsverlauf: Was der Agent im Chat + * sagt, muss sich im Verlauf nachzählen lassen. Zwei getrennte Zählungen wären + * zwei Wahrheiten. + */ +export function useAgentChatAnswer() { + const { data: workItems = [], isLoading: itemsLoading } = useAgentWorkItems() + const { data: agents = [], isLoading: agentsLoading } = useTeamAgents() + + return useCallback( + (agent: AgentChatSubject, frage: string): string => { + // Thomas ist Vorgesetzter und hat kein eigenes Personalblatt; für ihn + // zählt der gesamte Bestand, für alle anderen nur der eigene. + const eigene = agent.id === 'thomas' + ? workItems + : workItems.filter(w => w.agentId === agent.id) + + return answerAgentQuestion( + { + agentId: agent.id, + agentName: agent.name, + agentRole: agent.role, + dossier: agents.find(a => a.id === agent.id), + workItems: eigene, + loading: itemsLoading || agentsLoading, + }, + frage, + ) + }, + [workItems, agents, itemsLoading, agentsLoading], + ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8ebe6d6..2324e49 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -349,6 +349,16 @@ export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?' */ export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?' +/** + * Einheitlicher KI-Hinweis in allen Agenten-Detailansichten (Runde 9, §5). + * + * Der Wortlaut ist vorgegeben und steht deshalb genau einmal hier. Sechsmal + * abgetippt hiesse er nach der ersten Korrektur an fünf Stellen anders — und + * ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner. + */ +export const AI_DISCLAIMER = + 'KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben' + // ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ────────────────── export const LEAD_SIGNAL_TYPE_LABELS: Record = { diff --git a/src/services/agentChatService.ts b/src/services/agentChatService.ts new file mode 100644 index 0000000..8599764 --- /dev/null +++ b/src/services/agentChatService.ts @@ -0,0 +1,178 @@ +import type { AgentWorkItem } from '../domain/agentWorkItem' +import { AgentWorkItemStatus } from '../domain/agentWorkItem' +import type { TeamAgent } from '../domain/teamAgent' +import { mockToday } from '../lib/constants' + +/** + * Property On — die Antworten der Agentenchats (Runde 9, §3). + * + * Bewusst eine Schlüsselwortlogik und kein Sprachmodell: Der Auftrag verlangt + * ausdrücklich keine LLM- oder RAG-Infrastruktur, und für die drei Fragetypen + * wäre sie auch nicht der Punkt. Der Punkt ist, dass die Zahlen stimmen. + * + * Jede Antwort stammt aus Beständen, die auf der Seite des jeweiligen Agenten + * ohnehin sichtbar sind. Nichts wird geschätzt, nichts hochgerechnet: Findet + * sich keine Grundlage, sagt der Agent das, statt eine plausible Zahl zu + * nennen. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort — sie + * sieht aus wie eine Auskunft. + */ + +/** Was gefragt wurde. */ +export const ChatIntent = { + /** «Womit kannst du mir helfen?» */ + CAPABILITIES: 'CAPABILITIES', + /** «Wie viele Aufgaben sind in deinem Log?» */ + LOG_COUNT: 'LOG_COUNT', + /** «Wie viele Aufträge wurden letzten Monat bearbeitet?» */ + LAST_MONTH: 'LAST_MONTH', + /** Nichts davon erkannt. */ + UNKNOWN: 'UNKNOWN', +} as const +export type ChatIntent = typeof ChatIntent[keyof typeof ChatIntent] + +/** + * Erkennung per Schlüsselwörtern. + * + * Die Reihenfolge entscheidet: «Wie viele Aufträge wurden letzten Monat + * bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als Frage nach + * dem Log gelesen. Der Zeitbezug ist die speziellere Aussage und wird deshalb + * zuerst geprüft. + */ +export function detectIntent(text: string): ChatIntent { + const t = text.toLowerCase() + + const zeitbezug = /letzte[nrs]?\s+(monat|30\s*tage)|vormonat|vergangenen\s+monat/.test(t) + const menge = /wie\s*viel|anzahl|wie\s*oft/.test(t) + + if (zeitbezug && (menge || /auftr|fäll|falle|vorgäng|bearbeit|erledigt|task|aufgab/.test(t))) { + return ChatIntent.LAST_MONTH + } + if (menge && /log|aufgab|task|auftr|vorgäng|eintr|fäll|falle|pendent|offen/.test(t)) { + return ChatIntent.LOG_COUNT + } + if (/womit|was\s+kannst|wobei|wofür|hilf|helfen|kannst du|zuständig|aufgabe(n)?\s*bereich|rolle/.test(t)) { + return ChatIntent.CAPABILITIES + } + return ChatIntent.UNKNOWN +} + +export interface AgentChatInput { + agentId: string + agentName: string + agentRole: string + /** Personaldossier des Agenten, sofern vorhanden — Thomas hat keines. */ + dossier?: TeamAgent + /** + * Vorgänge aus dem Bearbeitungsverlauf. Für einen einzelnen Agenten seine + * eigenen; für Thomas als Vorgesetzten die aller Agenten. + */ + workItems: AgentWorkItem[] + /** Sind die Bestände schon geladen? Sonst wird nicht gezählt, sondern gewartet. */ + loading: boolean +} + +/** Kalendermonat vor dem Stichtag der Mockdaten. */ +function letzterMonat(): { von: Date; bis: Date; name: string } { + const heute = mockToday() + const von = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth() - 1, 1)) + const bis = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth(), 1)) + return { + von, + bis, + name: von.toLocaleDateString('de-CH', { month: 'long', year: 'numeric', timeZone: 'UTC' }), + } +} + +function plural(n: number, ein: string, viele: string): string { + return `${n} ${n === 1 ? ein : viele}` +} + +function antwortFaehigkeiten(input: AgentChatInput): string { + const { agentName, agentRole, dossier } = input + + // Der Kurzbeschrieb aus dem Personalblatt ist genau der Satz, der auch in der + // Personalverwaltung steht — der Chat soll nichts anderes behaupten als das + // Dossier. + if (dossier) { + const aufgaben = dossier.responsibilities.slice(0, 3) + return [ + `Ich bin ${agentName}, ${agentRole}. ${dossier.shortDescription}`, + aufgaben.length > 0 + ? `Konkret kümmere ich mich unter anderem um: ${aufgaben.join('; ')}.` + : '', + 'Frag mich zum Beispiel, wie viele Aufgaben in meinem Log stehen oder wie viele Aufträge ich letzten Monat bearbeitet habe.', + ].filter(Boolean).join('\n\n') + } + + return [ + `Ich bin ${agentName}, ${agentRole}. Ich führe die fünf digitalen Mitarbeitenden und habe ihren gesamten Bearbeitungsverlauf im Blick.`, + 'Frag mich zum Beispiel, wie viele Vorgänge im Log stehen oder wie viele letzten Monat bearbeitet wurden.', + ].join('\n\n') +} + +function antwortLog(input: AgentChatInput): string { + const { workItems, agentId } = input + const gesamt = workItems.length + + if (gesamt === 0) { + return 'In meinem Log steht derzeit kein Vorgang. Sobald einer eingeht, siehst du ihn im Bearbeitungsverlauf.' + } + + const erledigt = workItems.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length + const pendent = workItems.filter(w => w.requiresDecision).length + const wessen = agentId === 'thomas' ? 'Im Log aller Agenten stehen' : 'In meinem Log stehen' + + return [ + `${wessen} ${plural(gesamt, 'Vorgang', 'Vorgänge')}.`, + `Davon ${plural(erledigt, 'erledigt', 'erledigt')}${pendent > 0 ? ` und ${plural(pendent, 'wartet auf deine Entscheidung', 'warten auf deine Entscheidung')}` : ''}.`, + ].join(' ') +} + +function antwortLetzterMonat(input: AgentChatInput): string { + const { workItems, agentId } = input + const { von, bis, name } = letzterMonat() + + const imZeitraum = workItems.filter(w => { + const stempel = w.completedAt ?? w.createdAt + const t = new Date(stempel).getTime() + return Number.isFinite(t) && t >= von.getTime() && t < bis.getTime() + }) + + const wer = agentId === 'thomas' ? 'Die Agenten haben' : 'Ich habe' + + if (imZeitraum.length === 0) { + return [ + `Im ${name} ist in meinem Bestand kein Vorgang verzeichnet.`, + `Insgesamt liegen ${plural(workItems.length, 'Vorgang', 'Vorgänge')} im Log — die verteilen sich auf andere Monate.`, + ].join(' ') + } + + const erledigt = imZeitraum.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length + return `${wer} im ${name} ${plural(imZeitraum.length, 'Vorgang', 'Vorgänge')} bearbeitet, davon ${erledigt} abgeschlossen.` +} + +/** + * Die Antwort auf eine Chatfrage — oder eine ehrliche Auskunft darüber, dass + * die Frage nicht verstanden wurde. + */ +export function answerAgentQuestion(input: AgentChatInput, frage: string): string { + if (input.loading) { + return 'Einen Moment — ich hole gerade meine Daten.' + } + + switch (detectIntent(frage)) { + case ChatIntent.CAPABILITIES: + return antwortFaehigkeiten(input) + case ChatIntent.LOG_COUNT: + return antwortLog(input) + case ChatIntent.LAST_MONTH: + return antwortLetzterMonat(input) + default: + return [ + 'Das habe ich nicht verstanden. In dieser Demo beantworte ich drei Arten von Fragen:', + '· Womit kannst du mir helfen?', + '· Wie viele Aufgaben sind in deinem Log?', + '· Wie viele Aufträge wurden letzten Monat bearbeitet?', + ].join('\n') + } +} diff --git a/src/stores/agentChatStore.ts b/src/stores/agentChatStore.ts index eaa503f..05f1216 100644 --- a/src/stores/agentChatStore.ts +++ b/src/stores/agentChatStore.ts @@ -1,5 +1,11 @@ import { create } from 'zustand' +/** Eine Zeile im Chatverlauf. */ +export interface AgentChatMessage { + from: 'user' | 'agent' + text: string +} + export interface AgentChatSubject { id: string name: string @@ -19,12 +25,21 @@ interface AgentChatState { * Scrollen mitten im Tippen den Fokus (§9). */ focused: boolean + /** + * Der Verlauf des laufenden Gesprächs (Runde 9, §3). + * + * Bewusst nicht gespeichert: Er lebt, solange die Seite offen ist, und ist + * beim nächsten Besuch leer. Ein Verlauf, der Tage überdauert, weckte die + * Erwartung, der Agent erinnere sich — und das täte er nicht. + */ + messages: AgentChatMessage[] openFor: (agent: AgentChatSubject) => void close: () => void setDraft: (draft: string) => void setCompact: (compact: boolean) => void setFocused: (focused: boolean) => void + append: (message: AgentChatMessage) => void } /** @@ -44,16 +59,19 @@ export const useAgentChatStore = create((set) => ({ draft: '', compact: false, focused: false, + messages: [], - // Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in - // Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen. + // Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört + // nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen + // Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen. openFor: (agent) => set(s => (s.agent?.id === agent.id ? { agent } - : { agent, draft: '', compact: false, focused: false })), + : { agent, draft: '', compact: false, focused: false, messages: [] })), close: () => set({ agent: null, compact: false, focused: false }), setDraft: (draft) => set({ draft }), setCompact: (compact) => set({ compact }), setFocused: (focused) => set({ focused }), + append: (message) => set(s => ({ messages: [...s.messages, message] })), }))