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>
This commit is contained in:
1 parent
f2926d6bcb
commit
c6f3b2d6e3
20 files changed
+481
-66
No files matched your search
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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>) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] })),
|
||||||
}))
|
}))
|
||||||
Reference in new issue
Block a user