/** * Structured view over an agent's free-text systemPrompt. * * Templates and the editor split the prompt into four H2 sections so the UI * can show "who is this employee" as a tagline on the agent card and edit * each part separately. The on-disk format stays a single `systemPrompt` * string — these helpers parse it on read and serialize it on save, so the * backend schema is untouched and prompts authored before this feature * still load (their full text falls into `extra`). * * Section markers are fixed English strings — they are an internal protocol, * never shown to the user. The content inside each section can be in any * language. */ export interface AgentPromptProfile { role: string goal: string backstory: string extra: string } const SECTION_KEYS = ['role', 'goal', 'backstory', 'extra'] as const type SectionKey = (typeof SECTION_KEYS)[number] const SECTION_HEADINGS: Record = { role: 'Role', goal: 'Goal', backstory: 'Backstory', extra: 'Additional Instructions', } const HEADING_TO_KEY: Record = { role: 'role', goal: 'goal', backstory: 'backstory', 'additional instructions': 'extra', } const SECTION_HEADING_REGEX = /^##\s+(.+?)\s*$/ export function emptyProfile(): AgentPromptProfile { return { role: '', goal: '', backstory: '', extra: '' } } export function isStructuredPrompt(systemPrompt: string | null | undefined): boolean { if (!systemPrompt) return false const lines = systemPrompt.split(/\r?\n/) for (const line of lines) { const m = line.match(SECTION_HEADING_REGEX) if (m && HEADING_TO_KEY[m[1].trim().toLowerCase()]) return true } return false } /** * Parse a systemPrompt into role/goal/backstory/extra. If no section markers * are present, the entire prompt becomes `extra` so legacy agents keep their * prompt verbatim. */ export function parsePrompt(systemPrompt: string | null | undefined): AgentPromptProfile { const profile = emptyProfile() if (!systemPrompt) return profile if (!isStructuredPrompt(systemPrompt)) { profile.extra = systemPrompt.trim() return profile } const lines = systemPrompt.split(/\r?\n/) let current: SectionKey | null = null const buffers: Record = { role: [], goal: [], backstory: [], extra: [], } for (const line of lines) { const m = line.match(SECTION_HEADING_REGEX) if (m) { const key = HEADING_TO_KEY[m[1].trim().toLowerCase()] if (key) { current = key continue } } if (current) buffers[current].push(line) } for (const key of SECTION_KEYS) { profile[key] = buffers[key].join('\n').trim() } return profile } /** * Serialize a profile back into a systemPrompt string. Empty sections are * omitted so the stored prompt stays compact. */ export function serializePrompt(profile: AgentPromptProfile): string { const parts: string[] = [] for (const key of SECTION_KEYS) { const value = (profile[key] || '').trim() if (!value) continue parts.push(`## ${SECTION_HEADINGS[key]}\n${value}`) } return parts.join('\n\n') } /** * Maximum visible characters for the card tagline. CJK characters count as * one — the rendered string stays roughly the same width as 14 Chinese * characters or about 28 Latin characters. */ export const TAGLINE_MAX_LENGTH = 28 export const TAGLINE_CJK_BUDGET = 14 function visualLength(text: string): number { let len = 0 for (const ch of text) { const code = ch.codePointAt(0) || 0 // CJK Unified Ideographs / full-width punctuation count as 2 cells, ASCII as 1. if ( (code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3000 && code <= 0x30ff) || (code >= 0xff00 && code <= 0xffef) ) { len += 2 } else { len += 1 } } return len } function truncateVisual(text: string, maxCells: number): string { let used = 0 let out = '' for (const ch of text) { const code = ch.codePointAt(0) || 0 const cells = (code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3000 && code <= 0x30ff) || (code >= 0xff00 && code <= 0xffef) ? 2 : 1 if (used + cells > maxCells) return out + '…' out += ch used += cells } return out } /** * Build the one-line `{role} · {goal}` tagline shown on the agent card. * Falls back gracefully: * - role + goal → "数据分析师 · 把数据变成洞察" * - role only → "数据分析师" * - goal only → goal (truncated) * - neither + extra → first non-empty line of extra (truncated) * - nothing at all → empty string (caller decides fallback) */ export function deriveTagline( profile: AgentPromptProfile, fallbackDescription?: string | null, ): string { const role = (profile.role || '').trim().split(/\r?\n/)[0]?.trim() || '' const goal = (profile.goal || '').trim().split(/\r?\n/)[0]?.trim() || '' let tagline = '' if (role && goal) { tagline = `${role} · ${goal}` } else if (role) { tagline = role } else if (goal) { tagline = goal } else if ((profile.extra || '').trim()) { tagline = profile.extra.trim().split(/\r?\n/)[0]?.trim() || '' } else if (fallbackDescription && fallbackDescription.trim()) { tagline = fallbackDescription.trim().split(/\r?\n/)[0]?.trim() || '' } // Cap visual width so long goals can't push the card layout around. const cap = TAGLINE_MAX_LENGTH * 2 // visualLength counts CJK as 2 return visualLength(tagline) > cap ? truncateVisual(tagline, cap) : tagline } /** * Used by the Goal field's live preview to colour the hint when the user * crosses the recommended length. Returns visual width in CJK-equivalent * cells so the threshold compares apples to apples regardless of script. */ export function taglineVisualWidth(text: string): number { return Math.ceil(visualLength(text) / 2) }