182 lines
5.2 KiB
TypeScript
182 lines
5.2 KiB
TypeScript
import { type CSSProperties, useState } from 'react'
|
|
|
|
import introCopyJsonl from './intro-copy.jsonl?raw'
|
|
|
|
type IntroCopy = {
|
|
headline: string
|
|
body: string
|
|
}
|
|
|
|
type IntroCopyRecord = IntroCopy & {
|
|
personality: string
|
|
}
|
|
|
|
export type IntroProps = {
|
|
personality?: string
|
|
seed?: number
|
|
}
|
|
|
|
const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral'])
|
|
|
|
const FALLBACK_COPY: IntroCopy[] = [
|
|
{
|
|
headline: 'What are we moving today?',
|
|
body: "Send a bug, branch, plan, or rough idea. I'll inspect the repo and turn it into the next concrete step."
|
|
},
|
|
{
|
|
headline: "What's on your mind?",
|
|
body: "Bring the code, question, or stuck part. I'll read the room before making changes."
|
|
},
|
|
{
|
|
headline: 'What should Hermes look at?',
|
|
body: "Send the task, failing path, or half-formed plan. I'll help turn it into action."
|
|
},
|
|
{
|
|
headline: 'Where should we start?',
|
|
body: "Bring the problem, goal, or file. I'll inspect first and keep the next step concrete."
|
|
},
|
|
{
|
|
headline: 'What needs attention?',
|
|
body: "Send the context you have. I'll help sort it into a plan or a fix."
|
|
}
|
|
]
|
|
|
|
function normalizeKey(value?: string): string {
|
|
return (value || '').trim().toLowerCase()
|
|
}
|
|
|
|
function titleize(value: string): string {
|
|
return value
|
|
.split(/[-_\s]+/)
|
|
.filter(Boolean)
|
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
function isIntroCopyRecord(value: unknown): value is IntroCopyRecord {
|
|
if (!value || typeof value !== 'object') {
|
|
return false
|
|
}
|
|
|
|
const record = value as Record<string, unknown>
|
|
|
|
return (
|
|
typeof record.personality === 'string' &&
|
|
typeof record.headline === 'string' &&
|
|
typeof record.body === 'string' &&
|
|
Boolean(record.personality.trim()) &&
|
|
Boolean(record.headline.trim()) &&
|
|
Boolean(record.body.trim())
|
|
)
|
|
}
|
|
|
|
function parseIntroCopy(raw: string): Record<string, IntroCopy[]> {
|
|
const byPersonality: Record<string, IntroCopy[]> = {}
|
|
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const trimmed = line.trim()
|
|
|
|
if (!trimmed) {
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const parsed: unknown = JSON.parse(trimmed)
|
|
|
|
if (!isIntroCopyRecord(parsed)) {
|
|
continue
|
|
}
|
|
|
|
const key = normalizeKey(parsed.personality)
|
|
byPersonality[key] ??= []
|
|
byPersonality[key].push({
|
|
headline: parsed.headline.trim(),
|
|
body: parsed.body.trim()
|
|
})
|
|
} catch {
|
|
// Bad generated copy should not break the whole desktop app.
|
|
}
|
|
}
|
|
|
|
return byPersonality
|
|
}
|
|
|
|
const INTRO_COPY_BY_PERSONALITY = parseIntroCopy(introCopyJsonl)
|
|
|
|
function neutralCopy(): IntroCopy[] {
|
|
return INTRO_COPY_BY_PERSONALITY.none || INTRO_COPY_BY_PERSONALITY.default || FALLBACK_COPY
|
|
}
|
|
|
|
function fallbackCopyForPersonality(personalityKey: string): IntroCopy[] {
|
|
if (NEUTRAL_PERSONALITIES.has(personalityKey)) {
|
|
return neutralCopy()
|
|
}
|
|
|
|
const label = titleize(personalityKey)
|
|
|
|
return [
|
|
{
|
|
headline: `${label} mode is on. What should we work on?`,
|
|
body: "Send the task, file, or rough idea. I'll use your configured voice and keep the work grounded in this repo."
|
|
},
|
|
{
|
|
headline: `What does ${label} Hermes need to see?`,
|
|
body: "Bring the context or the stuck part. I'll adapt to your configured personality."
|
|
},
|
|
{
|
|
headline: `${label} mode is ready.`,
|
|
body: "Send the problem, file, or idea. I'll follow the personality you've configured."
|
|
},
|
|
{
|
|
headline: `What should ${label} Hermes tackle?`,
|
|
body: "Drop the task here. I'll keep the work grounded in the repo."
|
|
},
|
|
{
|
|
headline: 'Where should we begin?',
|
|
body: `Give me the context and I'll answer in ${label} mode.`
|
|
}
|
|
]
|
|
}
|
|
|
|
function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
|
|
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
|
|
}
|
|
|
|
function resolveCopy(personality?: string, seed?: number): IntroCopy {
|
|
const personalityKey = normalizeKey(personality)
|
|
|
|
const copies = NEUTRAL_PERSONALITIES.has(personalityKey)
|
|
? INTRO_COPY_BY_PERSONALITY[personalityKey] || neutralCopy()
|
|
: INTRO_COPY_BY_PERSONALITY[personalityKey] || fallbackCopyForPersonality(personalityKey)
|
|
|
|
return pickCopy(copies, seed)
|
|
}
|
|
|
|
export function Intro({ personality, seed }: IntroProps) {
|
|
const [mountSeed] = useState(() => Math.floor(Math.random() * 100000))
|
|
const copy = resolveCopy(personality, mountSeed + (seed ?? 0))
|
|
|
|
return (
|
|
<div
|
|
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
|
|
data-slot="aui_intro"
|
|
>
|
|
<div className="w-full min-w-0">
|
|
<p
|
|
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
|
style={
|
|
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
|
|
}
|
|
>
|
|
<span>
|
|
<span>HERMES AGENT</span>
|
|
</span>
|
|
<span aria-hidden="true">HERMES AGENT</span>
|
|
</p>
|
|
|
|
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|