Compare commits

..
1 Commits
Author SHA1 Message Date
Benjamin SutterandClaude Opus 5 c6f3b2d6e3 feat(agenten): Runde 9 — statisches Sitzungszimmer, kompakte Tabelle, Chats, KI-Hinweis
**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) <noreply@anthropic.com>
2026-08-30 20:52:43 +02:00
20 changed files with 481 additions and 66 deletions

No files matched your search

+10 -17
View File
@@ -5,9 +5,10 @@ import type { MarketLead } from '../../hooks/useMarketLeads'
import { AgentAvatar, ObjectDeepLink } from '../team' import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces' import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS } from '../../lib/constants' 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 { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge' import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
import { AiDisclaimer } from '../ui'
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels' import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' 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 signalType = leadSignalTypeOf(signal)
const risiko = isRisiko(signal) const risiko = isRisiko(signal)
const rationale = signalRationaleOf(signal) const rationale = signalRationaleOf(signal)
const action = recommendedActionOf(signal)
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt. // Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
const relevant = useMemo( const relevant = useMemo(
@@ -215,16 +215,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
)} )}
</Box> </Box>
{/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */}
<Box sx={{ bgcolor: 'white', border: '1px solid', borderColor: DS_BRAND.main, borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Empfohlene nächste Handlung
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
{action}
</Typography>
</Box>
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */} {/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
{showMatching && ( {showMatching && (
<> <>
@@ -248,11 +238,14 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
</> </>
)} )}
{signal.disclaimer && ( {/*
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}> An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
{signal.disclaimer} («Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
</Typography> 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).
*/}
<AiDisclaimer />
</Box> </Box>
</Box> </Box>
) )
+10 -14
View File
@@ -3,6 +3,7 @@ import { Box, Chip, Typography } from '@mui/material'
import { ExternalLink, MapPin } from 'lucide-react' import { ExternalLink, MapPin } from 'lucide-react'
import type { ResearchLead } from '../../domain/researchLead' import type { ResearchLead } from '../../domain/researchLead'
import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants' 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' 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 Warum relevant
</Typography> </Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: lead.recommendedAction ? 1.25 : 1.5 }}> <Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
{lead.reason} {lead.reason}
</Typography> </Typography>
{lead.recommendedAction && ( {/*
<> Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen.
<Typography Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt —
variant="caption" entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete
sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }} beschränkt bleibt und nicht als Anweisung gelesen wird.
> */}
Empfohlene nächste Handlung
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
{lead.recommendedAction}
</Typography>
</>
)}
<Box <Box
sx={{ sx={{
@@ -119,6 +113,8 @@ export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead:
Originalquelle öffnen <ExternalLink size={11} /> Originalquelle öffnen <ExternalLink size={11} />
</Box> </Box>
</Box> </Box>
<AiDisclaimer />
</Box> </Box>
) )
}) })
+13 -4
View File
@@ -159,7 +159,19 @@ export function MeetingRoom3D() {
} }
}, [positionLabels]) }, [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<HTMLDivElement>) => { const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const s = sceneRef.current const s = sceneRef.current
const wrap = wrapRef.current const wrap = wrapRef.current
@@ -169,8 +181,6 @@ export function MeetingRoom3D() {
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1 const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1) const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
s.applyParallax(nx, ny)
const ray = new THREE.Raycaster() const ray = new THREE.Raycaster()
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera) ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0] const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
@@ -187,7 +197,6 @@ export function MeetingRoom3D() {
const handlePointerLeave = useCallback(() => { const handlePointerLeave = useCallback(() => {
const s = sceneRef.current const s = sceneRef.current
if (!s) return if (!s) return
s.applyParallax(0, 0)
hoveredRef.current = null hoveredRef.current = null
s.setHovered(null) s.setHovered(null)
setHovered(null) setHovered(null)
+5 -4
View File
@@ -65,11 +65,12 @@ export const CAMERA = {
maxHFov: 84, maxHFov: 84,
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */ /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
lookAt: [0, 1.24, -5.2] as const, lookAt: [0, 1.24, -5.2] as const,
/** /*
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die * Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20). * 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 } as const
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */ /** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
-13
View File
@@ -41,7 +41,6 @@ export interface MeetingRoomScene {
renderer: THREE.WebGLRenderer renderer: THREE.WebGLRenderer
seats: SeatHandle[] seats: SeatHandle[]
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */ /** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
applyParallax: (dx: number, dy: number) => void
setSize: (width: number, height: number) => void setSize: (width: number, height: number) => void
render: () => void render: () => void
setHovered: (id: string | null) => void setHovered: (id: string | null) => void
@@ -549,9 +548,6 @@ export function createMeetingRoomScene(
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe), addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
) )
const baseCam = camera.position.clone()
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
function render() { function render() {
renderer.render(scene, camera) renderer.render(scene, camera)
} }
@@ -562,15 +558,6 @@ export function createMeetingRoomScene(
renderer, renderer,
seats: handles, 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) { setSize(width, height) {
renderer.setSize(width, height, false) renderer.setSize(width, height, false)
const aspect = width / Math.max(height, 1) const aspect = width / Math.max(height, 1)
@@ -4,6 +4,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property' import type { Property, UpdatePropertyInput } from '../../domain/property'
import { AiDisclaimer } from '../ui'
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties' import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
@@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) {
) )
})} })}
</Box> </Box>
{/*
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.
*/}
<AiDisclaimer />
</Box> </Box>
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */} {/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
+39 -3
View File
@@ -115,7 +115,43 @@ export function PropertyTable({
return ( return (
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Table stickyHeader size="small"> <Table
stickyHeader
size="small"
/*
* Feste Spaltenbreiten statt automatischer (Runde 9, §2).
*
* Mit automatischer Breite richtet sich jede Spalte nach ihrem längsten
* Inhalt — bei elf Spalten summiert sich das über die Fensterbreite
* hinaus, und die letzten beiden lagen im waagrechten Scrollbereich.
* Die Anteile unten ergeben zusammen 100 %; keine Spalte ist entfernt
* und kein Wert gekürzt, sie teilen sich den Platz nur nach Bedarf:
* Objektname und Mieter tragen lange Texte, «Fläche» oder «Break-Option»
* kommen mit wenig aus.
*/
sx={{
tableLayout: 'fixed',
'& .MuiTableCell-root': {
px: 1,
py: 0.75,
// Lange Namen brechen um, statt die Spalte aufzuweiten.
wordBreak: 'break-word',
},
}}
>
<colgroup>
<col style={{ width: '19%' }} />{/* Objekt */}
<col style={{ width: '7%' }} />{/* Typ */}
<col style={{ width: '9%' }} />{/* Standort */}
<col style={{ width: '6%' }} />{/* Fläche */}
<col style={{ width: '8%' }} />{/* Miete */}
<col style={{ width: '13%' }} />{/* Aktueller Mieter */}
<col style={{ width: '9%' }} />{/* Mietlaufzeit */}
<col style={{ width: '7%' }} />{/* Breakoutoption */}
<col style={{ width: '9%' }} />{/* Breakoutoption Zeitpunkt */}
<col style={{ width: '9%' }} />{/* Datenqualität */}
<col style={{ width: '4%' }} />{/* Aktionen */}
</colgroup>
<TableHead> <TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}> <TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
@@ -205,7 +241,7 @@ export function PropertyTable({
{/* Aktueller Mieter */} {/* Aktueller Mieter */}
<TableCell> <TableCell>
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}> <Typography variant="body2">
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>} {p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
</Typography> </Typography>
</TableCell> </TableCell>
@@ -249,7 +285,7 @@ export function PropertyTable({
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'} title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
placement="top" placement="top"
> >
<Box sx={{ width: 80 }}> <Box sx={{ width: '100%', maxWidth: 90 }}>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={qScore * 100} value={qScore * 100}
@@ -4,6 +4,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { X, Calendar } from 'lucide-react' import { X, Calendar } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore' import { useReminderStore } from '../../stores/reminderStore'
import { AiDisclaimer } from '../ui'
import { import {
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder, useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
} from '../../hooks/useReminders' } from '../../hooks/useReminders'
@@ -201,6 +202,8 @@ export function ReminderDetailDrawer() {
</Box> </Box>
</> </>
)} )}
<AiDisclaimer />
</Box> </Box>
)} )}
</Box> </Box>
@@ -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 (
<Box sx={{ width: '100%', maxWidth: 720, mt: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{messages.map((m, i) => (
m.from === 'user' ? (
<Box key={i} sx={{ alignSelf: 'flex-end', maxWidth: '85%' }}>
<Box
sx={{
px: 1.75, py: 1,
borderRadius: 2.5,
bgcolor: DS_SLATE[100],
border: `1px solid ${DS_BORDER.default}`,
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, whiteSpace: 'pre-line' }}>
{m.text}
</Typography>
</Box>
</Box>
) : (
<Box key={i} sx={{ display: 'flex', gap: 1.25, alignItems: 'flex-start', maxWidth: '95%' }}>
<Box sx={{ flexShrink: 0, mt: 0.25 }}>
<AgentAvatar agent={agent} size="small" />
</Box>
<Box
sx={{
px: 1.75, py: 1,
borderRadius: 2.5,
bgcolor: 'white',
border: `1px solid ${DS_BORDER.default}`,
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
{m.text}
</Typography>
</Box>
</Box>
)
))}
</Box>
)
}
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material' import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
import { SendHorizonal } from 'lucide-react' import { SendHorizonal } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { AgentChatTranscript } from './AgentChatTranscript'
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat' import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
import { AGENT_CHAT_PROMPT } from '../../lib/constants' import { AGENT_CHAT_PROMPT } from '../../lib/constants'
import { DS_BORDER, DS_TEXT } from '../../lib/ds' import { DS_BORDER, DS_TEXT } from '../../lib/ds'
@@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
</Box> </Box>
)} )}
</Box> </Box>
<AgentChatTranscript />
</Box> </Box>
) )
} }
@@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material'
import { HelpCircle, X } from 'lucide-react' import { HelpCircle, X } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useTeamStore } from '../../stores/teamStore' import { useTeamStore } from '../../stores/teamStore'
import { AiDisclaimer } from '../ui'
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems' import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
import { useTeamAgents } from '../../hooks/useTeamAgents' import { useTeamAgents } from '../../hooks/useTeamAgents'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
@@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() {
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
</Box> </Box>
)} )}
<AiDisclaimer />
</Box> </Box>
)} )}
</Box> </Box>
+3
View File
@@ -87,3 +87,6 @@ export {
PROTOCOL_STATUS_SURFACE, PROTOCOL_STATUS_SURFACE,
PROTOCOL_STATUS_TEXT, PROTOCOL_STATUS_TEXT,
} from './teamTokens' } from './teamTokens'
// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg.
export { AgentChatTranscript } from './AgentChatTranscript'
+21 -8
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import type { FocusEvent, KeyboardEvent } from 'react' import type { FocusEvent, KeyboardEvent } from 'react'
import { useAgentChatStore } from '../../stores/agentChatStore' 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 * Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
@@ -29,7 +29,10 @@ interface Options {
* vergessen. * vergessen.
* *
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und * 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 = {}) { export function useAgentChat({ active = true }: Options = {}) {
const agent = useAgentChatStore(s => s.agent) const agent = useAgentChatStore(s => s.agent)
@@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) {
const focused = useAgentChatStore(s => s.focused) const focused = useAgentChatStore(s => s.focused)
const setDraft = useAgentChatStore(s => s.setDraft) const setDraft = useAgentChatStore(s => s.setDraft)
const setFocused = useAgentChatStore(s => s.setFocused) const setFocused = useAgentChatStore(s => s.setFocused)
const showToast = useToastStore(s => s.showToast) const append = useAgentChatStore(s => s.append)
const answerFor = useAgentChatAnswer()
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null) const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
@@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) {
el.setSelectionRange(end, end) el.setSelectionRange(end, end)
}, [active, focused]) }, [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 send = useCallback(() => {
const text = draft.trim() const text = draft.trim()
if (!text || !agent) return if (!text || !agent) return
setDraft('') setDraft('')
showToast( append({ from: 'user', text })
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, append({ from: 'agent', text: answerFor(agent, text) })
'info', }, [draft, agent, setDraft, append, answerFor])
)
}, [draft, agent, setDraft, showToast])
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => { (e: KeyboardEvent<HTMLDivElement>) => {
+38
View File
@@ -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 (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 0.75,
mt: 2,
pt: 1.5,
borderTop: `1px solid ${DS_SLATE[100]}`,
}}
>
<Info size={13} color={DS_SLATE[400]} style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.muted, lineHeight: 1.5 }}>
{AI_DISCLAIMER}
</Typography>
</Box>
)
})
+3
View File
@@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider'
export { ConfirmDialog } from './ConfirmDialog' export { ConfirmDialog } from './ConfirmDialog'
export { DecisionContextPanel } from './DecisionContextPanel' export { DecisionContextPanel } from './DecisionContextPanel'
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel' export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'
// Runde 9, §5: einheitlicher KI-Hinweis in allen Agenten-Detailansichten.
export { AiDisclaimer } from './AiDisclaimer'
@@ -1,6 +1,7 @@
import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material' import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material'
import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react' import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
import type { VisitAssignment } from '../../domain/visitAssignment' import type { VisitAssignment } from '../../domain/visitAssignment'
import { AiDisclaimer } from '../ui'
import { VisitRequestType } from '../../domain/visitAssignment' import { VisitRequestType } from '../../domain/visitAssignment'
import { needsReportWarning } from '../../services/visitAssignmentService' import { needsReportWarning } from '../../services/visitAssignmentService'
import { useRequestVisitReport } from '../../hooks/useVisitAssignments' import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
@@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) {
</Box> </Box>
</> </>
)} )}
<AiDisclaimer />
</Box> </Box>
</> </>
)} )}
+45
View File
@@ -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],
)
}
+10
View File
@@ -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?' 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) ────────────────── // ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = { export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
+178
View File
@@ -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')
}
}
+21 -3
View File
@@ -1,5 +1,11 @@
import { create } from 'zustand' import { create } from 'zustand'
/** Eine Zeile im Chatverlauf. */
export interface AgentChatMessage {
from: 'user' | 'agent'
text: string
}
export interface AgentChatSubject { export interface AgentChatSubject {
id: string id: string
name: string name: string
@@ -19,12 +25,21 @@ interface AgentChatState {
* Scrollen mitten im Tippen den Fokus (§9). * Scrollen mitten im Tippen den Fokus (§9).
*/ */
focused: boolean 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 openFor: (agent: AgentChatSubject) => void
close: () => void close: () => void
setDraft: (draft: string) => void setDraft: (draft: string) => void
setCompact: (compact: boolean) => void setCompact: (compact: boolean) => void
setFocused: (focused: boolean) => void setFocused: (focused: boolean) => void
append: (message: AgentChatMessage) => void
} }
/** /**
@@ -44,16 +59,19 @@ export const useAgentChatStore = create<AgentChatState>((set) => ({
draft: '', draft: '',
compact: false, compact: false,
focused: false, focused: false,
messages: [],
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in // Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen. // nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen
// Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen.
openFor: (agent) => openFor: (agent) =>
set(s => (s.agent?.id === agent.id set(s => (s.agent?.id === agent.id
? { agent } ? { agent }
: { agent, draft: '', compact: false, focused: false })), : { agent, draft: '', compact: false, focused: false, messages: [] })),
close: () => set({ agent: null, compact: false, focused: false }), close: () => set({ agent: null, compact: false, focused: false }),
setDraft: (draft) => set({ draft }), setDraft: (draft) => set({ draft }),
setCompact: (compact) => set({ compact }), setCompact: (compact) => set({ compact }),
setFocused: (focused) => set({ focused }), setFocused: (focused) => set({ focused }),
append: (message) => set(s => ({ messages: [...s.messages, message] })),
})) }))