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

- Haiku 一次分類(難度、模型、思考程度、語氣),角色立刻回應
- turn.step 覆寫模型與思考程度,額度低於門檻自動降一級,支援手動鎖定
- 主模型回覆改寫為角色口吻,程式碼/路徑/指令/數字/URL 原樣保留,失敗回原文並另存原文 log
- 好感度規則、五階段、不跨階段掉落、個性倍率、狀態列愛心、/jsc-anima:anima-affinity
- 年齡與關係規則(未成年友情、成年移轉、戀愛式依賴需明確開啟)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 06:16:22 +00:00
co-authored by Claude Sonnet 5.5
parent c890059a50
commit 5bec18b44b
9 changed files with 583 additions and 5 deletions
+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 }
}