feat: 階段 5 工具三 wiki 知識庫 #10
@@ -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 沿用角色包)",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# jsc-anima
|
||||
|
||||
目前版本:0.1.0
|
||||
|
||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
|
||||
|
||||
## 知識庫(wiki)
|
||||
@@ -26,6 +28,10 @@ claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima
|
||||
claude plugin install jsc-anima
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。
|
||||
|
||||
## 更新
|
||||
|
||||
```
|
||||
@@ -38,6 +44,10 @@ claude plugin update jsc-anima
|
||||
claude plugin uninstall jsc-anima
|
||||
```
|
||||
|
||||
## 使用說明
|
||||
|
||||
技能列表與範例見 [skills/jsc-anima/README.md](skills/jsc-anima/README.md)。
|
||||
|
||||
## 授權
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -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)]}好感度只影響語氣、稱呼、害羞與主動程度,不影響技術內容、事實與安全回答的品質;不使用愧疚感或情緒勒索。`
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import type { Extracted } from './extract'
|
||||
|
||||
const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] =>
|
||||
({
|
||||
name: '小葵', nameEn: 'Aoi', slug: 'aoi',
|
||||
persona: { selfRef: '我', address: '你', tone, catchphrase: '加油', values: '誠實', taboos: '背叛' },
|
||||
axes: { gentle: 7, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 },
|
||||
anime: { moe: '認真', speech: '柔和', emotions: '會臉紅', kaomoji: '(^_^)', themeColor: '#88c' },
|
||||
state: { mood: '緊張', dialogueStyle: '客氣', monologue: '想變強', relationships: [{ with: '阿零', kind: 'friend', note: '同伴' }], age: 16, place: '學院' },
|
||||
...over,
|
||||
}) as never
|
||||
|
||||
const reply = (c: Extracted['characters'][number]) =>
|
||||
JSON.stringify({ work: { name: '測試物語', nameEn: 'Test Story', slug: 'ts', timeline: [{ time: '春', place: '學院' }], terms: [{ term: '魔核', definition: '力量來源' }] }, characters: [c] })
|
||||
|
||||
type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] }
|
||||
|
||||
const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => {
|
||||
const s: Setup = { files, cmds: [], prompts: [] }
|
||||
const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void
|
||||
mock.store(on)
|
||||
o('command.register', () => ({ value: { name: 'x' } }))
|
||||
o('session.start', () => ({ cwd: '/repo' }))
|
||||
o('session.cwd', (() => ({ value: '/repo' })) as never)
|
||||
o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never)
|
||||
o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never)
|
||||
o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never)
|
||||
o('model.complete', ((_$: unknown, e: { prompt: string }) => {
|
||||
s.prompts.push(e.prompt)
|
||||
return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } }
|
||||
}) as never)
|
||||
o('env.get', (() => ({ value: undefined })) as never)
|
||||
o('process.run', ((_$: unknown, e: { argv: string[] }) => {
|
||||
s.cmds.push(e.argv)
|
||||
const j = e.argv.join(' ')
|
||||
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.example/o/r.git' : j.startsWith('tea logins') ? 'admin https://gitea.example u' : j.startsWith('tea pulls') ? 'PR#1' : ''
|
||||
return { value: { exitCode: 0, stdout: out, stderr: '' } }
|
||||
}) as never)
|
||||
return s
|
||||
}
|
||||
|
||||
const run = ($: never, args: string) => ($ as any).command.run({ command: 'anima-extract', args }).then((r: { text: string }) => r.text)
|
||||
|
||||
test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR', async ($, on) => {
|
||||
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', '第一段\n\n第二段']]))
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
const t1 = await run($ as never, 'ch1.txt')
|
||||
expect(t1).toContain('ts')
|
||||
expect(t1).toContain('aoi')
|
||||
expect(t1).toContain('尚未建立')
|
||||
expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false)
|
||||
|
||||
const t2 = await run($ as never, 'confirm work=testy aoi=ao')
|
||||
expect(t2).toContain('變更摘要')
|
||||
expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false)
|
||||
|
||||
const t3 = await run($ as never, 'confirm')
|
||||
expect(t3).toContain('已建立 PR')
|
||||
const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!)
|
||||
expect(pack.timeline[0].chapter).toBe('ch1')
|
||||
expect(pack.timeline[0].age).toBe(16)
|
||||
expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核')
|
||||
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
|
||||
expect(pr).toContain('main')
|
||||
expect(pr).toContain('feat/extract-testy-ch1')
|
||||
expect(s.cmds.some(c => c[0] === 'git' && c[1] === 'commit' && c[3]!.startsWith('feat: '))).toBe(true)
|
||||
})
|
||||
|
||||
test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確認', async ($, on) => {
|
||||
const files = new Map([['/repo/ch1.txt', 'a'], ['/repo/ch2.txt', 'b']])
|
||||
const s = setup(on as never, [reply(char()), reply(char()), reply(char({ state: { mood: '開心', dialogueStyle: '', monologue: '', relationships: [], age: 17, place: '' } }, '傲嬌'))], files)
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
const write = async (f: string) => {
|
||||
await run($ as never, f)
|
||||
await run($ as never, 'confirm')
|
||||
return run($ as never, 'confirm')
|
||||
}
|
||||
await write('ch1.txt')
|
||||
await write('ch1.txt')
|
||||
expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1)
|
||||
|
||||
await run($ as never, 'ch2.txt')
|
||||
const summary = await run($ as never, 'confirm')
|
||||
expect(summary).toContain('追加')
|
||||
expect(summary).toContain('待確認')
|
||||
await run($ as never, 'confirm')
|
||||
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!)
|
||||
expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2'])
|
||||
expect(pack.persona.tone).toBe('溫柔')
|
||||
expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' })
|
||||
})
|
||||
|
||||
test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async ($, on) => {
|
||||
const bad = char({ axes: { gentle: 99, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 } })
|
||||
const s = setup(on as never, [reply(bad)], new Map([['/repo/ch1.txt', 'a']]))
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
await run($ as never, 'ch1.txt')
|
||||
expect(await run($ as never, 'confirm')).toContain('schema 驗證失敗')
|
||||
expect(s.cmds.some(c => c[0] === 'tea')).toBe(false)
|
||||
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
|
||||
expect(await run($ as never, 'cancel')).toContain('已取消')
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { Axes } from './characters'
|
||||
|
||||
export type Relationship = { with: string; kind: 'friend' | 'romantic' | 'family' | 'other'; note: string }
|
||||
|
||||
export type StateEntry = {
|
||||
chapter: string
|
||||
mood: string
|
||||
dialogueStyle: string
|
||||
monologue: string
|
||||
relationships: Relationship[]
|
||||
age: number | null
|
||||
place: string
|
||||
}
|
||||
|
||||
export type CharacterPack = {
|
||||
slug: string
|
||||
name: string
|
||||
work: string
|
||||
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
|
||||
axes: Axes
|
||||
anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string }
|
||||
baseChapter: string
|
||||
timeline: StateEntry[]
|
||||
conflicts: Conflict[]
|
||||
}
|
||||
|
||||
export type Conflict = { field: string; existing: string; proposed: string; chapter: string; status: '待確認' }
|
||||
|
||||
export type WorkPack = {
|
||||
slug: string
|
||||
name: string
|
||||
timeline: { chapter: string; time: string; place: string }[]
|
||||
terms: { term: string; definition: string; chapter: string }[]
|
||||
}
|
||||
|
||||
export type Extracted = {
|
||||
work: { name: string; nameEn: string; slug: string; timeline: { time: string; place: string }[]; terms: { term: string; definition: string }[] }
|
||||
characters: {
|
||||
name: string
|
||||
nameEn: string
|
||||
slug: string
|
||||
persona: CharacterPack['persona']
|
||||
axes: Axes
|
||||
anime: CharacterPack['anime']
|
||||
state: Omit<StateEntry, 'chapter'>
|
||||
}[]
|
||||
}
|
||||
|
||||
export const AXIS_KEYS: (keyof Axes)[] = ['gentle', 'tsundere', 'lively', 'formal', 'talkative', 'humor', 'proactive', 'expressive']
|
||||
const PERSONA_KEYS = ['selfRef', 'address', 'tone', 'catchphrase', 'values', 'taboos'] as const
|
||||
const ANIME_KEYS = ['moe', 'speech', 'emotions', 'kaomoji', 'themeColor'] as const
|
||||
const MAX_FIELD = 400
|
||||
export const CHUNK_SIZE = 4000
|
||||
|
||||
export const slugify = (s: string): string =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|
||||
export const chunkText = (text: string, size = CHUNK_SIZE): string[] => {
|
||||
const out: string[] = []
|
||||
let cur = ''
|
||||
for (const para of text.split(/\n\s*\n/)) {
|
||||
if (cur && cur.length + para.length + 2 > size) {
|
||||
out.push(cur)
|
||||
cur = ''
|
||||
}
|
||||
let p = para
|
||||
while (p.length > size) {
|
||||
out.push(p.slice(0, size))
|
||||
p = p.slice(size)
|
||||
}
|
||||
cur = cur ? `${cur}\n\n${p}` : p
|
||||
}
|
||||
if (cur.trim()) out.push(cur)
|
||||
return out
|
||||
}
|
||||
|
||||
export const extractPrompt = (chunk: string, index: number, total: number): string =>
|
||||
[
|
||||
`以下是輕小說章節的第 ${index + 1}/${total} 段。請萃取環境(時間、地點、用語)與人物(心情、對話風格、獨白、關係、年齡)。`,
|
||||
'規則:所有欄位用你自己的話撰寫並簡短概括,不得抄錄原作台詞;每個欄位不超過 200 字;axes 為 0-10 的整數。',
|
||||
'slug 與 workSlug 使用官方英文名或羅馬拼音的小寫英數與連字號。',
|
||||
'只輸出一個 JSON,不要其他文字,格式:',
|
||||
'{"work":{"name":"","nameEn":"","slug":"","timeline":[{"time":"","place":""}],"terms":[{"term":"","definition":""}]},',
|
||||
'"characters":[{"name":"","nameEn":"","slug":"","persona":{"selfRef":"","address":"","tone":"","catchphrase":"","values":"","taboos":""},',
|
||||
'"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},',
|
||||
'"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},',
|
||||
'"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}',
|
||||
'',
|
||||
'章節內容:',
|
||||
chunk,
|
||||
].join('\n')
|
||||
|
||||
export const parseExtracted = (text: string): Extracted | undefined => {
|
||||
const m = text.match(/\{[\s\S]*\}/)
|
||||
if (!m) return undefined
|
||||
try {
|
||||
const v = JSON.parse(m[0]) as Extracted
|
||||
return v && v.work && Array.isArray(v.characters) ? v : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 逐段結果合併:同一人物以英文名對應,後段的狀態蓋過前段(永遠採最新)。
|
||||
export const mergeChunks = (parts: Extracted[]): Extracted => {
|
||||
const first = parts[0]!
|
||||
const work = { ...first.work, timeline: [...first.work.timeline], terms: [...first.work.terms] }
|
||||
const chars = new Map<string, Extracted['characters'][number]>()
|
||||
for (const p of parts) {
|
||||
for (const t of p.work.terms ?? []) if (!work.terms.some(x => x.term === t.term)) work.terms.push(t)
|
||||
for (const t of p.work.timeline ?? []) if (!work.timeline.some(x => x.time === t.time && x.place === t.place)) work.timeline.push(t)
|
||||
for (const c of p.characters) {
|
||||
const key = slugify(c.nameEn || c.slug || c.name) || c.name
|
||||
chars.set(key, { ...(chars.get(key) ?? c), ...c, state: { ...(chars.get(key)?.state ?? c.state), ...c.state } })
|
||||
}
|
||||
}
|
||||
return { work, characters: [...chars.values()] }
|
||||
}
|
||||
|
||||
export const validateCharacter = (c: CharacterPack): string[] => {
|
||||
const errs: string[] = []
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.slug)) errs.push(`${c.name}:角色代號格式不合法`)
|
||||
if (!c.name) errs.push('缺少角色名稱')
|
||||
for (const k of PERSONA_KEYS) {
|
||||
const v = c.persona?.[k]
|
||||
if (typeof v !== 'string') errs.push(`${c.name}:persona.${k} 必須是字串`)
|
||||
else if (v.length > MAX_FIELD) errs.push(`${c.name}:persona.${k} 過長(疑似抄錄原文)`)
|
||||
}
|
||||
for (const k of ANIME_KEYS) {
|
||||
const v = c.anime?.[k]
|
||||
if (typeof v !== 'string') errs.push(`${c.name}:anime.${k} 必須是字串`)
|
||||
else if (v.length > MAX_FIELD) errs.push(`${c.name}:anime.${k} 過長(疑似抄錄原文)`)
|
||||
}
|
||||
for (const k of AXIS_KEYS) {
|
||||
const v = c.axes?.[k]
|
||||
if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`)
|
||||
}
|
||||
if (!Array.isArray(c.timeline) || c.timeline.length === 0) errs.push(`${c.name}:時間線不可為空`)
|
||||
for (const t of c.timeline ?? []) {
|
||||
if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`)
|
||||
if (t.age !== null && !(Number.isInteger(t.age) && t.age >= 0)) errs.push(`${c.name}:年齡必須是非負整數或 null`)
|
||||
for (const f of [t.mood, t.dialogueStyle, t.monologue])
|
||||
if (typeof f === 'string' && f.length > MAX_FIELD) errs.push(`${c.name}:時間線欄位過長(疑似抄錄原文)`)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
export type MergeResult<T> = { pack: T; action: '新增' | '更新'; detail: string; conflicts: Conflict[] }
|
||||
|
||||
export const mergeCharacter = (
|
||||
incoming: Extracted['characters'][number],
|
||||
workSlug: string,
|
||||
slug: string,
|
||||
chapter: string,
|
||||
existing: CharacterPack | undefined,
|
||||
): MergeResult<CharacterPack> => {
|
||||
const entry: StateEntry = {
|
||||
chapter,
|
||||
mood: incoming.state.mood ?? '',
|
||||
dialogueStyle: incoming.state.dialogueStyle ?? '',
|
||||
monologue: incoming.state.monologue ?? '',
|
||||
relationships: incoming.state.relationships ?? [],
|
||||
age: incoming.state.age ?? null,
|
||||
place: incoming.state.place ?? '',
|
||||
}
|
||||
if (!existing) {
|
||||
return {
|
||||
action: '新增',
|
||||
detail: `時間線 ${chapter}`,
|
||||
conflicts: [],
|
||||
pack: { slug, name: incoming.name, work: workSlug, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, baseChapter: chapter, timeline: [entry], conflicts: [] },
|
||||
}
|
||||
}
|
||||
const sameChapter = existing.timeline.some(t => t.chapter === chapter)
|
||||
const timeline = sameChapter
|
||||
? existing.timeline.map(t => (t.chapter === chapter ? entry : t))
|
||||
: [...existing.timeline, entry]
|
||||
// 重跑同一章節:該章節產生的基礎設定直接覆蓋;新章節若與既有基礎設定不同,標記待確認而不覆蓋。
|
||||
const overwriteBase = existing.baseChapter === chapter
|
||||
const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter)
|
||||
let base = existing
|
||||
if (overwriteBase) {
|
||||
base = { ...existing, name: incoming.name, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime }
|
||||
} else {
|
||||
const diffs: [string, string, string][] = []
|
||||
for (const k of PERSONA_KEYS) if (existing.persona[k] !== incoming.persona[k]) diffs.push([`persona.${k}`, existing.persona[k], incoming.persona[k]])
|
||||
for (const k of AXIS_KEYS) if (existing.axes[k] !== incoming.axes[k]) diffs.push([`axes.${k}`, String(existing.axes[k]), String(incoming.axes[k])])
|
||||
for (const [field, a, b] of diffs) conflicts.push({ field, existing: a, proposed: b, chapter, status: '待確認' })
|
||||
}
|
||||
return {
|
||||
action: '更新',
|
||||
detail: `時間線 ${sameChapter ? '覆蓋' : '追加'} ${chapter}`,
|
||||
conflicts: conflicts.filter(c => c.chapter === chapter),
|
||||
pack: { ...base, timeline, conflicts },
|
||||
}
|
||||
}
|
||||
|
||||
export const mergeWork = (
|
||||
incoming: Extracted['work'],
|
||||
slug: string,
|
||||
chapter: string,
|
||||
existing: WorkPack | undefined,
|
||||
): MergeResult<WorkPack> => {
|
||||
const timeline = [...(existing?.timeline ?? []).filter(t => t.chapter !== chapter), ...(incoming.timeline ?? []).map(t => ({ chapter, ...t }))]
|
||||
const terms = [...(existing?.terms ?? []).filter(t => t.chapter !== chapter), ...(incoming.terms ?? []).map(t => ({ chapter, ...t }))]
|
||||
return {
|
||||
action: existing ? '更新' : '新增',
|
||||
detail: `時間線 ${incoming.timeline?.length ?? 0} 筆、用語 ${incoming.terms?.length ?? 0} 筆(${chapter})`,
|
||||
conflicts: [],
|
||||
pack: { slug, name: incoming.name, timeline, terms },
|
||||
}
|
||||
}
|
||||
|
||||
export const parseRemote = (url: string): { host: string; repo: string } | undefined => {
|
||||
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/)
|
||||
return m ? { host: m[1]!, repo: m[2]! } : undefined
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Engine } from 'claude-code'
|
||||
|
||||
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract'
|
||||
import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
|
||||
|
||||
export const EXTRACT_COMMAND = 'anima-extract'
|
||||
const PENDING_KEY = 'extract:pending'
|
||||
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>,之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。`
|
||||
|
||||
type Plan = {
|
||||
files: { path: string; json: unknown }[]
|
||||
lines: string[]
|
||||
conflicts: Conflict[]
|
||||
workSlug: string
|
||||
workName: string
|
||||
}
|
||||
|
||||
type Pending = {
|
||||
phase: 'slug' | 'summary'
|
||||
chapter: string
|
||||
root: string
|
||||
extracted: Extracted
|
||||
workSlug: string
|
||||
slugs: Record<string, string>
|
||||
plan?: Plan
|
||||
}
|
||||
|
||||
const dirOf = (root: string, work: string) => `${root}/characters/${work}`
|
||||
|
||||
async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
|
||||
if (!(await $.fs.exists(path).catch(() => false))) return undefined
|
||||
try {
|
||||
return JSON.parse(String(await $.fs.read(path))) as T
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
|
||||
|
||||
async function stage($: Engine, path: string): Promise<string> {
|
||||
const cwd = await $.session.cwd().catch(() => '')
|
||||
const full = path.startsWith('/') ? path : `${cwd}/${path}`
|
||||
if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}`
|
||||
const text = String(await $.fs.read(full))
|
||||
if (!text.trim()) return `檔案是空的:${path}`
|
||||
|
||||
const chunks = chunkText(text)
|
||||
const parts: Extracted[] = []
|
||||
for (const [i, chunk] of chunks.entries()) {
|
||||
const r = await $.model.complete({ model: 'sonnet', prompt: extractPrompt(chunk, i, chunks.length), timeoutMs: 180000 })
|
||||
if (!r.isAnswered) return `第 ${i + 1}/${chunks.length} 段萃取失敗,尚未寫入任何檔案。`
|
||||
const parsed = parseExtracted(r.text)
|
||||
if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。`
|
||||
parts.push(parsed)
|
||||
}
|
||||
const extracted = mergeChunks(parts)
|
||||
const chapter = (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '')
|
||||
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
|
||||
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
|
||||
const slugs: Record<string, string> = {}
|
||||
for (const c of extracted.characters) slugs[slugKey(c)] = slugKey(c)
|
||||
|
||||
await $.store.set(PENDING_KEY, { phase: 'slug', chapter, root: cwd, extracted, workSlug, slugs } satisfies Pending)
|
||||
const existsWork = await $.fs.exists(`${dirOf(cwd, workSlug)}/work.json`).catch(() => false)
|
||||
const lines = [
|
||||
`已萃取《${extracted.work.name}》章節「${chapter}」(${chunks.length} 段),請確認代號:`,
|
||||
`- 作品代號:${workSlug}(${existsWork ? '已建立,將更新' : '尚未建立,將新增'})`,
|
||||
]
|
||||
for (const c of extracted.characters) {
|
||||
const s = slugs[slugKey(c)]!
|
||||
const ex = await $.fs.exists(`${dirOf(cwd, workSlug)}/${s}.json`).catch(() => false)
|
||||
lines.push(`- ${c.name}:${s}(${ex ? '已建立,將更新' : '尚未建立,將新增'})→ 啟動指令 /jsc-anima:${workSlug}-${s}`)
|
||||
}
|
||||
lines.push('', `代號沒問題請輸入 /jsc-anima:${EXTRACT_COMMAND} confirm;要修改請加 work=新代號 或 <原角色代號>=新代號,例如 confirm work=sao ${slugs[Object.keys(slugs)[0] ?? ''] ?? 'asuna'}=asu。`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function buildPlan($: Engine, p: Pending): Promise<Plan | string> {
|
||||
const dir = dirOf(p.root, p.workSlug)
|
||||
const files: Plan['files'] = []
|
||||
const lines: string[] = []
|
||||
const conflicts: Conflict[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
const work = mergeWork(p.extracted.work, p.workSlug, p.chapter, await readJson<WorkPack>($, `${dir}/work.json`))
|
||||
files.push({ path: `${dir}/work.json`, json: work.pack })
|
||||
lines.push(`- 作品 ${p.workSlug}:${work.action},${work.detail}`)
|
||||
|
||||
for (const c of p.extracted.characters) {
|
||||
const slug = p.slugs[slugKey(c)]!
|
||||
const m = mergeCharacter(c, p.workSlug, slug, p.chapter, await readJson<CharacterPack>($, `${dir}/${slug}.json`))
|
||||
errors.push(...validateCharacter(m.pack))
|
||||
files.push({ path: `${dir}/${slug}.json`, json: m.pack })
|
||||
lines.push(`- 角色 ${p.workSlug}-${slug}(${c.name}):${m.action},${m.detail}${m.conflicts.length ? `,${m.conflicts.length} 項衝突待確認` : ''}`)
|
||||
conflicts.push(...m.conflicts)
|
||||
}
|
||||
if (errors.length) return `角色包 schema 驗證失敗,尚未寫入任何檔案:\n${errors.map(e => `- ${e}`).join('\n')}`
|
||||
return { files, lines, conflicts, workSlug: p.workSlug, workName: p.extracted.work.name }
|
||||
}
|
||||
|
||||
function run($: Engine, argv: string[], cwd: string) {
|
||||
return $.process.run(argv, { cwd, timeoutMs: 60000 })
|
||||
}
|
||||
|
||||
async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> {
|
||||
const root = p.root
|
||||
const git = async (...a: string[]) => {
|
||||
const r = await run($, ['git', ...a], root)
|
||||
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
|
||||
return String(r.stdout).trim()
|
||||
}
|
||||
try {
|
||||
const prev = await git('rev-parse', '--abbrev-ref', 'HEAD')
|
||||
const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '')
|
||||
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
|
||||
await git('fetch', 'origin', base)
|
||||
await git('switch', '-c', branch, '--no-track', `origin/${base}`)
|
||||
for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`)
|
||||
await git('add', 'characters')
|
||||
await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`)
|
||||
await git('push', '-u', 'origin', branch)
|
||||
await git('switch', prev)
|
||||
|
||||
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
|
||||
const body = [
|
||||
'由 /jsc-anima:anima-extract 產生。',
|
||||
'',
|
||||
...plan.lines,
|
||||
...(plan.conflicts.length ? ['', '## 待確認的衝突', ...plan.conflicts.map(c => `- ${c.field}:既有「${c.existing}」,新章節提議「${c.proposed}」`)] : []),
|
||||
'',
|
||||
'角色包只含自行撰寫的人設與規則,不含原作台詞。',
|
||||
].join('\n')
|
||||
return await createPr($, root, base, branch, title, body)
|
||||
} catch (err) {
|
||||
return `寫檔或建立 PR 失敗:${(err as Error).message}`
|
||||
}
|
||||
}
|
||||
|
||||
// 認證優先讀 tea 登入,其次 GITEA_TOKEN;token 只放在子行程的環境變數,不寫進任何檔案。
|
||||
async function createPr($: Engine, root: string, base: string, branch: string, title: string, body: string): Promise<string> {
|
||||
const remote = parseRemote(String((await run($, ['git', 'remote', 'get-url', 'origin'], root)).stdout))
|
||||
if (!remote) return '已推送分支,但無法解析 origin,請手動建立 PR。'
|
||||
const logins = await run($, ['tea', 'logins', 'list', '--output', 'simple'], root).catch(() => undefined)
|
||||
const login = logins?.exitCode === 0 ? String(logins.stdout).split('\n').map(l => l.trim().split(/\s+/)).find(f => f[1]?.includes(remote.host))?.[0] : undefined
|
||||
if (login) {
|
||||
const r = await run($, ['tea', 'pulls', 'create', '--login', login, '--repo', remote.repo, '--base', base, '--head', branch, '--title', title, '--description', body], root)
|
||||
return r.exitCode === 0 ? `已建立 PR(未自動合併):\n${String(r.stdout).trim()}` : `已推送分支 ${branch},但 tea 建立 PR 失敗:${String(r.stderr).trim()}`
|
||||
}
|
||||
const token = await $.env.get('GITEA_TOKEN')
|
||||
if (!token) return `已推送分支 ${branch},但找不到 tea 登入或 GITEA_TOKEN,請手動建立 PR。`
|
||||
const r = await $.process.run(
|
||||
['sh', '-c', `curl -sf -X POST "https://${remote.host}/api/v1/repos/${remote.repo}/pulls" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d @-`],
|
||||
{ cwd: root, env: { GITEA_TOKEN: token }, stdin: JSON.stringify({ title, body, base, head: branch }), timeoutMs: 30000 },
|
||||
)
|
||||
return r.exitCode === 0 ? `已建立 PR(未自動合併):${String(r.stdout).match(/"html_url":"([^"]+)"/)?.[1] ?? branch}` : `已推送分支 ${branch},但 API 建立 PR 失敗。`
|
||||
}
|
||||
|
||||
export async function runExtract($: Engine, args: string): Promise<string> {
|
||||
const [head = '', ...rest] = args.trim().split(/\s+/)
|
||||
if (!head) return USAGE
|
||||
|
||||
if (head === 'cancel') {
|
||||
await $.store.delete(PENDING_KEY)
|
||||
return '已取消,沒有寫入任何檔案。'
|
||||
}
|
||||
if (head !== 'confirm') return stage($, args.trim())
|
||||
|
||||
const p = (await $.store.get(PENDING_KEY)) as Pending | undefined
|
||||
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
|
||||
|
||||
if (p.phase === 'slug') {
|
||||
for (const kv of rest) {
|
||||
const [k, v] = kv.split('=')
|
||||
const s = slugify(v ?? '')
|
||||
if (!k || !s) continue
|
||||
if (k === 'work') p.workSlug = s
|
||||
else if (k in p.slugs) p.slugs[k] = s
|
||||
}
|
||||
const plan = await buildPlan($, p)
|
||||
if (typeof plan === 'string') return plan
|
||||
await $.store.set(PENDING_KEY, { ...p, phase: 'summary', plan } satisfies Pending)
|
||||
return [
|
||||
'變更摘要:',
|
||||
...plan.lines,
|
||||
...(plan.conflicts.length ? ['衝突(標記待確認,不覆蓋既有設定):', ...plan.conflicts.map(c => ` · ${c.field}:「${c.existing}」vs「${c.proposed}」`)] : []),
|
||||
'',
|
||||
`確認寫檔並對預設分支建立 PR 請再輸入 /jsc-anima:${EXTRACT_COMMAND} confirm,取消請輸入 cancel。`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const plan = p.plan!
|
||||
await $.store.delete(PENDING_KEY)
|
||||
return openPr($, p, plan)
|
||||
}
|
||||
+150
-2
@@ -2,16 +2,26 @@ import type { Engine, Register } from 'claude-code'
|
||||
|
||||
import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters'
|
||||
import type { Axes } from './characters'
|
||||
import { EXTRACT_COMMAND, runExtract } from './extractCommand'
|
||||
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'
|
||||
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
|
||||
import type { Entry } from './wiki'
|
||||
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
||||
|
||||
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 WIKI_COMMAND = 'anima-wiki'
|
||||
const SYNCED_KEY = 'wikiSyncedAt'
|
||||
const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
|
||||
|
||||
const AXIS_OPTION: Record<keyof Axes, string> = {
|
||||
gentle: 'axisGentle',
|
||||
@@ -159,6 +169,19 @@ async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string |
|
||||
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -167,10 +190,21 @@ export const register: Register = (on, options) => {
|
||||
|
||||
const isEnabled = flag('enabled', true)
|
||||
let active: string | undefined
|
||||
let toolRegistered = false
|
||||
let route: Route | undefined
|
||||
let prefixed = false
|
||||
const holder: { sys?: Sys } = {}
|
||||
const wikiCommands = new Set<string>()
|
||||
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
|
||||
|
||||
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)[]) {
|
||||
@@ -185,9 +219,15 @@ 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: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
|
||||
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
|
||||
if (active && !toolRegistered) {
|
||||
toolRegistered = true
|
||||
await registerTools($)
|
||||
}
|
||||
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
|
||||
}
|
||||
return next(e)
|
||||
@@ -195,11 +235,29 @@ export const register: Register = (on, options) => {
|
||||
|
||||
on('command.run', async ($, e, next) => {
|
||||
if (!isEnabled) return next(e)
|
||||
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
|
||||
if (e.command === OFF_COMMAND) {
|
||||
active = undefined
|
||||
await $.store.delete(ACTIVE_KEY)
|
||||
return { text: '已離開角色,回到一般模式。' }
|
||||
}
|
||||
if (e.command === TOOL_COMMAND) {
|
||||
return { text: findCharacter(active) ? await runSkillTool($, e.args) : NEED_ACTIVE }
|
||||
}
|
||||
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) {
|
||||
if (!wikiCommands.has(e.command)) return next(e)
|
||||
@@ -209,7 +267,13 @@ export const register: Register = (on, options) => {
|
||||
return { text: page ?? `找不到知識「${e.command}」。` }
|
||||
}
|
||||
active = c.slug
|
||||
route = undefined
|
||||
await $.store.set(ACTIVE_KEY, c.slug)
|
||||
// 工具二到四只在啟動角色後才註冊。
|
||||
if (!toolRegistered) {
|
||||
toolRegistered = true
|
||||
await registerTools($)
|
||||
}
|
||||
await registerWikiTools($, wikiCfg(), wikiCommands)
|
||||
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
|
||||
})
|
||||
@@ -218,13 +282,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)
|
||||
@@ -249,6 +392,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),
|
||||
|
||||
@@ -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 '關係定位:成年角色,維持夥伴與朋友關係。'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
export const PLUGIN = 'jsc-anima'
|
||||
export const SKILL_PREFIX = 'anima-'
|
||||
export const SKILL_DIR = 'skills'
|
||||
export const GUIDE_SKILL = 'jsc-anima'
|
||||
export const LIST_START = '<!-- skills:start -->'
|
||||
export const LIST_END = '<!-- skills:end -->'
|
||||
|
||||
export type Parsed = { name: string; description: string; body: string }
|
||||
|
||||
export const slugify = (s: string): string =>
|
||||
s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
|
||||
export const commandOf = (skill: string): string => `/${PLUGIN}:${SKILL_PREFIX}${skill}`
|
||||
|
||||
// 指令第一個字是技能名稱,其餘是描述。
|
||||
export const parseArgs = (args: string): { skill: string; description: string } | undefined => {
|
||||
const m = args.trim().match(/^(\S+)\s+([\s\S]+)$/)
|
||||
if (!m) return undefined
|
||||
const skill = slugify(m[1]!)
|
||||
return skill ? { skill, description: m[2]!.trim() } : undefined
|
||||
}
|
||||
|
||||
export const parseSkillMd = (md: string): Parsed | undefined => {
|
||||
const m = md.trim().match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (!m) return undefined
|
||||
const field = (k: string) => m[1]!.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'))?.[1]?.trim().replace(/^["']|["']$/g, '')
|
||||
return { name: field('name') ?? '', description: field('description') ?? '', body: m[2]!.trim() }
|
||||
}
|
||||
|
||||
// 回傳錯誤訊息清單,空陣列代表通過。
|
||||
export const validateSkill = (md: string, skill: string): string[] => {
|
||||
const p = parseSkillMd(md)
|
||||
if (!p) return ['缺少 frontmatter(--- 包住的 name 與 description)']
|
||||
const errs: string[] = []
|
||||
if (p.name !== `${SKILL_PREFIX}${skill}`) errs.push(`name 必須是 ${SKILL_PREFIX}${skill},實際為「${p.name}」`)
|
||||
if (!p.description) errs.push('description 不可為空')
|
||||
if (p.body.length < 20) errs.push('內文過短')
|
||||
return errs
|
||||
}
|
||||
|
||||
export const bumpVersion = (v: string, kind: 'minor' | 'patch'): string => {
|
||||
const [a = 0, b = 0, c = 0] = v.split('.').map(n => Number(n) || 0)
|
||||
return kind === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`
|
||||
}
|
||||
|
||||
export type SkillEntry = { skill: string; description: string }
|
||||
|
||||
export const parseEntries = (readme: string): SkillEntry[] => {
|
||||
const block = readme.split(LIST_START)[1]?.split(LIST_END)[0] ?? ''
|
||||
const out: SkillEntry[] = []
|
||||
for (const line of block.split('\n')) {
|
||||
const m = line.match(/^- `\/jsc-anima:anima-([^`\s]+)[^`]*` — (.*)$/)
|
||||
if (m) out.push({ skill: m[1]!, description: m[2]! })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
|
||||
[...list.filter(x => x.skill !== e.skill), e].sort((a, b) => a.skill.localeCompare(b.skill))
|
||||
|
||||
// 技能 README:名稱 /jsc-anima:jsc-anima、技能列表(完整可複製指令)與範例。
|
||||
export const renderGuide = (list: SkillEntry[], example?: { skill: string; input: string }): string => {
|
||||
const ex = example ?? { skill: list[0]?.skill ?? 'example', input: '' }
|
||||
return [
|
||||
`# /${PLUGIN}:${GUIDE_SKILL}`,
|
||||
'',
|
||||
'動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。',
|
||||
'',
|
||||
'## 技能列表',
|
||||
'',
|
||||
LIST_START,
|
||||
...list.map(e => `- \`${commandOf(e.skill)}\` — ${e.description}`),
|
||||
LIST_END,
|
||||
'',
|
||||
'## 範例',
|
||||
'',
|
||||
'```',
|
||||
`${commandOf(ex.skill)}${ex.input ? ' ' + ex.input : ''}`,
|
||||
'```',
|
||||
'',
|
||||
'產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const guideSkillMd = (): string =>
|
||||
`---\nname: ${GUIDE_SKILL}\ndescription: jsc-anima 動漫助理的使用說明與技能列表\n---\n\n請讀取同目錄的 README.md 並依內容回答使用者。\n`
|
||||
|
||||
export const setReadmeVersion = (readme: string, version: string): string => {
|
||||
const line = `目前版本:${version}`
|
||||
return /^目前版本:.*$/m.test(readme)
|
||||
? readme.replace(/^目前版本:.*$/m, line)
|
||||
: readme.replace(/^(# jsc-anima\n)/m, `$1\n${line}\n`)
|
||||
}
|
||||
|
||||
export const setPluginVersion = (json: string, version: string): string =>
|
||||
json.replace(/("version":\s*")[^"]*(")/, `$1${version}$2`)
|
||||
|
||||
export const generatePrompt = (skill: string, description: string): string =>
|
||||
[
|
||||
`請撰寫一個 Claude 技能,技能名稱 ${SKILL_PREFIX}${skill},用途:${description}`,
|
||||
'只輸出 JSON(不要程式碼圍欄),格式:{"skill":"<SKILL.md 全文,含 frontmatter name 與 description,內文用繁體中文>","example":"<一筆範例輸入>"}',
|
||||
`frontmatter 的 name 必須是 ${SKILL_PREFIX}${skill}。`,
|
||||
].join('\n')
|
||||
|
||||
export const parseGenerated = (text: string): { skill: string; example: string } | undefined => {
|
||||
try {
|
||||
const j = JSON.parse(text.trim().replace(/^```(?:json)?\n?|\n?```$/g, ''))
|
||||
if (typeof j.skill === 'string' && typeof j.example === 'string') return { skill: j.skill, example: j.example }
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { bumpVersion, parseEntries, renderGuide, upsertEntry, validateSkill } from './skillgen'
|
||||
|
||||
const SKILL = '---\nname: anima-greet\ndescription: 打招呼\n---\n\n用角色口吻向使用者打招呼,並簡短說明今天的待辦。\n'
|
||||
const GENERATED = JSON.stringify({ skill: SKILL, example: '早安' })
|
||||
|
||||
type Calls = { argv: string[] }[]
|
||||
|
||||
// 啟動角色並替身所有外部:模型、檔案系統、git/tea。
|
||||
async function setup($: any, on: any, opts: { reply?: (prompt: string) => string | undefined; existing?: boolean } = {}) {
|
||||
const writes: Record<string, string> = {}
|
||||
const procs: Calls = []
|
||||
mock.store(on)
|
||||
on('command.register', () => ({ value: { name: 'x' } }) as never)
|
||||
on('session.start', () => ({ cwd: '/repo' }) as never)
|
||||
on('session.cwd', () => ({ value: '/repo' }))
|
||||
on('command.run', () => ({ text: '' }))
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
on('model.complete', (_: any, e: any) => {
|
||||
const t = opts.reply ? opts.reply(String(e.prompt)) : String(e.prompt).startsWith('請撰寫') ? GENERATED : '嗨!'
|
||||
return { value: t === undefined ? { isAnswered: false, reason: 'empty-reply' } : { isAnswered: true, text: t, usage: {} } }
|
||||
})
|
||||
on('process.run', (_: any, e: any) => {
|
||||
procs.push({ argv: [...e.argv] })
|
||||
const s = e.argv.join(' ')
|
||||
if (s.startsWith('git rev-parse')) return { value: { exitCode: 0, stdout: '/repo\n', stderr: '' } }
|
||||
if (s.startsWith('git symbolic-ref')) return { value: { exitCode: 0, stdout: 'origin/main\n', stderr: '' } }
|
||||
if (s.startsWith('tea logins')) return { value: { exitCode: 0, stdout: 'admin\n', stderr: '' } }
|
||||
if (s.startsWith('tea pulls')) return { value: { exitCode: 0, stdout: 'https://g/pulls/1\n', stderr: '' } }
|
||||
return { value: { exitCode: 0, stdout: '', stderr: '' } }
|
||||
})
|
||||
on('fs.exists', (_: any, e: any) => ({ value: Boolean(opts.existing && String(e.path).endsWith('anima-greet/SKILL.md')) }))
|
||||
on('fs.read', (_: any, e: any) => ({
|
||||
value: String(e.path).endsWith('plugin.json') ? '{\n "version": "0.3.0"\n}' : String(e.path).endsWith('README.md') ? '# jsc-anima\n' : '',
|
||||
}))
|
||||
on('fs.write', (_: any, e: any) => {
|
||||
writes[e.path] = e.text
|
||||
return { value: undefined }
|
||||
})
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
return { writes, procs }
|
||||
}
|
||||
|
||||
const run = ($: any, args: string) => $.command.run({ command: 'anima-skill', args } as never)
|
||||
|
||||
test('未啟動角色時工具二被拒絕並提示先啟動', async ($, on) => {
|
||||
const { procs } = await setup($, on)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
expect(r.text.includes('請先啟動角色')).toBe(true)
|
||||
expect(procs.length).toBe(0)
|
||||
})
|
||||
|
||||
test('新增技能:寫檔、升 minor、更新 README、建立 PR', async ($, on) => {
|
||||
const { writes, procs } = await setup($, on)
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
const key = (suffix: string) => Object.keys(writes).find(k => k.endsWith(suffix))!
|
||||
expect(writes[key('skills/anima-greet/SKILL.md')]!.includes('name: anima-greet')).toBe(true)
|
||||
expect(writes[key('.claude-plugin/plugin.json')]!.includes('"0.4.0"')).toBe(true)
|
||||
expect(writes[key('/README.md')]!.includes('目前版本:0.4.0') || Object.values(writes).some(v => v.includes('目前版本:0.4.0'))).toBe(true)
|
||||
expect(writes[key('skills/jsc-anima/README.md')]!.includes('/jsc-anima:anima-greet')).toBe(true)
|
||||
expect(procs.some(p => p.argv.join(' ').startsWith('git commit') && p.argv.join(' ').includes('feat: 新增技能'))).toBe(true)
|
||||
const pr = procs.find(p => p.argv[0] === 'tea' && p.argv[1] === 'pulls')!
|
||||
expect(pr.argv.includes('main')).toBe(true)
|
||||
expect(pr.argv.includes('feat/anima-greet')).toBe(true)
|
||||
expect(r.text.includes('https://g/pulls/1')).toBe(true)
|
||||
})
|
||||
|
||||
test('技能已存在則覆蓋並升 patch', async ($, on) => {
|
||||
const { writes } = await setup($, on, { existing: true })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
await run($, 'greet 打招呼')
|
||||
expect(Object.entries(writes).some(([k, v]) => k.endsWith('plugin.json') && v.includes('"0.3.1"'))).toBe(true)
|
||||
})
|
||||
|
||||
test('驗證失敗或實跑失敗就不建立 PR', async ($, on) => {
|
||||
const bad = await setup($, on, { reply: () => JSON.stringify({ skill: 'no frontmatter', example: 'x' }) })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r1 = await run($, 'greet 打招呼')
|
||||
expect(r1.text.includes('驗證失敗')).toBe(true)
|
||||
expect(bad.procs.some(p => p.argv[0] === 'tea')).toBe(false)
|
||||
expect(Object.keys(bad.writes).length).toBe(0)
|
||||
})
|
||||
|
||||
test('實跑無回應就不建立 PR', async ($, on) => {
|
||||
const s = await setup($, on, { reply: p => (p.startsWith('請撰寫') ? GENERATED : undefined) })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
expect(r.text.includes('實跑失敗')).toBe(true)
|
||||
expect(s.procs.some(p => p.argv[0] === 'tea')).toBe(false)
|
||||
})
|
||||
|
||||
test('技能驗證與版本規則', () => {
|
||||
expect(validateSkill(SKILL, 'greet').length).toBe(0)
|
||||
expect(validateSkill(SKILL, 'other').length).toBe(1)
|
||||
expect(bumpVersion('0.3.4', 'minor')).toBe('0.4.0')
|
||||
expect(bumpVersion('0.3.4', 'patch')).toBe('0.3.5')
|
||||
const list = upsertEntry(parseEntries(renderGuide([{ skill: 'a', description: 'A' }])), { skill: 'b', description: 'B' })
|
||||
expect(list.length).toBe(2)
|
||||
expect(renderGuide(list).includes('# /jsc-anima:jsc-anima')).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Engine } from 'claude-code'
|
||||
|
||||
import {
|
||||
GUIDE_SKILL, SKILL_DIR, SKILL_PREFIX, bumpVersion, commandOf, generatePrompt, guideSkillMd, parseArgs,
|
||||
parseEntries, parseGenerated, renderGuide, setPluginVersion, setReadmeVersion, upsertEntry, validateSkill,
|
||||
} from './skillgen'
|
||||
|
||||
export const TOOL_COMMAND = 'anima-skill'
|
||||
export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
|
||||
|
||||
export async function registerTools($: Engine): Promise<void> {
|
||||
await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' })
|
||||
}
|
||||
|
||||
const MODEL = 'sonnet'
|
||||
|
||||
function git($: Engine, cwd: string, ...argv: string[]) {
|
||||
return $.process.run(['git', ...argv], { cwd, timeoutMs: 60000 })
|
||||
}
|
||||
|
||||
async function complete($: Engine, prompt: string, system?: string): Promise<string | undefined> {
|
||||
const r = await $.model.complete({ model: MODEL, prompt, system, maxTokens: 4000, timeoutMs: 120000 })
|
||||
return r.isAnswered && r.text.trim() ? r.text : undefined
|
||||
}
|
||||
|
||||
// Gitea 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在行程環境與標頭,不寫檔。
|
||||
async function createPr($: Engine, cwd: string, base: string, head: string, title: string, body: string): Promise<string> {
|
||||
const tea = await $.process.run(['tea', 'logins', 'list'], { cwd, timeoutMs: 10000 }).catch(() => undefined)
|
||||
if (tea && tea.exitCode === 0 && tea.stdout.trim()) {
|
||||
const r = await $.process.run(['tea', 'pulls', 'create', '--base', base, '--head', head, '--title', title, '--description', body], { cwd, timeoutMs: 60000 })
|
||||
if (r.exitCode !== 0) throw new Error(`建立 PR 失敗:${r.stderr.trim()}`)
|
||||
return r.stdout.trim()
|
||||
}
|
||||
const token = (await $.env.get('GITEA_TOKEN').catch(() => undefined)) as string | undefined
|
||||
if (!token) throw new Error('找不到 Gitea 認證:請先 tea login add,或設定 GITEA_TOKEN。')
|
||||
const remote = (await git($, cwd, 'remote', 'get-url', 'origin')).stdout.trim()
|
||||
const m = remote.match(/^(https?:\/\/[^/]+)\/(.+?)(?:\.git)?$/)
|
||||
if (!m) throw new Error(`無法解析 origin:${remote}`)
|
||||
const r = await $.process.run(
|
||||
['curl', '-fsS', '-X', 'POST', `${m[1]}/api/v1/repos/${m[2]}/pulls`, '-H', `Authorization: token ${token}`, '-H', 'Content-Type: application/json',
|
||||
'-d', JSON.stringify({ base, head, title, body })],
|
||||
{ cwd, timeoutMs: 60000 },
|
||||
)
|
||||
if (r.exitCode !== 0) throw new Error(`建立 PR 失敗:${r.stderr.trim()}`)
|
||||
return (JSON.parse(r.stdout) as { html_url?: string }).html_url ?? ''
|
||||
}
|
||||
|
||||
export async function runSkillTool($: Engine, args: string): Promise<string> {
|
||||
const a = parseArgs(args)
|
||||
if (!a) return '用法:/jsc-anima:anima-skill <技能名稱> <描述>'
|
||||
const { skill, description } = a
|
||||
const name = `${SKILL_PREFIX}${skill}`
|
||||
|
||||
// 1. 產生
|
||||
const gen = await complete($, generatePrompt(skill, description)).then(t => (t ? parseGenerated(t) : undefined))
|
||||
if (!gen) return '產生技能失敗(模型沒有回傳可用內容),未建立 PR。'
|
||||
|
||||
// 2. 驗證
|
||||
const errs = validateSkill(gen.skill, skill)
|
||||
if (errs.length) return `技能驗證失敗,未建立 PR:\n- ${errs.join('\n- ')}`
|
||||
|
||||
// 3. 以範例輸入實跑
|
||||
const run = await complete($, gen.example, gen.skill)
|
||||
if (!run) return '技能實跑失敗(範例輸入沒有得到回應),未建立 PR。'
|
||||
|
||||
// 4. 在獨立 worktree 寫檔,不動使用者目前的工作樹
|
||||
const cwd = await $.session.cwd()
|
||||
const top = (await git($, cwd, 'rev-parse', '--show-toplevel')).stdout.trim()
|
||||
if (!top) return '目前目錄不在 git repo 內,未建立 PR。'
|
||||
const head = (await git($, top, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).stdout.trim().replace(/^origin\//, '')
|
||||
const base = head || 'main'
|
||||
const branch = `feat/${name}`
|
||||
const wt = `${top}/.anima-wt-${skill}`
|
||||
await git($, top, 'fetch', 'origin', base)
|
||||
const add = await git($, top, 'worktree', 'add', '-B', branch, wt, `origin/${base}`)
|
||||
if (add.exitCode !== 0) return `建立工作目錄失敗:${add.stderr.trim()}`
|
||||
|
||||
try {
|
||||
const skillPath = `${wt}/${SKILL_DIR}/${name}/SKILL.md`
|
||||
const isOverwrite = await $.fs.exists(skillPath)
|
||||
const pluginPath = `${wt}/.claude-plugin/plugin.json`
|
||||
const plugin = await $.fs.read(pluginPath)
|
||||
const cur = plugin.match(/"version":\s*"([^"]*)"/)?.[1] ?? '0.0.0'
|
||||
const next = bumpVersion(cur, isOverwrite ? 'patch' : 'minor')
|
||||
|
||||
const guidePath = `${wt}/${SKILL_DIR}/${GUIDE_SKILL}/README.md`
|
||||
const old = (await $.fs.exists(guidePath)) ? await $.fs.read(guidePath) : ''
|
||||
const list = upsertEntry(parseEntries(old), { skill, description })
|
||||
|
||||
await $.fs.write(skillPath, gen.skill.trim() + '\n')
|
||||
await $.fs.write(`${wt}/${SKILL_DIR}/${GUIDE_SKILL}/SKILL.md`, guideSkillMd())
|
||||
await $.fs.write(guidePath, renderGuide(list, { skill, input: gen.example }))
|
||||
await $.fs.write(pluginPath, setPluginVersion(plugin, next))
|
||||
const readmePath = `${wt}/README.md`
|
||||
await $.fs.write(readmePath, setReadmeVersion(await $.fs.read(readmePath), next))
|
||||
|
||||
const verb = isOverwrite ? '更新' : '新增'
|
||||
await git($, wt, 'add', '-A')
|
||||
const c = await git($, wt, 'commit', '-m', `feat: ${verb}技能 ${name}(v${next})`)
|
||||
if (c.exitCode !== 0) return `commit 失敗:${c.stderr.trim()}`
|
||||
const push = await git($, wt, 'push', '-u', 'origin', branch)
|
||||
if (push.exitCode !== 0) return `推送失敗:${push.stderr.trim()}`
|
||||
const url = await createPr($, wt, base, branch, `feat: ${verb}技能 ${name}`, `${description}\n\n指令:${commandOf(skill)}\n版本:${cur} → ${next}\n\n已通過驗證並以範例輸入實跑。`)
|
||||
return `已${verb}技能 ${commandOf(skill)}(v${next}),PR:${url}`
|
||||
} catch (e) {
|
||||
return `建立 PR 失敗:${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
await git($, top, 'worktree', 'remove', '--force', wt).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ test('啟動後才註冊工具,並依 wiki 目錄動態註冊', OPTS, async ($
|
||||
const registered: string[] = []
|
||||
setup(on, { registered, seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n' } })
|
||||
await start($)
|
||||
expect(registered).toEqual([...registered.filter(n => n === 'anima-off' || n.startsWith('sao-'))])
|
||||
expect(registered).not.toContain('anima-wiki')
|
||||
expect(registered).not.toContain('tools-git')
|
||||
await run($, 'sao-asuna')
|
||||
expect(registered).toContain('anima-wiki')
|
||||
expect(registered).toContain('tools-git')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# /jsc-anima:jsc-anima
|
||||
|
||||
動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。
|
||||
|
||||
## 技能列表
|
||||
|
||||
<!-- skills:start -->
|
||||
<!-- skills:end -->
|
||||
|
||||
## 範例
|
||||
|
||||
```
|
||||
/jsc-anima:anima-skill <技能名稱> <描述>
|
||||
```
|
||||
|
||||
產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: jsc-anima
|
||||
description: jsc-anima 動漫助理的使用說明與技能列表
|
||||
---
|
||||
|
||||
請讀取同目錄的 README.md 並依內容回答使用者。
|
||||
Reference in New Issue
Block a user