diff --git a/plugins/anime-companion/.claude-plugin/plugin.json b/plugins/anime-companion/.claude-plugin/plugin.json index 30486a6..d45c488 100644 --- a/plugins/anime-companion/.claude-plugin/plugin.json +++ b/plugins/anime-companion/.claude-plugin/plugin.json @@ -7,27 +7,47 @@ "author": { "name": "JSC" }, - "keywords": ["anime", "light-novel", "persona", "companion"], + "keywords": [ + "anime", + "light-novel", + "persona", + "companion" + ], "userConfig": { "defaultCharacter": { "type": "string", "title": "預設角色", "description": "新 session 自動套用的角色;none 表示不自動套用,需用角色 skill 啟動。", "default": "none", - "options": ["none", "sao-asuna", "sao-suguha"] + "options": [ + "none", + "sao-asuna", + "sao-suguha" + ] }, "intensity": { "type": "string", "title": "強度", "description": "0 不套角色;1 只換稱呼與句尾;2 前後各一個對話氣泡;3 再加動作旁白。", "default": "2", - "options": ["0", "1", "2", "3"] + "options": [ + "0", + "1", + "2", + "3" + ] }, "address": { "type": "string", "title": "稱呼", "description": "角色怎麼稱呼你。留空則依角色與使用者在原作中的關係(亞絲娜叫「桐人」、直葉叫「哥哥」)。", "default": "" + }, + "showTiming": { + "type": "boolean", + "title": "顯示分類耗時", + "description": "在狀態列顯示每則訊息的功能標籤分類耗時與結果,用來量測延遲。", + "default": false } } } diff --git a/plugins/anime-companion/README.md b/plugins/anime-companion/README.md index ac17815..1baffdf 100644 --- a/plugins/anime-companion/README.md +++ b/plugins/anime-companion/README.md @@ -28,6 +28,7 @@ claude plugin install anime-companion@jsc-plugins | --- | --- | --- | | `defaultCharacter` | `none` | 新 session 自動套用的角色:`none`、`sao-asuna`、`sao-suguha` | | `intensity` | `2` | 0 不套角色;1 只換稱呼與句尾;2 前後各一個對話氣泡;3 再加動作旁白 | +| `showTiming` | `false` | 在狀態列顯示每則訊息的功能標籤分類耗時與結果 | | `address` | 空 | 角色怎麼稱呼你;留空則依原作關係(亞絲娜「桐人」、直葉「哥哥」) | 缺失或無效的設定值一律退回預設。 @@ -38,3 +39,11 @@ claude plugin install anime-companion@jsc-plugins claude plugin validate plugins/anime-companion claude plugin test plugins/anime-companion ``` + +## 對白檢索 + +每則使用者訊息送出時,hook 用 `$.model.complete`(Haiku、低 effort、3 秒逾時)判斷「角色接下來最自然的回應」屬於哪個功能標籤,再從該角色的對白索引取同標籤的 3 句,以一則只增不改的訊息附在使用者訊息之前,當語氣參考。 + +- 沒選角色、強度 0、索引為空、分類失敗、該標籤沒有對白時,都不注入,也不影響對話。 +- 索引為空時不呼叫模型。索引由建置流水線產生(W5、W6),放在 `hooks/data/`;目前為空。 +- 開 `showTiming` 可在狀態列看到每次分類的耗時。 diff --git a/plugins/anime-companion/hooks/classify.test.ts b/plugins/anime-companion/hooks/classify.test.ts new file mode 100644 index 0000000..0b29272 --- /dev/null +++ b/plugins/anime-companion/hooks/classify.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { buildClassifyPrompt, classify, parseFunction, type Complete } from './classify' +import { FUNCTIONS } from './tags' + +describe('parseFunction', () => { + test('白名單內的代號', () => { + expect(parseFunction('comfort')).toBe('comfort') + expect(parseFunction(' Report_Fail\n')).toBe('report_fail') + expect(parseFunction('tease。')).toBe('tease') + }) + + test('白名單外或空回覆視為失敗', () => { + expect(parseFunction('flirt')).toBeNull() + expect(parseFunction('')).toBeNull() + expect(parseFunction('我覺得是安慰')).toBeNull() + }) + + test('只認開頭第一個詞', () => { + expect(parseFunction('comfort or tease')).toBe('comfort') + expect(parseFunction('他說 comfort')).toBeNull() + }) +}) + +describe('buildClassifyPrompt', () => { + test('列出所有功能代號(other 除外)並包住使用者的話', () => { + const p = buildClassifyPrompt('測試失敗了') + for (const key of Object.keys(FUNCTIONS).filter(k => k !== 'other')) expect(p).toContain(`${key}:`) + expect(p).toContain('<使用者的話>\n測試失敗了\n') + }) + + test('過長的使用者輸入截斷', () => { + expect(buildClassifyPrompt('字'.repeat(5000)).length).toBeLessThan(1500) + }) +}) + +describe('classify', () => { + const ok = (text: string): Complete => async () => ({ isAnswered: true, text }) + + test('成功回傳標籤,並用 haiku、低 effort、有逾時', async () => { + let seen: Parameters[0] | undefined + const r = await classify(async req => ((seen = req), { isAnswered: true, text: 'comfort' }), '好累') + expect(r).toBe('comfort') + expect(seen?.model).toBe('haiku') + expect(seen?.effort).toBe('low') + expect(seen?.timeoutMs).toBeGreaterThan(0) + }) + + test('未回答、空回覆、白名單外都回傳 null', async () => { + expect(await classify(async () => ({ isAnswered: false }), 'x')).toBeNull() + expect(await classify(ok(''), 'x')).toBeNull() + expect(await classify(ok('flirt'), 'x')).toBeNull() + }) + + test('呼叫丟出例外也回傳 null', async () => { + expect( + await classify(async () => { + throw new Error('blocked model') + }, 'x'), + ).toBeNull() + }) +}) diff --git a/plugins/anime-companion/hooks/classify.ts b/plugins/anime-companion/hooks/classify.ts new file mode 100644 index 0000000..1d10c24 --- /dev/null +++ b/plugins/anime-companion/hooks/classify.ts @@ -0,0 +1,51 @@ +import { FUNCTIONS, isFunctionTag, type FunctionTag } from './tags' + +export const CLASSIFY_TIMEOUT_MS = 3000 + +// 判斷的是「角色接下來最自然的回應」屬於哪種功能,不是使用者這句話本身的功能。 +export function buildClassifyPrompt(userText: string): string { + const choices = Object.entries(FUNCTIONS) + .filter(([key]) => key !== 'other') + .map(([key, label]) => `${key}:${label}`) + .join('\n') + return [ + '使用者正在請助理協助做事。判斷在他這句話之後,一位親切的角色最自然的第一句回應屬於下列哪一種功能。', + '只回答功能代號一個詞,不要解釋;都不適合就回答 other。', + '', + choices, + '', + '<使用者的話>', + userText.slice(0, 600), + '', + ].join('\n') +} + +// 只認回覆開頭的第一個詞,且必須在白名單內;其餘一律視為判斷失敗。 +export function parseFunction(reply: string): FunctionTag | null { + const word = reply.trim().toLowerCase().match(/^[a-z_]+/)?.[0] + return isFunctionTag(word) ? word : null +} + +export type Complete = (request: { + model: string + prompt: string + maxTokens: number + effort: 'low' + timeoutMs: number +}) => Promise<{ isAnswered: boolean; text?: string }> + +// 任何失敗(逾時、API 錯誤、空回覆、白名單外)都回傳 null,由呼叫端退回不注入。 +export async function classify(complete: Complete, userText: string): Promise { + try { + const r = await complete({ + model: 'haiku', + prompt: buildClassifyPrompt(userText), + maxTokens: 16, + effort: 'low', + timeoutMs: CLASSIFY_TIMEOUT_MS, + }) + return r.isAnswered && r.text ? parseFunction(r.text) : null + } catch { + return null + } +} diff --git a/plugins/anime-companion/hooks/data/index.ts b/plugins/anime-companion/hooks/data/index.ts new file mode 100644 index 0000000..4fd031d --- /dev/null +++ b/plugins/anime-companion/hooks/data/index.ts @@ -0,0 +1,8 @@ +import type { CharacterCode } from '../characters' +import type { IndexEntry } from '../retrieval' + +// 各角色的對白索引。W5、W6 由建置流水線產生並填入;目前為空,空索引時 hook 不呼叫模型。 +export const INDEXES: Readonly> = { + 'sao-asuna': [], + 'sao-suguha': [], +} diff --git a/plugins/anime-companion/hooks/examples.ts b/plugins/anime-companion/hooks/examples.ts new file mode 100644 index 0000000..8a8eb9c --- /dev/null +++ b/plugins/anime-companion/hooks/examples.ts @@ -0,0 +1,13 @@ +import type { IndexEntry } from './retrieval' + +export const MAX_LINE = 80 + +// 注入對話的語氣參考。明說只學語氣,不照抄,也不要提到這份參考。 +export function formatExamples(name: string, entries: readonly IndexEntry[]): string | null { + if (entries.length === 0) return null + const lines = entries.map(e => `- 「${e.text.length > MAX_LINE ? e.text.slice(0, MAX_LINE) + '…' : e.text}」`) + return [ + `以下是「${name}」在類似情境說過的話,只用來掌握她的語氣、用字與節奏。不要照抄,也不要向使用者提到這份參考。`, + ...lines, + ].join('\n') +} diff --git a/plugins/anime-companion/hooks/inject.test.ts b/plugins/anime-companion/hooks/inject.test.ts new file mode 100644 index 0000000..c9bb990 --- /dev/null +++ b/plugins/anime-companion/hooks/inject.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'claude-code/testing' + +import type { Complete } from './classify' +import { resolveConfig } from './config' +import { buildInjection } from './inject' +import type { IndexEntry } from './retrieval' + +const entry = (id: string, fn: IndexEntry['function']): IndexEntry => ({ id, function: fn, emotion: 'neutral', mode: null, text: `句${id}` }) +const indexes = { + 'sao-asuna': [entry('a', 'comfort'), entry('b', 'comfort')], + 'sao-suguha': [] as IndexEntry[], +} +const answer = (text: string): Complete => async () => ({ isAnswered: true, text }) +const deps = (opts: Record, complete: Complete, calls = { n: 0 }) => ({ + config: resolveConfig(opts), + indexes, + complete: async (r: Parameters[0]) => (calls.n++, complete(r)), + now: () => 0, + turn: 0, +}) + +describe('buildInjection', () => { + const on = { defaultCharacter: 'sao-asuna', intensity: '2' } + + test('分類成功時回傳同標籤的對白', async () => { + const r = await buildInjection(deps(on, answer('comfort')), '測試失敗了') + expect(r?.fn).toBe('comfort') + expect(r?.text).toContain('句a') + }) + + test('沒選角色或強度 0 不呼叫模型', async () => { + const calls = { n: 0 } + expect(await buildInjection(deps({ intensity: '2' }, answer('comfort'), calls), 'x')).toBeNull() + expect(await buildInjection(deps({ ...on, intensity: '0' }, answer('comfort'), calls), 'x')).toBeNull() + expect(calls.n).toBe(0) + }) + + test('索引為空不呼叫模型', async () => { + const calls = { n: 0 } + expect(await buildInjection(deps({ defaultCharacter: 'sao-suguha', intensity: '2' }, answer('comfort'), calls), 'x')).toBeNull() + expect(calls.n).toBe(0) + }) + + test('分類失敗退回不注入,但仍回報耗時', async () => { + const r = await buildInjection(deps(on, async () => ({ isAnswered: false })), 'x') + expect(r).toEqual({ text: null, fn: null, ms: 0 }) + }) + + test('該標籤沒有對白時不注入', async () => { + const r = await buildInjection(deps(on, answer('greet')), 'x') + expect(r?.fn).toBe('greet') + expect(r?.text).toBeNull() + }) +}) diff --git a/plugins/anime-companion/hooks/inject.ts b/plugins/anime-companion/hooks/inject.ts new file mode 100644 index 0000000..13d7c82 --- /dev/null +++ b/plugins/anime-companion/hooks/inject.ts @@ -0,0 +1,31 @@ +import { CHARACTERS, type CharacterCode } from './characters' +import { classify, type Complete } from './classify' +import type { Config } from './config' +import { formatExamples } from './examples' +import { retrieve, type IndexEntry } from './retrieval' + +export type InjectDeps = { + config: Config + indexes: Readonly> + complete: Complete + append: (text: string) => Promise + now: () => number + turn: number +} + +// 回傳 null 表示這一輪不注入(沒選角色、強度 0、索引為空、分類失敗或沒有同標籤的對白)。 +// 索引為空時不呼叫模型,省下一次無用的分類。 +export async function buildInjection( + { config, indexes, complete, now, turn }: Omit, + userText: string, +): Promise<{ text: string | null; fn: string | null; ms: number } | null> { + const character = config.character + if (character === null || config.intensity === 0) return null + const index = indexes[character] + if (index.length === 0) return null + const started = now() + const fn = await classify(complete, userText) + const ms = now() - started + if (fn === null) return { text: null, fn: null, ms } + return { text: formatExamples(CHARACTERS[character].name, retrieve(index, fn, { turn })), fn, ms } +} diff --git a/plugins/anime-companion/hooks/register.tsx b/plugins/anime-companion/hooks/register.tsx index 3c135d3..7ea241c 100644 --- a/plugins/anime-companion/hooks/register.tsx +++ b/plugins/anime-companion/hooks/register.tsx @@ -1,15 +1,33 @@ import type { Register } from 'claude-code' import { resolveConfig } from './config' +import { INDEXES } from './data/index' +import { buildInjection } from './inject' import { buildRules, SECTION_ID } from './rules' -// 依強度與稱呼把規則段落加到系統提示最後;人格卡與檢索見 W5~W7。 +// 規則段落(W8)加到系統提示最後;每則使用者訊息再依功能標籤檢索角色的對白當語氣參考(W7)。 export const register: Register = (on, options) => { const config = resolveConfig(options) + const showTiming = options.showTiming === true + let turn = 0 + on('prompt.compose', async ($, e, next) => { const result = await next(e) const text = buildRules(config) if (text === null) return result return { sections: [...result.sections, { id: SECTION_ID, text, scope: 'session' }] } }) + + on('prompt.submit', async ($, e, next) => { + if (e.command || e.isMeta) return next(e) + const r = await buildInjection( + { config, indexes: INDEXES, complete: req => $.model.complete(req), now: Date.now, turn: turn++ }, + e.text, + ) + if (r === null) return next(e) + if (showTiming) $.ui.status(`anime-companion 分類 ${r.ms}ms → ${r.fn ?? '失敗'}`) + // 附加在使用者訊息之前,只增不改,不影響對話的快取。 + if (r.text !== null) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: r.text }] } }) + return next(e) + }) } diff --git a/plugins/anime-companion/hooks/retrieval.test.ts b/plugins/anime-companion/hooks/retrieval.test.ts new file mode 100644 index 0000000..7abdafd --- /dev/null +++ b/plugins/anime-companion/hooks/retrieval.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { formatExamples } from './examples' +import { retrieve, type IndexEntry } from './retrieval' + +const e = (id: string, fn: IndexEntry['function'], mode: string | null = null): IndexEntry => ({ + id, + function: fn, + emotion: 'neutral', + mode, + text: `句${id}`, +}) +const INDEX = [e('c', 'comfort'), e('a', 'comfort'), e('b', 'comfort'), e('d', 'tease'), e('x', 'comfort', 'leafa')] + +describe('retrieve', () => { + test('只取同功能標籤,依 id 排序', () => { + expect(retrieve(INDEX, 'comfort', { k: 10 }).map(r => r.id)).toEqual(['a', 'b', 'c', 'x']) + }) + + test('預設取 3 句', () => { + expect(retrieve(INDEX, 'comfort')).toHaveLength(3) + }) + + test('沒有同標籤的對白回傳空陣列', () => { + expect(retrieve(INDEX, 'greet')).toEqual([]) + }) + + test('依模式過濾', () => { + expect(retrieve(INDEX, 'comfort', { mode: 'leafa' }).map(r => r.id)).toEqual(['x']) + }) + + test('同一輪結果固定,不同輪輪流取', () => { + const ids = (turn: number) => retrieve(INDEX, 'comfort', { k: 2, turn }).map(r => r.id) + expect(ids(0)).toEqual(ids(0)) + expect(ids(0)).toEqual(['a', 'b']) + expect(ids(1)).toEqual(['b', 'c']) + expect(ids(3)).toEqual(['x', 'a']) + }) + + test('k 大於池子時不重複', () => { + expect(new Set(retrieve(INDEX, 'tease', { k: 5 }).map(r => r.id)).size).toBe(1) + }) +}) + +describe('formatExamples', () => { + test('沒有對白不注入', () => { + expect(formatExamples('亞絲娜', [])).toBeNull() + }) + + test('列出對白並說明只學語氣', () => { + const text = formatExamples('亞絲娜', [e('a', 'comfort')])! + expect(text).toContain('句a') + expect(text).toContain('不要照抄') + expect(text).toContain('亞絲娜') + }) + + test('過長的句子截斷', () => { + const long = { ...e('a', 'comfort'), text: 'あ'.repeat(200) } + expect(formatExamples('亞絲娜', [long])!.length).toBeLessThan(300) + }) +}) diff --git a/plugins/anime-companion/hooks/retrieval.ts b/plugins/anime-companion/hooks/retrieval.ts new file mode 100644 index 0000000..9ba3724 --- /dev/null +++ b/plugins/anime-companion/hooks/retrieval.ts @@ -0,0 +1,28 @@ +import type { FunctionTag } from './tags' + +// 對白索引的一筆;由建置流水線產生,見 data/。text 不超過 80 字。 +export type IndexEntry = { + id: string + function: FunctionTag + emotion: string + mode: string | null + text: string +} + +export const DEFAULT_K = 3 + +// 取同功能標籤的對白 k 句。依 id 排序後從 turn 起輪流取,讓每一輪撈到的句子不同, +// 同一輪結果固定(便於測試)。沒有同標籤的對白就回傳空陣列,不退而求其次。 +export function retrieve( + index: readonly IndexEntry[], + fn: FunctionTag, + { k = DEFAULT_K, mode = null, turn = 0 }: { k?: number; mode?: string | null; turn?: number } = {}, +): IndexEntry[] { + const pool = index + .filter(e => e.function === fn && (mode === null || e.mode === mode)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + if (pool.length === 0) return [] + const n = Math.min(k, pool.length) + const start = ((turn % pool.length) + pool.length) % pool.length + return Array.from({ length: n }, (_, i) => pool[(start + i) % pool.length]!) +} diff --git a/plugins/anime-companion/hooks/tags.ts b/plugins/anime-companion/hooks/tags.ts new file mode 100644 index 0000000..764e0b3 --- /dev/null +++ b/plugins/anime-companion/hooks/tags.ts @@ -0,0 +1,26 @@ +// 溝通功能標籤白名單,與建置流水線的 tools/corpus/tags.json 的 functions 一致。 +export const FUNCTIONS = { + greet: '打招呼、道別', + refuse: '拒絕、否定請求', + comfort: '安慰、安撫', + tease: '吐槽、調侃、挖苦', + celebrate: '慶祝、開心、稱讚', + report_fail: '報告失敗、壞消息、承認錯誤', + confirm_request: '請求確認、確認對方意願或事實', + shy_cover: '害羞掩飾、嘴硬、岔開話題', + explain: '說明、陳述事實或計畫', + ask: '發問', + agree: '同意、答應', + apologize: '道歉', + thank: '道謝', + encourage: '鼓勵、打氣', + worry: '擔心、關心', + urge: '催促、命令、警告', + other: '以上皆非', +} as const + +export type FunctionTag = keyof typeof FUNCTIONS + +export function isFunctionTag(value: unknown): value is FunctionTag { + return typeof value === 'string' && Object.hasOwn(FUNCTIONS, value) +}