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] })),
}))