mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
197 lines
5.8 KiB
TypeScript
197 lines
5.8 KiB
TypeScript
/**
|
|
* 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<SectionKey, string> = {
|
|
role: 'Role',
|
|
goal: 'Goal',
|
|
backstory: 'Backstory',
|
|
extra: 'Additional Instructions',
|
|
}
|
|
|
|
const HEADING_TO_KEY: Record<string, SectionKey> = {
|
|
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<SectionKey, string[]> = {
|
|
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)
|
|
}
|