Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f3b2d6e3 |
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