Files
jsc-marketplace/plugins/cockpit/companion/core.ts
T

176 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
},
}
}