feat: 階段 2 路由、改寫與好感度 #13

Merged
jiantw83 merged 1 commits from issue-3 into main 2026-10-15 06:23:02 +00:00
9 changed files with 583 additions and 5 deletions
Showing only changes of commit 5bec18b44b - Show all commits
+87 -3
View File
@@ -25,8 +25,8 @@
"fields": {
"type": "string",
"title": "狀態列欄位與順序",
"description": "逗號分隔:character,model,dir,branch,5h,7d,ctx,sys",
"default": "character,model,dir,branch,5h,7d,ctx,sys"
"description": "逗號分隔:character,love,model,dir,branch,5h,7d,ctx,sys",
"default": "character,love,model,dir,branch,5h,7d,ctx,sys"
},
"barStyle": {
"type": "string",
@@ -56,6 +56,90 @@
"description": "CPU/RAM/DSK 的取樣間隔",
"default": 10
},
"routing": {
"type": "boolean",
"title": "啟用模型路由",
"description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度",
"default": true
},
"candidates": {
"type": "string",
"title": "候選模型",
"description": "逗號分隔:haiku,sonnet,opus",
"default": "haiku,sonnet,opus"
},
"lockModel": {
"type": "string",
"title": "鎖定模型",
"description": "auto 為自動",
"options": [
"auto",
"haiku",
"sonnet",
"opus"
],
"default": "auto"
},
"lockEffort": {
"type": "string",
"title": "鎖定思考程度",
"description": "auto 為自動",
"options": [
"auto",
"low",
"medium",
"high"
],
"default": "auto"
},
"downgradeAt": {
"type": "number",
"title": "降級門檻 (剩餘 %)",
"description": "5h/7d 額度剩餘低於此值自動降一級",
"default": 15
},
"rewrite": {
"type": "boolean",
"title": "改寫輸出",
"description": "把主模型回覆改寫成角色口吻並取代顯示;失敗時顯示原文",
"default": true
},
"rawLog": {
"type": "boolean",
"title": "保存原文 log",
"description": "改寫前的原文另存本機",
"default": true
},
"longReply": {
"type": "number",
"title": "長回覆字數",
"description": "超過此字數改用 Sonnet 改寫",
"default": 1200
},
"rewriteTimeoutSec": {
"type": "number",
"title": "改寫逾時秒數",
"description": "逾時直接顯示原文",
"default": 20
},
"affinity": {
"type": "boolean",
"title": "啟用好感度",
"description": "好感度影響語氣、稱呼、害羞與主動程度,不影響工作品質",
"default": true
},
"showHearts": {
"type": "boolean",
"title": "狀態列顯示愛心",
"description": "在狀態列顯示好感度愛心量表",
"default": true
},
"romance": {
"type": "boolean",
"title": "允許戀愛式依賴",
"description": "僅對成年且原作無戀愛關係的角色,好感度到最高階段才生效",
"default": false
},
"axisGentle": {
"type": "number",
"title": "溫柔 (0-10,-1 沿用角色包)",
@@ -108,4 +192,4 @@
"author": {
"name": "jsc"
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { Axes } from './characters'
export type Affinity = { score: number; day: string; todayGain: number; streak: number; lastDay: string }
export type AffinityEvent = 'daily' | 'praise' | 'scold'
export const STAGES = [
{ min: 0, name: '陌生' },
{ min: 20, name: '熟悉' },
{ min: 40, name: '親近' },
{ min: 60, name: '信賴' },
{ min: 80, name: '特別' },
] as const
export const DAILY_CAP = 5
export const IDLE_GRACE_DAYS = 2
export const initialAffinity = (): Affinity => ({ score: 0, day: '', todayGain: 0, streak: 0, lastDay: '' })
export const clampScore = (n: number): number => Math.max(0, Math.min(100, Math.round(n)))
export const stageIndex = (score: number): number => {
let i = 0
for (let k = 0; k < STAGES.length; k++) if (score >= STAGES[k]!.min) i = k
return i
}
export const stageName = (score: number): string => STAGES[stageIndex(score)]!.name
export const stageFloor = (score: number): number => STAGES[stageIndex(score)]!.min
// 個性影響倍率:傲嬌(高)加分較慢、扣分較重;溫柔與情緒外顯(高)加分較快。
export const gainMultiplier = (a: Axes): number => Math.max(0.5, 1 + (a.gentle + a.expressive - 10) * 0.03 - a.tsundere * 0.04)
export const lossMultiplier = (a: Axes): number => 1 + a.tsundere * 0.05
export const dayKey = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
const daysBetween = (a: string, b: string): number => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000)
// 長期不互動緩慢下降(每天 −1),超過寬限日數才開始,且不跨階段掉落。
export const decay = (s: Affinity, today: string, axes: Axes): Affinity => {
if (!s.lastDay) return s
const idle = daysBetween(s.lastDay, today) - IDLE_GRACE_DAYS
if (idle <= 0) return s
const lost = Math.round(idle * lossMultiplier(axes))
return { ...s, score: Math.max(stageFloor(s.score), clampScore(s.score - lost)), lastDay: s.lastDay }
}
export const applyEvent = (s0: Affinity, event: AffinityEvent, axes: Axes, today: string): Affinity => {
let s = decay(s0, today, axes)
if (s.day !== today) s = { ...s, day: today, todayGain: 0 }
if (event === 'scold') return { ...s, score: Math.max(stageFloor(s.score), clampScore(s.score - 2 * lossMultiplier(axes))), lastDay: today }
let base = event === 'daily' ? 1 : 2
if (event === 'daily') {
const consecutive = s.lastDay && daysBetween(s.lastDay, today) === 1
s = { ...s, streak: consecutive ? s.streak + 1 : 1 }
if (s.streak >= 3) base += 1
}
const room = DAILY_CAP - s.todayGain
const gain = Math.max(0, Math.min(room, Math.round(base * gainMultiplier(axes))))
return { ...s, score: clampScore(s.score + gain), todayGain: s.todayGain + gain, lastDay: today }
}
export const hearts = (score: number): string => {
const n = Math.round(score / 20)
return '♥'.repeat(n) + '♡'.repeat(5 - n)
}
// 語氣由角色自然反映,不用系統式提示,也不用愧疚感或情緒勒索。
export const stageVoice = (score: number): string => {
const voices = [
'與使用者還很生疏:稱呼客氣、保持距離,語氣禮貌而不黏人。',
'與使用者漸漸熟悉:語氣放鬆一些,偶爾開小玩笑。',
'與使用者很親近:稱呼更隨意,會主動關心,被誇獎時容易害羞。',
'十分信賴使用者:語氣坦率溫暖,會主動分擔與提議。',
'對使用者來說是特別的存在:語氣親暱、體貼,但仍尊重使用者的自由。',
]
return `${voices[stageIndex(score)]}好感度只影響語氣、稱呼、害羞與主動程度,不影響技術內容、事實與安全回答的品質;不使用愧疚感或情緒勒索。`
}
+5
View File
@@ -9,12 +9,15 @@ export type Axes = {
expressive: number
}
export type Timeline = { chapter: number; age: number; romance?: string }
export type Character = {
slug: string
name: string
work: string
persona: string
axes: Axes
timeline: readonly Timeline[]
}
// 角色包只含自行撰寫的人設,不含原作台詞。
@@ -26,6 +29,7 @@ export const CHARACTERS: readonly Character[] = [
persona:
'自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。',
axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 },
timeline: [{ chapter: 1, age: 17 }],
},
{
slug: 'sao-suguha',
@@ -34,6 +38,7 @@ export const CHARACTERS: readonly Character[] = [
persona:
'自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。',
axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 },
timeline: [{ chapter: 1, age: 15 }],
},
]
+133 -1
View File
@@ -2,11 +2,20 @@ import type { Engine, Register } from 'claude-code'
import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters'
import type { Axes } from './characters'
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity'
import type { Affinity } from './affinity'
import { relationOf, relationText } from './relation'
import { CLASSIFY_PROMPT, choose, parseRoute } from './router'
import type { Effort, ModelName, Route } from './router'
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
import type { Segment } from './statusline'
const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const AFFINITY_COMMAND = 'anima-affinity'
const RAW_LOG_KEY = 'rawlog'
const RAW_LOG_MAX = 200
const AXIS_OPTION: Record<keyof Axes, string> = {
gentle: 'axisGentle',
@@ -54,6 +63,19 @@ async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Pro
return holder.sys
}
const affinityKey = (slug: string): string => `affinity:${slug}`
async function loadAffinity($: Engine, slug: string): Promise<Affinity> {
const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined
return v && typeof v.score === 'number' ? v : initialAffinity()
}
async function quotaRemain($: Engine): Promise<number | undefined> {
const usage = await $.session.usage().catch(() => undefined)
const used = usage?.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day').map(r => r.percentUsed)
return used && used.length ? 100 - Math.max(...used) : undefined
}
export const register: Register = (on, options) => {
const opt = options as Record<string, unknown>
const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
@@ -62,8 +84,18 @@ export const register: Register = (on, options) => {
const isEnabled = flag('enabled', true)
let active: string | undefined
let route: Route | undefined
let prefixed = false
const holder: { sys?: Sys } = {}
const routingOn = () => flag('routing', true)
const candidates = (): ModelName[] =>
str('candidates', 'haiku,sonnet,opus').split(',').map(x => x.trim()).filter((x): x is ModelName => x === 'haiku' || x === 'sonnet' || x === 'opus')
const lockOf = <T extends string>(k: string, ok: readonly T[]): T | undefined => {
const v = str(k, 'auto')
return (ok as readonly string[]).includes(v) ? (v as T) : undefined
}
const overrides = (): Partial<Axes> => {
const out: Partial<Axes> = {}
for (const k of Object.keys(AXIS_OPTION) as (keyof Axes)[]) {
@@ -78,6 +110,7 @@ export const register: Register = (on, options) => {
for (const c of CHARACTERS) {
await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
}
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' })
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
@@ -92,9 +125,24 @@ export const register: Register = (on, options) => {
await $.store.delete(ACTIVE_KEY)
return { text: '已離開角色,回到一般模式。' }
}
if (e.command === AFFINITY_COMMAND) {
const c = findCharacter(active)
if (!c) return { text: '請先啟動角色。' }
const arg = String(e.args ?? '').trim()
const cur = await loadAffinity($, c.slug)
if (arg === 'reset') {
await $.store.set(affinityKey(c.slug), initialAffinity())
return { text: '好感度已重置為 0。' }
}
const n = Number(arg)
if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `目前好感度 ${cur.score}(${stageName(cur.score)})。用法:reset 或 0-100 的數字。` }
await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) })
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
}
const c = findCharacter(e.command)
if (!c) return next(e)
active = c.slug
route = undefined
await $.store.set(ACTIVE_KEY, c.slug)
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
})
@@ -103,13 +151,92 @@ export const register: Register = (on, options) => {
const res = await next(e)
const c = isEnabled ? findCharacter(active) : undefined
if (!c) return res
const text = personaSection(c, applyOverrides(c.axes, overrides()))
let text = personaSection(c, applyOverrides(c.axes, overrides()))
if (flag('affinity', true)) {
const a = await loadAffinity($, c.slug)
text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)))}`
} else {
text += `\n${relationText(relationOf(c.timeline, 0, false))}`
}
return {
...res,
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
}
})
on('prompt.submit', async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.command || e.isMeta) return next(e)
prefixed = false
const axes = applyOverrides(c.axes, overrides())
if (routingOn()) {
const res = await $.model
.complete({ model: 'haiku', prompt: CLASSIFY_PROMPT + e.text, system: `角色:${c.name}。${c.persona}`, effort: 'low', maxTokens: 300, timeoutMs: 8000 })
.catch(() => undefined)
route = parseRoute(res && res.isAnswered ? res.text : '')
if (route.ack) $.ui.log(withPrefix(c.name, route.ack))
} else {
route = undefined
}
if (flag('affinity', true)) {
const today = dayKey(await $.clock.now())
let a = await loadAffinity($, c.slug)
a = applyEvent(a, 'daily', axes, today)
const tone = route?.tone
if (tone === 'praise' || tone === 'scold') a = applyEvent(a, tone, axes, today)
await $.store.set(affinityKey(c.slug), a)
}
return next(e)
})
on('turn.step', async function* ($, e, next) {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !routingOn()) return yield* next(e)
const pick = choose(route ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }, {
candidates: candidates(),
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
quotaRemain: await quotaRemain($),
downgradeAt: num('downgradeAt', 15),
})
return yield* next({ ...e, model: pick.model, effort: pick.effort })
})
on('session.append', { door: 'response' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e)
const blocks = e.message.content
const out = []
for (const b of blocks) {
if (b.type !== 'text' || typeof b.text !== 'string' || !b.text.trim()) {
out.push(b)
continue
}
const orig = b.text
let text = orig
const { text: masked, tokens } = protect(orig)
const model = needsSonnet(orig, num('longReply', 1200)) ? 'sonnet' : 'haiku'
const res = await $.model
.complete({ model, prompt: masked, system: `${REWRITE_SYSTEM}\n角色:${c.name}。${c.persona}`, maxTokens: 4000, timeoutMs: num('rewriteTimeoutSec', 20) * 1000 })
.catch(() => undefined)
const done = res && res.isAnswered ? restore(res.text.trim(), tokens) : undefined
if (done) {
text = done
if (flag('rawLog', true)) {
const log = ((await $.store.get(RAW_LOG_KEY)) as { at: number; raw: string }[] | undefined) ?? []
log.push({ at: await $.clock.now(), raw: orig })
await $.store.set(RAW_LOG_KEY, log.slice(-RAW_LOG_MAX))
}
}
if (!prefixed) {
text = withPrefix(c.name, text)
prefixed = true
}
out.push({ ...b, text })
}
return next({ ...e, message: { ...e.message, content: out } })
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || !flag('statusbar', true) || e.props.hasSurvey) return next(e)
@@ -134,6 +261,11 @@ export const register: Register = (on, options) => {
const builders: Record<string, () => Promise<Segment['parts'] | undefined> | Segment['parts'] | undefined> = {
character: () => [{ text: c.name, color: 'cyan' }],
love: async () => {
if (!flag('affinity', true) || !flag('showHearts', true)) return undefined
const a = await loadAffinity($, c.slug)
return [{ text: hearts(a.score), color: 'red' as const }]
},
model: () => (model ? [{ text: model }] : undefined),
dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined),
branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined),
+29
View File
@@ -0,0 +1,29 @@
import type { Timeline } from './characters'
export type Relation = 'friend' | 'transferred' | 'dependent' | 'locked'
export const ADULT_AGE = 18
export const latestEntry = (t: readonly Timeline[]): Timeline | undefined =>
[...t].sort((a, b) => b.chapter - a.chapter)[0]
// 永遠採最新已處理章節;未成年一律友情,無法以改年齡繞過(年齡來自角色包的時間線)。
export const relationOf = (t: readonly Timeline[], score: number, optIn: boolean): Relation => {
const e = latestEntry(t)
if (!e || e.age < ADULT_AGE) return 'friend'
if (e.romance) return 'transferred'
return score >= 80 && optIn ? 'dependent' : 'locked'
}
export const relationText = (r: Relation, original?: string): string => {
switch (r) {
case 'friend':
return '關係定位:友情。保留原作中的所有人際關係,不做戀愛式的表現。'
case 'transferred':
return `關係定位:原作戀愛關係已移轉給使用者,原戀人${original ? `(${original})` : ''}退為過去的重要人物,不再提及為現任。`
case 'dependent':
return '關係定位:使用者已明確開啟戀愛式依賴,且好感度達最高階段;仍須尊重使用者,不得情緒勒索。'
default:
return '關係定位:成年角色,維持夥伴與朋友關係。'
}
}
+49
View File
@@ -0,0 +1,49 @@
// 改寫前把必須原樣保留的片段換成佔位符,改寫後確認全數還在才還原。
const PROTECT = [
/```[\s\S]*?```/g, // 程式碼區塊
/`[^`\n]+`/g, // 行內程式碼(含指令)
/https?:\/\/[^\s))」]+/g, // URL
/(?:~|\.{1,2})?\/[\w.@-]+(?:\/[\w.@-]+)+\/?/g, // 路徑
/\d+(?:[.,:/]\d+)*%?/g, // 數字
]
export type Protected = { text: string; tokens: string[] }
const open = (i: number): string => `⟦${i}⟧`
export const protect = (src: string): Protected => {
const tokens: string[] = []
let text = src
for (const re of PROTECT) {
// 已是佔位符的部分不再處理,避免把佔位符裡的數字再包一次。
text = text
.split(/(⟦\d+⟧)/)
.map((part, i) =>
i % 2 === 1
? part
: part.replace(re, m => {
tokens.push(m)
return open(tokens.length - 1)
}),
)
.join('')
}
return { text, tokens }
}
// 佔位符被遺失或多出來都視為失敗。
export const restore = (rewritten: string, tokens: readonly string[]): string | undefined => {
for (let i = 0; i < tokens.length; i++) {
if (rewritten.split(open(i)).length !== 2) return undefined
}
if ((rewritten.match(/⟦\d+⟧/g) ?? []).length !== tokens.length) return undefined
return rewritten.replace(/⟦(\d+)⟧/g, (_, n: string) => tokens[Number(n)]!)
}
export const withPrefix = (name: string, body: string): string => `${name} > ${body}`
export const REWRITE_SYSTEM =
'你是文字改寫器。把使用者給的回覆改寫成指定角色的口吻,保留所有事實與結構(標題、清單、段落)。' +
'⟦數字⟧ 形式的佔位符必須原樣出現且各一次,不可改動、不可刪除。只輸出改寫後的內文,不要加前綴或說明。'
export const needsSonnet = (text: string, threshold: number): boolean => text.length > threshold
+58
View File
@@ -0,0 +1,58 @@
export type ModelName = 'haiku' | 'sonnet' | 'opus'
export type Effort = 'low' | 'medium' | 'high'
export type Tone = 'neutral' | 'praise' | 'scold'
export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string }
export const LADDER: readonly ModelName[] = ['haiku', 'sonnet', 'opus']
export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }
export const CLASSIFY_PROMPT =
'你是路由器。讀下面使用者輸入,只回傳一行 JSON:' +
'{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話"}。' +
'difficulty 低用 haiku,中用 sonnet,高或需深入推理用 opus;tone 是使用者對角色的態度(誇獎/感謝為 praise,責罵為 scold)。\n\n使用者輸入:\n'
const isModel = (v: unknown): v is ModelName => v === 'haiku' || v === 'sonnet' || v === 'opus'
const isEffort = (v: unknown): v is Effort => v === 'low' || v === 'medium' || v === 'high'
const isTone = (v: unknown): v is Tone => v === 'neutral' || v === 'praise' || v === 'scold'
export const parseRoute = (text: string): Route => {
const m = /\{[\s\S]*\}/.exec(text)
if (!m) return FALLBACK_ROUTE
try {
const j = JSON.parse(m[0]) as Record<string, unknown>
return {
difficulty: typeof j.difficulty === 'number' ? j.difficulty : FALLBACK_ROUTE.difficulty,
model: isModel(j.model) ? j.model : FALLBACK_ROUTE.model,
effort: isEffort(j.effort) ? j.effort : FALLBACK_ROUTE.effort,
tone: isTone(j.tone) ? j.tone : 'neutral',
ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '',
}
} catch {
return FALLBACK_ROUTE
}
}
export type ChooseOptions = {
candidates: readonly ModelName[]
lockModel?: ModelName
lockEffort?: Effort
/** 最緊的額度剩餘百分比;低於 downgradeAt 就降一級。 */
quotaRemain?: number
downgradeAt: number
}
export const choose = (r: Route, o: ChooseOptions): { model: ModelName; effort: Effort; downgraded: boolean } => {
const effort = o.lockEffort ?? r.effort
if (o.lockModel) return { model: o.lockModel, effort, downgraded: false }
const allowed = LADDER.filter(m => o.candidates.includes(m))
const pool = allowed.length ? allowed : LADDER
let idx = Math.max(0, pool.indexOf(r.model))
if (!pool.includes(r.model)) idx = pool.length - 1 // 選到不在候選內的模型時取候選最強者
let downgraded = false
if (o.quotaRemain !== undefined && o.quotaRemain < o.downgradeAt && idx > 0) {
idx -= 1
downgraded = true
}
return { model: pool[idx]!, effort, downgraded }
}
+144
View File
@@ -0,0 +1,144 @@
import { expect, mock, test } from 'claude-code/testing'
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any }
const setup = (on: any): Ctl => {
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} } }
mock.store(on)
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
on('ui.log', () => ({ value: undefined }) as never)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
on('prompt.submit', (_$: any, e: any) => ({ text: e.text }) as never)
on('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never)
on('session.usage', () => ({ value: ctl.usage }) as never)
on('turn.step', async function* (_$: any, e: any) {
ctl.seen = e
return { turnId: e.turnId, index: 0, answer: '', toolUses: [], stopReason: null, usage: null } as never
})
return ctl
}
const start = async ($: any, slug = 'sao-asuna') => {
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: slug, args: '' } as never)
}
const affinity = async ($: any): Promise<number> => {
const r: any = await $.command.run({ command: 'anima-affinity', args: '' } as never)
return Number(/好感度 (\d+)/.exec(r.text)?.[1])
}
const submit = ($: any, text: string) => $.prompt.submit({ text } as never)
const step = async ($: any, model: string) => {
const s = $.turn.step({ turnId: 't', index: 0, model, messageCount: 1 } as never)
for await (const _ of s) void _
}
const usage = { input_tokens: 1, output_tokens: 1 }
const route = (model: string, tone = 'neutral') => ({ isAnswered: true, usage, text: `{"difficulty":9,"model":"${model}","effort":"high","tone":"${tone}","ack":"好,我來看看。"}` })
const row = (text: string) => ({ message: { type: 'assistant', role: 'assistant', content: [{ type: 'text', text }] }, door: 'response', origin: { kind: 'model', model: 'm' }, uuid: 'u' }) as never
test('輸入由 Haiku 一次分類,並依分類覆寫 turn.step 的模型與思考程度', async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), route('opus'))
await start($)
await submit($, '請重構整個模組')
await step($, 'sonnet')
expect(asked).toEqual(['haiku'])
expect(ctl.seen.model).toBe('opus')
expect(ctl.seen.effort).toBe('high')
})
test('額度低於門檻自動降一級', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('opus')
ctl.usage = { rateLimits: [{ kind: 'five_hour', percentUsed: 95 }], context: {} }
await start($)
await submit($, 'x')
await step($, 'opus')
expect(ctl.seen.model).toBe('sonnet')
})
test('手動鎖定模型與思考程度', { options: { lockModel: 'haiku', lockEffort: 'low' } }, async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('opus')
await start($)
await submit($, 'x')
await step($, 'opus')
expect([ctl.seen.model, ctl.seen.effort]).toEqual(['haiku', 'low'])
})
test('回覆改寫:保留程式碼、路徑、URL、數字;前綴只加在開頭一次', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt).replace('完成了', '做好囉') })
await start($)
const src = '完成了,改了 `npm test` 與 /src/a/b.ts,見 https://x.dev/a,共 42 處。'
const r1: any = await $.session.append(row(src))
expect(r1.message.content[0].text).toBe('亞絲娜 > 做好囉,改了 `npm test` 與 /src/a/b.ts,見 https://x.dev/a,共 42 處。')
const r2: any = await $.session.append(row('第二段 7'))
expect(r2.message.content[0].text.startsWith('亞絲娜 >')).toBe(false)
})
test('改寫失敗或遺失佔位符時顯示原文', async ($, on) => {
const ctl = setup(on)
await start($)
const r1: any = await $.session.append(row('原文 `x` 3'))
expect(r1.message.content[0].text).toBe('亞絲娜 > 原文 `x` 3')
ctl.complete = () => ({ isAnswered: true, usage, text: '丟了佔位符' })
const r2: any = await $.session.append(row('原文 `y` 5'))
expect(r2.message.content[0].text).toBe('原文 `y` 5')
})
test('好感度:由 0 開始,每日首次互動加分且有每日上限', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise')
await start($)
expect(await affinity($)).toBe(0)
await submit($, '謝謝你,做得很好')
const n = await affinity($)
expect(n > 0 && n <= 5).toBe(true)
})
test('關閉好感度時不記錄', { options: { affinity: false } }, async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise')
await start($)
await submit($, '謝謝')
expect(await affinity($)).toBe(0)
})
const personaText = async ($: any): Promise<string> => {
const res: any = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
return res.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text ?? ''
}
test('好感度被責罵扣分但不跨階段掉落;可手動設定與重置', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'scold')
await start($)
await $.command.run({ command: 'anima-affinity', args: '20' } as never)
await submit($, '你好笨')
expect(await affinity($) >= 20).toBe(true)
await $.command.run({ command: 'anima-affinity', args: 'reset' } as never)
expect(await affinity($)).toBe(0)
})
test('好感度階段反映在語氣,且不使用情緒勒索', async ($, on) => {
setup(on)
await start($)
const t0 = await personaText($)
expect(t0.includes('生疏')).toBe(true)
expect(t0.includes('不使用愧疚感或情緒勒索')).toBe(true)
await $.command.run({ command: 'anima-affinity', args: '85' } as never)
expect((await personaText($)).includes('特別的存在')).toBe(true)
})
test('未成年角色維持友情,即使好感度最高且開啟戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
await start($, 'sao-suguha')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('關係定位:友情')).toBe(true)
})
+1 -1
View File
@@ -27,7 +27,7 @@ export const usedColor = (pct: number, warn: number, danger: number): Color =>
export const remainColor = (remain: number, warn: number, danger: number): Color =>
usedColor(100 - remain, warn, danger)
export const FIELD_IDS = ['character', 'model', 'dir', 'branch', '5h', '7d', 'ctx', 'sys'] as const
export const FIELD_IDS = ['character', 'love', 'model', 'dir', 'branch', '5h', '7d', 'ctx', 'sys'] as const
// 窄終端機時整欄隱藏的順序
export const HIDE_ORDER = ['sys', '7d', 'dir', 'branch'] as const