Compare commits
2
Commits
f02232a373
...
770928aef6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
770928aef6 | ||
|
|
d9e50d196b |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 可在狀態列看到每次分類的耗時。
|
||||
|
||||
@@ -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<Complete>[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()
|
||||
})
|
||||
})
|
||||
@@ -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<FunctionTag | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CharacterCode } from '../characters'
|
||||
import type { IndexEntry } from '../retrieval'
|
||||
|
||||
// 各角色的對白索引。W5、W6 由建置流水線產生並填入;目前為空,空索引時 hook 不呼叫模型。
|
||||
export const INDEXES: Readonly<Record<CharacterCode, readonly IndexEntry[]>> = {
|
||||
'sao-asuna': [],
|
||||
'sao-suguha': [],
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string, string>, complete: Complete, calls = { n: 0 }) => ({
|
||||
config: resolveConfig(opts),
|
||||
indexes,
|
||||
complete: async (r: Parameters<Complete>[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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<CharacterCode, readonly IndexEntry[]>>
|
||||
complete: Complete
|
||||
append: (text: string) => Promise<unknown>
|
||||
now: () => number
|
||||
turn: number
|
||||
}
|
||||
|
||||
// 回傳 null 表示這一輪不注入(沒選角色、強度 0、索引為空、分類失敗或沒有同標籤的對白)。
|
||||
// 索引為空時不呼叫模型,省下一次無用的分類。
|
||||
export async function buildInjection(
|
||||
{ config, indexes, complete, now, turn }: Omit<InjectDeps, 'append'>,
|
||||
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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]!)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user