176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
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
|
||
},
|
||
}
|
||
}
|