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
+175
View File
@@ -0,0 +1,175 @@
import { affinityLine } from './status.ts'
import * as aff from './affinity.ts'
import { assemble } from './inject.ts'
import type { Item } from './inject.ts'
import * as mem from './interaction.ts'
import * as mood from './mood.ts'
import { toneRules } from './tone.ts'
import { ZERO } from './mood.ts'
import type { Clock, Fs, Pad } from './types.ts'
export type Card = {
name: string
/** 依重要度由高到低 */
lines: string[]
baseline?: Pad
/** 心情半衰期(小時),預設 8 */
halfLifeHours?: number
}
export type UserConfig = { nickname?: string; tone?: boolean }
type State = { mood: Pad; moodAt: number; affinity: aff.Affinity }
export type Options = {
fs: Fs
clock: Clock
/** 資料根目錄,例如 ~/cockpit */
root: string
/** 當前角色資料夾名稱 */
id: string
}
const HOUR = 3_600_000
export function createCompanion({ fs, clock, root, id }: Options) {
const dir = `${root}/${id}`
// 互動記憶與原著記憶分開資料夾
const paths = {
card: `${dir}/card.json`,
state: `${dir}/state.json`,
interaction: `${dir}/interaction/memory.json`,
user: `${root}/user.json`,
}
let turnDelta: Pad = ZERO
let affinityMoved = false
let lastText = ''
async function json<T>(path: string, fallback: T): Promise<T> {
const raw = await fs.read(path)
if (!raw) return fallback
try {
return JSON.parse(raw) as T
} catch {
return fallback
}
}
const loadCard = () => json<Card>(paths.card, { name: id, lines: [] })
const loadUser = () => json<UserConfig>(paths.user, {})
async function loadState(card: Card): Promise<State> {
const now = clock.now()
const s = await json<State | null>(paths.state, null)
if (!s) return { mood: card.baseline ?? ZERO, moodAt: now, affinity: aff.initialAffinity(now) }
const mood0 = mood.decay(s.mood, card.baseline ?? ZERO, now - s.moodAt, (card.halfLifeHours ?? 8) * HOUR)
return { mood: mood0, moodAt: now, affinity: aff.settle(s.affinity, now) }
}
const saveState = (s: State) => fs.write(paths.state, JSON.stringify(s))
async function remember(role: 'user' | 'assistant', text: string, relation: number) {
const m = await mem.load(fs, paths.interaction)
await mem.save(fs, paths.interaction, mem.add(m, { role, text, at: clock.now() }, relation))
}
return {
/** 使用者回合:記錄輸入、更新心情與好感 */
async userTurn(text: string, events: mood.MoodEvent[] = []) {
const card = await loadCard()
const st = await loadState(card)
turnDelta = ZERO
lastText = text
const kw = mood.keywordEvent(text)
const all = kw ? [...events, kw] : events
let m = st.mood
for (const ev of events) ({ mood: m, turn: turnDelta } = mood.applyDelta(m, turnDelta, mood.EVENTS[ev]))
const now = clock.now()
const bad = all.some(e => e === 'scold' || e === 'careIgnored')
const good = all.some(e => e === 'praise' || e === 'success' || e === 'likedTopic')
let a = st.affinity
let rel = 0
if (bad) {
a = aff.negative(a, now)
rel = -aff.LOSS
} else if (text.trim() && (good || text.trim().length >= 4)) {
a = aff.meaningful(a, now)
rel = a.value - st.affinity.value
}
affinityMoved = rel !== 0
await saveState({ mood: m, moodAt: now, affinity: a })
await remember('user', text, rel)
},
/**
* 助理回覆:解析並移除心情標記,缺失或錯誤時沿用使用者回合已套用的關鍵字事件。
* 回傳可顯示的文字。
*/
async assistantReply(reply: string): Promise<string> {
const parsed = mood.parseMoodTag(reply)
const card = await loadCard()
const st = await loadState(card)
const kw = parsed.delta ? null : mood.keywordEvent(lastText)
const delta = parsed.delta ?? (kw ? mood.EVENTS[kw] : null)
if (delta) {
const { mood: m, turn } = mood.applyDelta(st.mood, turnDelta, delta)
turnDelta = turn
await saveState({ ...st, mood: m, moodAt: clock.now() })
}
await remember('assistant', parsed.text, 0)
return parsed.text
},
/** 組裝送出模型請求前要注入 system 區塊尾端的內容 */
async buildInjection(extra: { care?: string[]; lore?: Item[] } = {}): Promise<string> {
const card = await loadCard()
const user = await loadUser()
const st = await loadState(card)
const im = await mem.load(fs, paths.interaction)
const n = im.recent.length
const memory: Item[] = [
...(extra.lore ?? []),
...im.summary.map((t, i) => ({ text: `(摘要)${t}`, score: 0.1 + (0.2 * i) / Math.max(1, im.summary.length) })),
...im.recent.map((t, i) => ({ text: `${t.role === 'user' ? '使用者' : '你'}:${t.text}`, score: 0.5 + (0.5 * (i + 1)) / n })),
]
const m = mood.moodLabel(st.mood)
const tone = user.tone !== false
const cardItems: Item[] = [
{ text: `名字:${card.name}`, score: 1e9 },
...(tone ? toneRules(card.name, user.nickname, aff.STAGE_NAMES[st.affinity.stage]).map((text, i) => ({ text, score: 1e9 - i - 1 })) : []),
...card.lines.map((text, i) => ({ text, score: card.lines.length - i })),
]
return assemble({
card: cardItems,
memory,
mood: [{ text: `${m.label} ${m.face};${affinityLine(st.affinity)}`, score: 1 }],
care: (extra.care ?? []).map((text, i) => ({ text, score: 100 - i })),
})
},
async mood() {
const st = await loadState(await loadCard())
return { pad: st.mood, ...mood.moodLabel(st.mood) }
},
async affinity() {
return (await loadState(await loadCard())).affinity
},
async setTone(on: boolean) {
await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on }))
},
/** `/cockpit tone on|off` */
async command(line: string): Promise<string | null> {
const m = /^tone\s+(on|off)$/.exec(line.trim())
if (!m) return null
await this.setTone(m[1] === 'on')
return m[1] === 'on' ? '語氣已開啟' : '語氣已關閉'
},
get affinityMoved() {
return affinityMoved
},
}
}