feat: companion 核心的人設注入、語氣、心情標記與互動記憶

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:27:34 +08:00
co-authored by Claude Sonnet 5.5
parent 030c599208
commit 83fd180872
10 changed files with 638 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
import { clamp } from './types.ts'
import type { Pad } from './types.ts'
export const TURN_CAP = 0.3
export const EVENTS = {
praise: { p: 0.15, a: 0.05, d: 0.05 },
scold: { p: -0.2, a: 0.15, d: -0.1 },
success: { p: 0.08, a: 0.03, d: 0.05 },
toolFailures: { p: -0.08, a: 0.1, d: -0.08 },
returnAfterIdle: { p: 0.1, a: 0.08, d: 0 },
lateNight: { p: -0.05, a: -0.1, d: 0 },
likedTopic: { p: 0.12, a: 0.08, d: 0.03 },
careIgnored: { p: -0.05, a: -0.03, d: -0.03 },
} as const satisfies Record<string, Pad>
export type MoodEvent = keyof typeof EVENTS
export const ZERO: Pad = { p: 0, a: 0, d: 0 }
/** 一回合內累計的變化量;單軸總變化不得超過 ±0.3 */
export function applyDelta(mood: Pad, turn: Pad, delta: Pad): { mood: Pad; turn: Pad } {
const out = { mood: { ...mood }, turn: { ...turn } }
for (const k of ['p', 'a', 'd'] as const) {
const next = clamp(turn[k] + delta[k], -TURN_CAP, TURN_CAP)
const step = next - turn[k]
out.turn[k] = next
out.mood[k] = clamp(mood[k] + step, -1, 1)
}
return out
}
/** 讀取時依經過時間往基線衰減:x = B + (x − B)·0.5^(Δ/τ) */
export function decay(mood: Pad, baseline: Pad, elapsedMs: number, halfLifeMs: number): Pad {
const f = Math.pow(0.5, Math.max(0, elapsedMs) / halfLifeMs)
return {
p: baseline.p + (mood.p - baseline.p) * f,
a: baseline.a + (mood.a - baseline.a) * f,
d: baseline.d + (mood.d - baseline.d) * f,
}
}
const MOODS: Record<string, { label: string; face: string }> = {
'+++': { label: '開心', face: '(≧▽≦)' },
'++-': { label: '安心', face: '(´▽`)' },
'+-+': { label: '放鬆', face: '(´ω`)' },
'+--': { label: '溫柔', face: '(。・ω・。)' },
'-++': { label: '生氣', face: '(`Д´)' },
'-+-': { label: '焦慮', face: '(;´Д`)' },
'--+': { label: '無聊', face: '(¬_¬)' },
'---': { label: '低落', face: '(´;ω;`)' },
}
/** 八分區對應情緒標籤與顏文字 */
export function moodLabel(m: Pad): { label: string; face: string } {
const s = (x: number) => (x >= 0 ? '+' : '-')
return MOODS[s(m.p) + s(m.a) + s(m.d)]!
}
// 心情標記:回覆末尾的隱藏標記,如 <!--mood p=0.1 a=-0.05 d=0-->
const TAG = /<!--\s*mood\b([^>]*?)-->\s*$/
export type ParsedReply = { text: string; delta: Pad | null }
export function parseMoodTag(reply: string): ParsedReply {
const m = TAG.exec(reply)
if (!m) return { text: stripStray(reply), delta: null }
const text = stripStray(reply.slice(0, m.index).trimEnd())
const vals: Partial<Pad> = {}
for (const kv of (m[1] ?? '').trim().split(/\s+/)) {
const mm = /^([pad])=(-?\d+(?:\.\d+)?)$/.exec(kv)
if (mm) vals[mm[1] as keyof Pad] = Number(mm[2])
}
if (vals.p === undefined || vals.a === undefined || vals.d === undefined) return { text, delta: null }
return { text, delta: { p: vals.p, a: vals.a, d: vals.d } }
}
// 格式錯誤的標記也不能露給使用者
function stripStray(s: string): string {
return s.replace(/\s*<!--\s*mood\b[^>]*?(?:-->|$)/g, '').trimEnd()
}
const KEYWORDS: [MoodEvent, RegExp][] = [
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],
['praise', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i],
['success', /成功|完成了|通過|搞定|passed|done|fixed/i],
]
/** 標記缺失或格式錯誤時的後備:用使用者文字的關鍵字對應事件 */
export function keywordEvent(text: string): MoodEvent | null {
for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev
return null
}