ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
292 lines
8.9 KiB
TypeScript
292 lines
8.9 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Check, Loader2, Circle, AlertTriangle } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { cn } from '@/lib/utils'
|
|
import ReviewCard from './ReviewCard'
|
|
|
|
type StepId = 'tic' | 'select' | 'narrative' | 'finalize'
|
|
type StepStatus = 'pending' | 'in_progress' | 'success' | 'fallback' | 'error'
|
|
|
|
interface StepConfig {
|
|
id: StepId
|
|
label: string
|
|
fallbackLabel?: string
|
|
}
|
|
|
|
const STEPS: StepConfig[] = [
|
|
{ id: 'tic', label: 'Hämtar uppgifter', fallbackLabel: 'Inga företagsuppgifter, fortsätter ändå' },
|
|
{ id: 'select', label: 'Identifierar din verksamhet', fallbackLabel: 'Använder standardval' },
|
|
{ id: 'narrative', label: 'Sammanfattar', fallbackLabel: 'Standardsammanfattning' },
|
|
{ id: 'finalize', label: 'Klar' },
|
|
]
|
|
|
|
interface InitialFields {
|
|
entity_type_label: string
|
|
sni_codes: { code: string; name: string }[]
|
|
purpose: string | null
|
|
city: string | null
|
|
fiscal_period: string | null
|
|
vat_period: string | null
|
|
f_skatt: string | null
|
|
employees: string | null
|
|
}
|
|
|
|
interface ProfilePayload {
|
|
company_id: string
|
|
horizontal_atoms: string[]
|
|
vertical_atoms: string[]
|
|
modifier_atoms: string[]
|
|
is_multi_vertical: boolean
|
|
profile_summary: string
|
|
verification_questions: string[]
|
|
uncertainty_notes: string[]
|
|
composer_model: string
|
|
composed_at: string
|
|
}
|
|
|
|
interface Props {
|
|
companyId: string
|
|
companyName: string
|
|
firstName: string | null
|
|
initialFields: InitialFields
|
|
atomTitles: Record<string, string>
|
|
alreadyVerified: boolean
|
|
existingSummary: string | null
|
|
}
|
|
|
|
export default function AgentOnboarding({
|
|
companyId,
|
|
companyName,
|
|
firstName,
|
|
initialFields,
|
|
atomTitles,
|
|
alreadyVerified,
|
|
existingSummary,
|
|
}: Props) {
|
|
const router = useRouter()
|
|
const [phase, setPhase] = useState<'building' | 'review' | 'done'>(
|
|
alreadyVerified ? 'review' : 'building',
|
|
)
|
|
const [statuses, setStatuses] = useState<Record<StepId, StepStatus>>({
|
|
tic: 'pending',
|
|
select: 'pending',
|
|
narrative: 'pending',
|
|
finalize: 'pending',
|
|
})
|
|
// Hydrate from props during the initial render so we don't flash an empty
|
|
// ReviewCard for already-verified profiles.
|
|
const [profile, setProfile] = useState<ProfilePayload | null>(() => {
|
|
if (alreadyVerified && existingSummary) {
|
|
return {
|
|
company_id: companyId,
|
|
horizontal_atoms: [],
|
|
vertical_atoms: [],
|
|
modifier_atoms: [],
|
|
is_multi_vertical: false,
|
|
profile_summary: existingSummary,
|
|
verification_questions: [],
|
|
uncertainty_notes: [],
|
|
composer_model: '',
|
|
composed_at: '',
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
|
|
const setStatus = useCallback((id: StepId, status: StepStatus) => {
|
|
setStatuses((prev) => ({ ...prev, [id]: status }))
|
|
}, [])
|
|
|
|
// No started-ref guard: React 19 Strict Mode runs this effect twice in dev.
|
|
// The first invocation's cleanup aborts its fetch; the second invocation
|
|
// makes the request that actually completes. The stream endpoint is
|
|
// idempotent (it upserts agent_profiles on company_id), so a transient
|
|
// duplicate call during Strict Mode rerun is safe.
|
|
useEffect(() => {
|
|
if (alreadyVerified) return
|
|
|
|
const controller = new AbortController()
|
|
void runStream(companyId, controller.signal, {
|
|
setStatus,
|
|
onProfile: (p) => setProfile(p),
|
|
onError: (msg) => setErrorMessage(msg),
|
|
onComplete: () => setPhase('review'),
|
|
})
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [companyId, alreadyVerified, setStatus])
|
|
|
|
if (phase === 'review' || phase === 'done') {
|
|
return (
|
|
<ReviewCard
|
|
companyId={companyId}
|
|
companyName={companyName}
|
|
initialFields={initialFields}
|
|
atomTitles={atomTitles}
|
|
profile={profile}
|
|
onVerified={() => router.push('/chat/intake')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<header className="mb-10 text-center">
|
|
<p className="text-sm uppercase tracking-wider text-muted-foreground">
|
|
{firstName ? `${firstName}, ` : ''}ett ögonblick
|
|
</p>
|
|
<h1 className="font-display text-3xl md:text-4xl tracking-tight mt-2">
|
|
Vi bygger din bokföringsassistent
|
|
</h1>
|
|
<p className="text-muted-foreground mt-3 text-balance">
|
|
Skräddarsyr för {companyName}. Tar oftast under en halv minut.
|
|
</p>
|
|
</header>
|
|
|
|
<Card className="border-border">
|
|
<CardContent className="p-6 md:p-8">
|
|
<ol className="space-y-4">
|
|
{STEPS.map((step) => (
|
|
<StepRow key={step.id} step={step} status={statuses[step.id]} />
|
|
))}
|
|
</ol>
|
|
|
|
{errorMessage && (
|
|
<div className="mt-6 flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
|
|
<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive shrink-0" />
|
|
<div>
|
|
<p className="font-medium">Något gick fel</p>
|
|
<p className="text-muted-foreground mt-1">{errorMessage}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3"
|
|
onClick={() => window.location.reload()}
|
|
>
|
|
Försök igen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StepRow({ step, status }: { step: StepConfig; status: StepStatus }) {
|
|
return (
|
|
<li className="flex items-start gap-3">
|
|
<span className="mt-0.5 shrink-0">
|
|
{status === 'success' && <Check className="h-4 w-4 text-success" />}
|
|
{status === 'fallback' && <Check className="h-4 w-4 text-warning" />}
|
|
{status === 'in_progress' && <Loader2 className="h-4 w-4 animate-spin text-foreground" />}
|
|
{status === 'pending' && <Circle className="h-4 w-4 text-muted-foreground/50" />}
|
|
{status === 'error' && <AlertTriangle className="h-4 w-4 text-destructive" />}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'text-sm leading-6',
|
|
status === 'pending' && 'text-muted-foreground',
|
|
status === 'fallback' && 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{status === 'fallback' && step.fallbackLabel ? step.fallbackLabel : step.label}
|
|
</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
async function runStream(
|
|
companyId: string,
|
|
signal: AbortSignal,
|
|
cbs: {
|
|
setStatus: (id: StepId, status: StepStatus) => void
|
|
onProfile: (p: ProfilePayload) => void
|
|
onError: (msg: string) => void
|
|
onComplete: () => void
|
|
},
|
|
): Promise<void> {
|
|
let response: Response
|
|
try {
|
|
response = await fetch('/api/agent/onboarding/stream', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ company_id: companyId }),
|
|
signal,
|
|
})
|
|
} catch (err) {
|
|
if (signal.aborted) return
|
|
cbs.onError(err instanceof Error ? err.message : 'Kunde inte starta byggsekvensen.')
|
|
return
|
|
}
|
|
|
|
if (!response.ok || !response.body) {
|
|
cbs.onError(`HTTP ${response.status}: kunde inte starta byggsekvensen.`)
|
|
return
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
let newlineIdx: number
|
|
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
|
|
const line = buffer.slice(0, newlineIdx).trim()
|
|
buffer = buffer.slice(newlineIdx + 1)
|
|
if (!line) continue
|
|
try {
|
|
const event = JSON.parse(line) as
|
|
| { step: StepId; status: StepStatus }
|
|
| { step: 'finalize'; status: 'success'; profile: ProfilePayload }
|
|
| { step: 'error'; status: 'error'; message: string }
|
|
| { step: 'prewarm'; status: StepStatus }
|
|
|
|
if (event.step === 'error') {
|
|
cbs.onError(event.message || 'Okänt fel under byggsekvensen.')
|
|
continue
|
|
}
|
|
|
|
if (event.step === 'prewarm') {
|
|
// Pre-warm runs after finalize: surface nothing in the UI; the
|
|
// user has already moved to Phase B by then.
|
|
continue
|
|
}
|
|
|
|
cbs.setStatus(event.step, event.status as StepStatus)
|
|
|
|
if (event.step === 'finalize' && 'profile' in event) {
|
|
cbs.onProfile(event.profile)
|
|
cbs.onComplete()
|
|
}
|
|
} catch {
|
|
// Malformed JSON line: keep reading.
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!signal.aborted) {
|
|
cbs.onError(err instanceof Error ? err.message : 'Streamen avbröts.')
|
|
}
|
|
} finally {
|
|
try {
|
|
reader.releaseLock()
|
|
} catch {
|
|
// Already released
|
|
}
|
|
}
|
|
}
|