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 { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
||||
import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
@@ -50,7 +51,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
const signalType = leadSignalTypeOf(signal)
|
||||
const risiko = isRisiko(signal)
|
||||
const rationale = signalRationaleOf(signal)
|
||||
const action = recommendedActionOf(signal)
|
||||
|
||||
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
|
||||
const relevant = useMemo(
|
||||
@@ -215,16 +215,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
)}
|
||||
</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). */}
|
||||
{showMatching && (
|
||||
<>
|
||||
@@ -248,11 +238,14 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</>
|
||||
)}
|
||||
|
||||
{signal.disclaimer && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
{/*
|
||||
An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
|
||||
(«Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
|
||||
KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle,
|
||||
und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche
|
||||
Fehler soll aber überall gleich lauten (§4, §5).
|
||||
*/}
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, Chip, Typography } from '@mui/material'
|
||||
import { ExternalLink, MapPin } from 'lucide-react'
|
||||
import type { ResearchLead } from '../../domain/researchLead'
|
||||
import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
@@ -74,23 +75,16 @@ export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead:
|
||||
>
|
||||
Warum relevant
|
||||
</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}
|
||||
</Typography>
|
||||
|
||||
{lead.recommendedAction && (
|
||||
<>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||
>
|
||||
Empfohlene nächste Handlung
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
|
||||
{lead.recommendedAction}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen.
|
||||
Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt —
|
||||
entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete
|
||||
beschränkt bleibt und nicht als Anweisung gelesen wird.
|
||||
*/}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
@@ -119,6 +113,8 @@ export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead:
|
||||
Originalquelle öffnen <ExternalLink size={11} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -159,7 +159,19 @@ export function MeetingRoom3D() {
|
||||
}
|
||||
}, [positionLabels])
|
||||
|
||||
// ── Maus: Parallaxe und Trefferprüfung ──
|
||||
/**
|
||||
* Maus: nur noch Trefferprüfung, keine Bewegung mehr (Runde 9, §1).
|
||||
*
|
||||
* Bis hierher folgte die Kamera dem Zeiger — eine leichte Parallaxe, die den
|
||||
* Raum lebendig wirken lassen sollte. Gewünscht ist jetzt eine vollständig
|
||||
* ruhige Szene: Wer die Agenten anschaut, soll sie anschauen und nicht ein
|
||||
* Bild, das unter dem Zeiger wegkippt.
|
||||
*
|
||||
* Entfernt ist ausschliesslich der Aufruf von `applyParallax` — die Szene
|
||||
* selbst, ihre Kameraposition, Sitzordnung und Beschriftungen bleiben
|
||||
* unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf
|
||||
* seinen Platz sind keine Bewegung und bleiben deshalb.
|
||||
*/
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const s = sceneRef.current
|
||||
const wrap = wrapRef.current
|
||||
@@ -169,8 +181,6 @@ export function MeetingRoom3D() {
|
||||
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
|
||||
s.applyParallax(nx, ny)
|
||||
|
||||
const ray = new THREE.Raycaster()
|
||||
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
|
||||
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
|
||||
@@ -187,7 +197,6 @@ export function MeetingRoom3D() {
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
const s = sceneRef.current
|
||||
if (!s) return
|
||||
s.applyParallax(0, 0)
|
||||
hoveredRef.current = null
|
||||
s.setHovered(null)
|
||||
setHovered(null)
|
||||
|
||||
@@ -65,11 +65,12 @@ export const CAMERA = {
|
||||
maxHFov: 84,
|
||||
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
||||
lookAt: [0, 1.24, -5.2] as const,
|
||||
/**
|
||||
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
|
||||
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
||||
/*
|
||||
* Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
|
||||
* um bis zu 7,5 Zentimeter, was den Raum beleben sollte und stattdessen als
|
||||
* Wackeln wahrgenommen wurde. Kameraposition und Blickziel bleiben exakt
|
||||
* dieselben — nur bewegt sich nichts mehr.
|
||||
*/
|
||||
parallax: 0.075,
|
||||
} as const
|
||||
|
||||
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface MeetingRoomScene {
|
||||
renderer: THREE.WebGLRenderer
|
||||
seats: SeatHandle[]
|
||||
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
|
||||
applyParallax: (dx: number, dy: number) => void
|
||||
setSize: (width: number, height: number) => void
|
||||
render: () => void
|
||||
setHovered: (id: string | null) => void
|
||||
@@ -549,9 +548,6 @@ export function createMeetingRoomScene(
|
||||
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||
)
|
||||
|
||||
const baseCam = camera.position.clone()
|
||||
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
|
||||
|
||||
function render() {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
@@ -562,15 +558,6 @@ export function createMeetingRoomScene(
|
||||
renderer,
|
||||
seats: handles,
|
||||
|
||||
applyParallax(dx, dy) {
|
||||
camera.position.set(
|
||||
baseCam.x + dx * CAMERA.parallax,
|
||||
baseCam.y + dy * CAMERA.parallax * 0.6,
|
||||
baseCam.z,
|
||||
)
|
||||
camera.lookAt(lookTarget)
|
||||
},
|
||||
|
||||
setSize(width, height) {
|
||||
renderer.setSize(width, height, false)
|
||||
const aspect = width / Math.max(height, 1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
|
||||
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
@@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) {
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
|
||||
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
|
||||
|
||||
@@ -115,7 +115,43 @@ export function PropertyTable({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||
@@ -205,7 +241,7 @@ export function PropertyTable({
|
||||
|
||||
{/* Aktueller Mieter */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="body2">
|
||||
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
@@ -249,7 +285,7 @@ export function PropertyTable({
|
||||
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{ width: 80 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 90 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={qScore * 100}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { X, Calendar } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import {
|
||||
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
|
||||
} from '../../hooks/useReminders'
|
||||
@@ -201,6 +202,8 @@ export function ReminderDetailDrawer() {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</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 { SendHorizonal } from 'lucide-react'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AgentChatTranscript } from './AgentChatTranscript'
|
||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
@@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<AgentChatTranscript />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material'
|
||||
import { HelpCircle, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
@@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() {
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -87,3 +87,6 @@ export {
|
||||
PROTOCOL_STATUS_SURFACE,
|
||||
PROTOCOL_STATUS_TEXT,
|
||||
} from './teamTokens'
|
||||
|
||||
// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg.
|
||||
export { AgentChatTranscript } from './AgentChatTranscript'
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useAgentChatAnswer } from '../../hooks/useAgentChatAnswer'
|
||||
|
||||
/**
|
||||
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
||||
@@ -29,7 +29,10 @@ interface Options {
|
||||
* vergessen.
|
||||
*
|
||||
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||
* Seit Runde 9 (§3) antwortet der Chat tatsächlich: aus den Beständen der
|
||||
* jeweiligen Agentenseite, per Schlüsselwortlogik, ohne Backend und ohne
|
||||
* Sprachmodell. Was er nicht belegen kann, sagt er nicht — eine erfundene Zahl
|
||||
* wäre schlimmer als ein «das habe ich nicht verstanden».
|
||||
*/
|
||||
export function useAgentChat({ active = true }: Options = {}) {
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
@@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) {
|
||||
const focused = useAgentChatStore(s => s.focused)
|
||||
const setDraft = useAgentChatStore(s => s.setDraft)
|
||||
const setFocused = useAgentChatStore(s => s.setFocused)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const append = useAgentChatStore(s => s.append)
|
||||
const answerFor = useAgentChatAnswer()
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) {
|
||||
el.setSelectionRange(end, end)
|
||||
}, [active, focused])
|
||||
|
||||
/**
|
||||
* Frage absenden und beantworten (Runde 9, §3).
|
||||
*
|
||||
* Bis hierher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt
|
||||
* antwortet der Agent aus den Beständen seiner eigenen Seite — ohne Backend
|
||||
* und ohne Sprachmodell, wie es der Auftrag verlangt.
|
||||
*
|
||||
* Frage und Antwort landen zusammen im Verlauf. Der Toast entfällt: Wer eine
|
||||
* Antwort im Chat sieht, braucht daneben keine Einblendung, die dasselbe
|
||||
* Ereignis noch einmal meldet.
|
||||
*/
|
||||
const send = useCallback(() => {
|
||||
const text = draft.trim()
|
||||
if (!text || !agent) return
|
||||
setDraft('')
|
||||
showToast(
|
||||
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`,
|
||||
'info',
|
||||
)
|
||||
}, [draft, agent, setDraft, showToast])
|
||||
append({ from: 'user', text })
|
||||
append({ from: 'agent', text: answerFor(agent, text) })
|
||||
}, [draft, agent, setDraft, append, answerFor])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<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 { DecisionContextPanel } 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 { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
|
||||
import type { VisitAssignment } from '../../domain/visitAssignment'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { VisitRequestType } from '../../domain/visitAssignment'
|
||||
import { needsReportWarning } from '../../services/visitAssignmentService'
|
||||
import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
|
||||
@@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</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?'
|
||||
|
||||
/**
|
||||
* Einheitlicher KI-Hinweis in allen Agenten-Detailansichten (Runde 9, §5).
|
||||
*
|
||||
* Der Wortlaut ist vorgegeben und steht deshalb genau einmal hier. Sechsmal
|
||||
* abgetippt hiesse er nach der ersten Korrektur an fünf Stellen anders — und
|
||||
* ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||
*/
|
||||
export const AI_DISCLAIMER =
|
||||
'KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben'
|
||||
|
||||
// ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
|
||||
|
||||
export const LEAD_SIGNAL_TYPE_LABELS: Record<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'
|
||||
|
||||
/** Eine Zeile im Chatverlauf. */
|
||||
export interface AgentChatMessage {
|
||||
from: 'user' | 'agent'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AgentChatSubject {
|
||||
id: string
|
||||
name: string
|
||||
@@ -19,12 +25,21 @@ interface AgentChatState {
|
||||
* Scrollen mitten im Tippen den Fokus (§9).
|
||||
*/
|
||||
focused: boolean
|
||||
/**
|
||||
* Der Verlauf des laufenden Gesprächs (Runde 9, §3).
|
||||
*
|
||||
* Bewusst nicht gespeichert: Er lebt, solange die Seite offen ist, und ist
|
||||
* beim nächsten Besuch leer. Ein Verlauf, der Tage überdauert, weckte die
|
||||
* Erwartung, der Agent erinnere sich — und das täte er nicht.
|
||||
*/
|
||||
messages: AgentChatMessage[]
|
||||
|
||||
openFor: (agent: AgentChatSubject) => void
|
||||
close: () => void
|
||||
setDraft: (draft: string) => void
|
||||
setCompact: (compact: boolean) => void
|
||||
setFocused: (focused: boolean) => void
|
||||
append: (message: AgentChatMessage) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,16 +59,19 @@ export const useAgentChatStore = create<AgentChatState>((set) => ({
|
||||
draft: '',
|
||||
compact: false,
|
||||
focused: false,
|
||||
messages: [],
|
||||
|
||||
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in
|
||||
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen.
|
||||
// Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört
|
||||
// nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen
|
||||
// Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen.
|
||||
openFor: (agent) =>
|
||||
set(s => (s.agent?.id === agent.id
|
||||
? { agent }
|
||||
: { agent, draft: '', compact: false, focused: false })),
|
||||
: { agent, draft: '', compact: false, focused: false, messages: [] })),
|
||||
|
||||
close: () => set({ agent: null, compact: false, focused: false }),
|
||||
setDraft: (draft) => set({ draft }),
|
||||
setCompact: (compact) => set({ compact }),
|
||||
setFocused: (focused) => set({ focused }),
|
||||
append: (message) => set(s => ({ messages: [...s.messages, message] })),
|
||||
}))
|
||||
Reference in new issue
Block a user