Compare commits

...
14 Commits
Author SHA1 Message Date
rootandClaude Sonnet 5.5 aa41b6cb64 版本號改為 0.0.5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:15:39 +00:00
rootandClaude Sonnet 5.5 74a5d4949c fix: anima-extract 支援 wiki URL 與自然語言參數,並依序逐章處理
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:13:32 +00:00
jiantw83 1ce475024d Merge pull request 'fix: 調整指令可用時機(anima-off/anima-affinity)' (#23) from fix-command-availability into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/23
2026-10-15 09:37:47 +00:00
rootandClaude Sonnet 5.5 20fc0a9c6c fix: anima-off 改為啟動後才可用,anima-affinity 一律可用;版本號改為 0.0.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:33:16 +00:00
rootandClaude Sonnet 5.5 f112cf48f1 版本號改為 0.0.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:22:41 +00:00
jiantw83 dd930160c5 Merge pull request 'test: 補測輸出改寫邊界行為' (#19) from issue-16 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/19
2026-10-15 09:22:09 +00:00
adminandClaude Sonnet 5.5 8554b804c0 合併 origin/main,解決 routing.test.ts 衝突
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:17:34 +00:00
jiantw83 09fd67de45 Merge pull request 'fix: anima-extract 在任何專案目錄都對 jsc-anima 開 PR' (#22) from issue-18 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/22
2026-10-15 09:14:19 +00:00
jiantw83 f1fa02c4f1 Merge pull request 'test: 補測啟動持久與角色微調' (#21) from issue-17 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/21
2026-10-15 09:14:06 +00:00
jiantw83 1b4ee92594 Merge pull request '補測好感度與關係規則' (#20) from issue-15 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/20
2026-10-15 09:13:53 +00:00
adminandClaude Sonnet 5.5 2a4d138cdc fix: anima-extract 改用 jsc-anima 專屬快取 clone,不論在哪個專案都對 jsc-anima 開 PR
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:09:14 +00:00
adminandClaude Sonnet 5.5 e2fcf6be2b test: 補測啟動持久與角色微調
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:06:15 +00:00
adminandClaude Sonnet 5.5 0633cad75a test: 補測好感度與關係規則,並修正移轉關係未帶出原戀人名稱
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:06:09 +00:00
adminandClaude Sonnet 5.5 535c5b1437 test: 補測輸出改寫邊界行為(模型選擇、逾時與額度不足、原文 log 開關、前綴只加一次)
實測行為皆符合規格,無需修正實作。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:06:03 +00:00
22 changed files with 2251 additions and 53 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-anima", "name": "jsc-anima",
"version": "0.0.2", "version": "0.0.5",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列", "description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
"license": "MIT", "license": "MIT",
"userConfig": { "userConfig": {
+1 -1
View File
@@ -2,7 +2,7 @@
# jsc-anima # jsc-anima
目前版本:0.0.2 目前版本:0.0.5
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。 動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
+128 -14
View File
@@ -1,5 +1,6 @@
import { expect, mock, test } from 'claude-code/testing' import { expect, mock, test } from 'claude-code/testing'
import { parseWikiPages, parseWikiSource } from './extract'
import type { Extracted } from './extract' import type { Extracted } from './extract'
const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] => const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] =>
@@ -17,25 +18,34 @@ const reply = (c: Extracted['characters'][number]) =>
type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] } type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] }
let cloneMissing = false
let registered: string[] = []
const probed = new Set<string>()
let sessionCwd = '/repo'
const web = new Map<string, string>()
const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => { const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => {
const s: Setup = { files, cmds: [], prompts: [] } const s: Setup = { files, cmds: [], prompts: [] }
const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void
mock.store(on) mock.store(on)
o('command.register', () => ({ value: { name: 'x' } })) o('command.register', ((_$: unknown, e: { name: string }) => { registered.push(e.name); return { value: { name: e.name } } }) as never)
o('session.start', () => ({ cwd: '/repo' })) o('session.start', () => ({ cwd: '/repo' }))
o('session.cwd', (() => ({ value: '/repo' })) as never) o('session.cwd', (() => ({ value: sessionCwd })) as never)
o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never) mock.env(on, { HOME: '/home/u' })
o('fs.exists', ((_$: unknown, e: { path: string }) => (e.path.endsWith('/characters/index.json') && probed.add(e.path), { value: s.files.has(e.path) || (e.path === '/home/u/.cache/jsc-anima/repo/.git' && !cloneMissing) })) as never)
o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(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('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 }) => { o('model.complete', ((_$: unknown, e: { prompt: string }) => {
s.prompts.push(e.prompt) s.prompts.push(e.prompt)
return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } } return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } }
}) as never) }) as never)
o('env.get', (() => ({ value: undefined })) as never) o('process.run', ((_$: unknown, e: { argv: string[]; }) => {
o('process.run', ((_$: unknown, e: { argv: string[] }) => {
s.cmds.push(e.argv) s.cmds.push(e.argv)
const j = e.argv.join(' ') 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' : '' if (e.argv[0] === 'curl') {
const body = web.get(e.argv[e.argv.length - 1]!)
return { value: { exitCode: body === undefined ? 22 : 0, stdout: body ?? '', stderr: '' } }
}
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' : j.startsWith('tea logins') ? 'admin https://gitea.jsc.idv.tw u' : j.startsWith('tea pulls') ? 'PR#1' : ''
return { value: { exitCode: 0, stdout: out, stderr: '' } } return { value: { exitCode: 0, stdout: out, stderr: '' } }
}) as never) }) as never)
return s return s
@@ -50,18 +60,18 @@ test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR',
expect(t1).toContain('ts') expect(t1).toContain('ts')
expect(t1).toContain('aoi') expect(t1).toContain('aoi')
expect(t1).toContain('尚未建立') expect(t1).toContain('尚未建立')
expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false) expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')).toBe(false)
const t2 = await run($ as never, 'confirm work=testy aoi=ao') const t2 = await run($ as never, 'confirm work=testy aoi=ao')
expect(t2).toContain('變更摘要') expect(t2).toContain('變更摘要')
expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false) expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')).toBe(false)
const t3 = await run($ as never, 'confirm') const t3 = await run($ as never, 'confirm')
expect(t3).toContain('已建立 PR') expect(t3).toContain('已建立 PR')
const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!) const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')!)
expect(pack.timeline[0].chapter).toBe('ch1') expect(pack.timeline[0].chapter).toBe('ch1')
expect(pack.timeline[0].age).toBe(16) expect(pack.timeline[0].age).toBe(16)
expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核') expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核')
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')! const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
expect(pr).toContain('main') expect(pr).toContain('main')
expect(pr).toContain('feat/extract-testy-ch1') expect(pr).toContain('feat/extract-testy-ch1')
@@ -79,14 +89,14 @@ test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確
} }
await write('ch1.txt') await write('ch1.txt')
await write('ch1.txt') await write('ch1.txt')
expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1) expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!).timeline.length).toBe(1)
await run($ as never, 'ch2.txt') await run($ as never, 'ch2.txt')
const summary = await run($ as never, 'confirm') const summary = await run($ as never, 'confirm')
expect(summary).toContain('追加') expect(summary).toContain('追加')
expect(summary).toContain('待確認') expect(summary).toContain('待確認')
await run($ as never, 'confirm') await run($ as never, 'confirm')
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2']) expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2'])
expect(pack.persona.tone).toBe('溫柔') expect(pack.persona.tone).toBe('溫柔')
expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' }) expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' })
@@ -112,10 +122,10 @@ test('貼上章節內容:paste <章節名稱> 加下一行起的內容,並
expect(s.prompts[0]).toContain('這是貼上的章節內容。') expect(s.prompts[0]).toContain('這是貼上的章節內容。')
await run($ as never, 'confirm') await run($ as never, 'confirm')
await run($ as never, 'confirm') await run($ as never, 'confirm')
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
expect(pack.timeline[0].chapter).toBe('第一章') expect(pack.timeline[0].chapter).toBe('第一章')
expect(pack.language).toBe('繁體中文,少量原作口癖') expect(pack.language).toBe('繁體中文,少量原作口癖')
expect(JSON.parse(s.files.get('/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' }) expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' })
}) })
test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => { test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => {
@@ -126,3 +136,107 @@ test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($
expect(await run($ as never, 'confirm')).toContain('疑似抄錄原作台詞') expect(await run($ as never, 'confirm')).toContain('疑似抄錄原作台詞')
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
}) })
test('在非 jsc-anima 目錄執行:git 與 PR 都走 jsc-anima 的快取 clone,不碰 session 目錄', async ($, on) => {
sessionCwd = '/other/proj'
try {
const s = setup(on as never, [reply(char())], new Map([['/other/proj/ch1.txt', '第一段']]))
await $.session.start({ source: 'startup', cwd: '/other/proj' } as never)
await run($ as never, '/other/proj/ch1.txt')
await run($ as never, 'confirm')
expect(await run($ as never, 'confirm')).toContain('已建立 PR')
expect(s.cmds.some(c => c.includes('fetch'))).toBe(true)
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
expect(pr).toContain('jiantw83/jsc-anima')
expect(s.files.has('/other/proj/characters/index.json')).toBe(false)
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0].slug).toBe('aoi')
} finally {
sessionCwd = '/repo'
}
})
test('快取 clone 不存在時先 clone jsc-anima', async ($, on) => {
cloneMissing = true
try {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
const clone = s.cmds.find(c => c.includes('clone'))!
expect(clone).toContain('https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git')
} finally {
cloneMissing = false
}
})
test('paste:CRLF 換行也能分出章節名稱與內容', async ($, on) => {
const s = setup(on as never, [reply(char())])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'paste 第二章\r\n第一行\r\n第二行')
expect(s.prompts[0]).toContain('第一行')
})
test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {作品代號}-{角色代號} 並可啟動', async ($, on) => {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
registered = []
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
await run($ as never, 'confirm')
await run($ as never, 'confirm')
// 模擬 PR 合併、外掛更新:clone 內的 characters/ 內容出現在外掛目錄。
const root = ([...probed].find(q => !q.startsWith('/home/u/')) ?? '').replace('/characters/index.json', '')
for (const [k, v] of [...s.files]) if (k.startsWith('/home/u/.cache/jsc-anima/repo/characters/')) s.files.set(k.replace('/home/u/.cache/jsc-anima/repo', root), v)
await $.session.start({ source: 'startup', cwd: '/anywhere' } as never)
expect(registered).toContain('ts-aoi')
})
const WIKI = 'https://gitea.jsc.idv.tw/jiantw83/sao/wiki'
const listHtml = ['Home', '第10章', '第2章', '第1章', '_pages']
.map(n => `<a href="/jiantw83/sao/wiki/${encodeURIComponent(n)}">${n}</a>`)
.join('') + '<a href="/jiantw83/sao">repo</a><a href="/jiantw83/sao/wiki/第1章/_revision/abc">rev</a>'
test('parseWikiSource:從自然語言中取出 wiki URL,頁面清單與單頁分開判斷', () => {
const src = parseWikiSource(`請幫我依照順序逐章處理 ${WIKI}/?action=_pages 謝謝`)
expect(src).toMatchObject({ kind: 'list', base: WIKI })
expect(parseWikiSource(`處理 ${WIKI}/第2章。`)).toMatchObject({ kind: 'page', page: '第2章', base: WIKI })
expect(parseWikiSource('ch1.txt')).toBeUndefined()
expect(parseWikiSource('請處理 ch 1.txt')).toBeUndefined()
})
test('parseWikiPages:略過 Home/_pages/子路徑,並依章節數字自然排序', () => {
expect(parseWikiPages(listHtml, WIKI)).toEqual(['第1章', '第2章', '第10章'])
})
test('anima-extract 帶 URL 與自然語言:不當檔案路徑,依序逐章處理', async ($, on) => {
web.clear()
web.set(`${WIKI}/?action=_pages`, listHtml)
for (const n of ['第1章', '第2章', '第10章']) web.set(`${WIKI}/raw/${encodeURIComponent(n)}`, `${n}內容`)
const s = setup(on as never, [reply(char()), reply(char()), reply(char())])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const t1 = await run($ as never, `請幫我依照順序逐章處理 ${WIKI}/?action=_pages`)
expect(t1).not.toContain('找不到檔案')
expect(t1).toContain('共 3 章:第1章、第2章、第10章')
expect(t1).toContain('章節「第1章」')
expect(s.prompts[0]).toContain('第1章內容')
await run($ as never, 'confirm')
const t3 = await run($ as never, 'confirm')
expect(t3).toContain('已建立 PR')
expect(t3).toContain('剩餘 1 章')
expect(t3).toContain('章節「第2章」')
expect(s.prompts[1]).toContain('第2章內容')
await run($ as never, 'confirm')
const t5 = await run($ as never, 'confirm')
expect(t5).toContain('章節「第10章」')
await run($ as never, 'confirm')
const t7 = await run($ as never, 'confirm')
expect(t7).toContain('已建立 PR')
expect(t7).not.toContain('接著處理')
})
test('wiki 章節讀不到時回報原因與剩餘章數;cancel 清除佇列', async ($, on) => {
web.clear()
web.set(`${WIKI}/?action=_pages`, listHtml)
setup(on as never, [])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
expect(await run($ as never, `${WIKI}/?action=_pages`)).toContain('無法讀取 wiki 章節「第1章」')
expect(await run($ as never, 'cancel')).toContain('已取消')
})
+47
View File
@@ -228,3 +228,50 @@ export const parseRemote = (url: string): { host: string; repo: string } | undef
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/) const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/)
return m ? { host: m[1]!, repo: m[2]! } : undefined return m ? { host: m[1]!, repo: m[2]! } : undefined
} }
export type WikiSource = { kind: 'list' | 'page'; url: string; origin: string; base: string; page?: string }
// 從自然語言參數中取出第一個 URL;Gitea wiki 頁面清單(?action=_pages 或 /wiki 首頁)視為整本逐章,單頁網址視為一章。
export const parseWikiSource = (args: string): WikiSource | undefined => {
const raw = /https?:\/\/[^\s<>"'「」『』()()]+/.exec(args)?.[0]?.replace(/[,。、;,.;:!?!?]+$/, '')
if (!raw) return undefined
let u: URL
try {
u = new URL(raw)
} catch {
return undefined
}
const m = /^(.*\/wiki)(?:\/(.*))?$/.exec(u.pathname.replace(/\/+$/, ''))
if (!m) return { kind: 'page', url: raw, origin: u.origin, base: raw }
const base = `${u.origin}${m[1]}`
const page = m[2] && !m[2].startsWith('raw/') && m[2] !== '_pages' ? decodeURIComponent(m[2]) : undefined
if (u.searchParams.get('action') === '_pages' || !page) return { kind: 'list', url: raw, origin: u.origin, base }
return { kind: 'page', url: raw, origin: u.origin, base, page }
}
const SKIP_PAGES = new Set(['home', 'sidebar', 'footer', '_sidebar', '_footer', '_pages', 'new'])
// 解析 Gitea wiki 頁面清單 HTML,依章節名稱自然排序(第2章 < 第10章)。
export const parseWikiPages = (html: string, base: string): string[] => {
const path = new URL(base).pathname
const names = new Set<string>()
for (const m of html.matchAll(/href="([^"#]+)"/g)) {
let p: string
try {
p = new URL(m[1]!.replace(/&amp;/g, '&'), base).pathname
} catch {
continue
}
if (!p.startsWith(`${path}/`)) continue
const rest = p.slice(path.length + 1)
if (!rest || rest.includes('/')) continue
let name: string
try {
name = decodeURIComponent(rest)
} catch {
continue
}
if (!SKIP_PAGES.has(name.toLowerCase())) names.add(name)
}
return [...names].sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true }))
}
+111 -18
View File
@@ -1,11 +1,15 @@
import type { Engine } from 'claude-code' import type { Engine } from 'claude-code'
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract' import { teaToken } from './wiki'
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, parseWikiPages, parseWikiSource, slugify, validateCharacter } from './extract'
import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract' import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
export const EXTRACT_COMMAND = 'anima-extract' export const EXTRACT_COMMAND = 'anima-extract'
// PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。
export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git'
const PENDING_KEY = 'extract:pending' const PENDING_KEY = 'extract:pending'
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>(或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。` const QUEUE_KEY = 'extract:queue'
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑或 wiki 網址>(網址為 Gitea wiki 頁面清單時依序逐章處理,前後可加說明文字;或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。`
type Plan = { type Plan = {
files: { path: string; json: unknown }[] files: { path: string; json: unknown }[]
@@ -15,6 +19,8 @@ type Plan = {
workName: string workName: string
} }
type Queue = { base: string; pages: string[] }
type Pending = { type Pending = {
phase: 'slug' | 'summary' phase: 'slug' | 'summary'
chapter: string chapter: string
@@ -38,6 +44,91 @@ async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
type Repo = { dir: string; base: string; auth: string[] }
async function gitIn($: Engine, auth: string[], dir: string, a: string[]) {
const r = await $.process.run(['git', ...auth, ...a], { cwd: dir, timeoutMs: 120000 })
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
return String(r.stdout).trim()
}
// 取得 jsc-anima 的快取 clone 並對齊 origin 預設分支(認證優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數)。
async function syncRepo($: Engine): Promise<Repo | string> {
const home = (await $.env.get('HOME')) ?? ''
const dir = `${home}/.cache/jsc-anima/repo`
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(ANIMA_REPO_URL).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
const auth = token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : []
try {
if (!(await $.fs.exists(`${dir}/.git`).catch(() => false))) {
await $.process.run(['mkdir', '-p', dir.replace(/\/[^/]+$/, '')], { timeoutMs: 5000 })
await gitIn($, auth, home || '/', ['clone', ANIMA_REPO_URL, dir])
}
await gitIn($, auth, dir, ['fetch', 'origin', '--prune'])
await gitIn($, auth, dir, ['remote', 'set-head', 'origin', '--auto'])
const base = (await gitIn($, auth, dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).replace(/^origin\//, '')
await gitIn($, auth, dir, ['checkout', '--detach', '--force', `origin/${base}`])
await gitIn($, auth, dir, ['clean', '-fd', 'characters'])
return { dir, base, auth }
} catch (err) {
return `無法取得 jsc-anima repo(${ANIMA_REPO_URL}):${(err as Error).message}`
}
}
async function giteaToken($: Engine, host: string): Promise<string | undefined> {
const home = (await $.env.get('HOME')) ?? ''
try {
const t = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), host)
if (t) return t
} catch {}
return (await $.env.get('GITEA_TOKEN')) || undefined
}
async function curl($: Engine, url: string): Promise<string | undefined> {
const token = await giteaToken($, new URL(url).host)
const r = await $.process.run(['curl', '-fsSL', ...(token ? ['-H', `Authorization: token ${token}`] : []), url], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? String(r.stdout) : undefined
}
const rawUrl = (base: string, page: string) => `${base}/raw/${encodeURIComponent(page)}`
async function stageWiki($: Engine, base: string, page: string, rest: string[]): Promise<string> {
const text = await curl($, rawUrl(base, page))
if (!text?.trim()) return `無法讀取 wiki 章節「${page}」(${rawUrl(base, page)})。${rest.length ? `剩餘 ${rest.length} 章未處理。` : ''}`
await $.store.set(QUEUE_KEY, { base, pages: rest } satisfies Queue)
return stage($, page, text)
}
async function stageSource($: Engine, args: string, src: NonNullable<ReturnType<typeof parseWikiSource>>): Promise<string> {
if (src.kind === 'page') {
if (src.page) return stageWiki($, src.base, src.page, [])
const text = await curl($, src.url)
if (!text?.trim()) return `無法讀取網址:${src.url}`
return stage($, decodeURIComponent(src.url.split('/').pop() || 'chapter'), text)
}
const html = await curl($, `${src.base}/?action=_pages`)
if (!html) return `無法讀取 wiki 頁面清單:${src.base}/?action=_pages`
const pages = parseWikiPages(html, src.base)
if (!pages.length) return `wiki 頁面清單中找不到章節:${src.url}`
const [first, ...rest] = pages
const out = await stageWiki($, src.base, first!, rest)
return `依序逐章處理,共 ${pages.length} 章:${pages.join('、')}\n\n${out}`
}
// 目前這章處理完(PR 已建立)後,接著萃取佇列中的下一章。
async function nextChapter($: Engine, done: string): Promise<string> {
const q = (await $.store.get(QUEUE_KEY)) as Queue | undefined
if (!q?.pages.length) {
await $.store.delete(QUEUE_KEY)
return done
}
const [next, ...rest] = q.pages
return `${done}\n\n接著處理下一章(剩餘 ${rest.length} 章):\n${await stageWiki($, q.base, next!, rest)}`
}
async function stageFile($: Engine, path: string): Promise<string> { async function stageFile($: Engine, path: string): Promise<string> {
const cwd = await $.session.cwd().catch(() => '') const cwd = await $.session.cwd().catch(() => '')
const full = path.startsWith('/') ? path : `${cwd}/${path}` const full = path.startsWith('/') ? path : `${cwd}/${path}`
@@ -48,7 +139,6 @@ async function stageFile($: Engine, path: string): Promise<string> {
} }
async function stage($: Engine, chapter: string, text: string): Promise<string> { async function stage($: Engine, chapter: string, text: string): Promise<string> {
const cwd = await $.session.cwd().catch(() => '')
const chunks = chunkText(text) const chunks = chunkText(text)
const parts: Extracted[] = [] const parts: Extracted[] = []
for (const [i, chunk] of chunks.entries()) { for (const [i, chunk] of chunks.entries()) {
@@ -58,6 +148,9 @@ async function stage($: Engine, chapter: string, text: string): Promise<string>
if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。` if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。`
parts.push(parsed) parts.push(parsed)
} }
const repo = await syncRepo($)
if (typeof repo === 'string') return repo
const cwd = repo.dir
const extracted = mergeChunks(parts) const extracted = mergeChunks(parts)
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name) const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。' if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
@@ -115,23 +208,19 @@ function run($: Engine, argv: string[], cwd: string) {
} }
async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> { async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> {
const root = p.root const repo = await syncRepo($)
const git = async (...a: string[]) => { if (typeof repo === 'string') return repo
const r = await run($, ['git', ...a], root) const root = repo.dir
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`) const git = (...a: string[]) => gitIn($, repo.auth, root, a)
return String(r.stdout).trim()
}
try { try {
const prev = await git('rev-parse', '--abbrev-ref', 'HEAD') const base = repo.base
const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '')
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}` const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
await git('fetch', 'origin', base) await git('checkout', '-B', branch, '--no-track', `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`) for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`)
await git('add', 'characters') await git('add', 'characters')
await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`) await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`)
await git('push', '-u', 'origin', branch) await git('push', '-u', '--force-with-lease', 'origin', branch)
await git('switch', prev) await git('checkout', '--detach', `origin/${base}`)
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包` const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
const body = [ const body = [
@@ -173,17 +262,21 @@ export async function runExtract($: Engine, args: string): Promise<string> {
if (head === 'cancel') { if (head === 'cancel') {
await $.store.delete(PENDING_KEY) await $.store.delete(PENDING_KEY)
await $.store.delete(QUEUE_KEY)
return '已取消,沒有寫入任何檔案。' return '已取消,沒有寫入任何檔案。'
} }
if (head === 'paste') { if (head === 'paste') {
// 貼上:第一行 paste <章節名稱>,下一行起為章節內容。 // 貼上:第一行 paste <章節名稱>,下一行起為章節內容。
const [first = '', ...body] = args.trim().split('\n') const [first = '', ...body] = args.trim().replace(/\r\n?/g, '\n').split('\n')
const name = first.replace(/^paste\s*/, '').trim() const name = first.replace(/^paste\s*/, '').trim()
const text = body.join('\n') const text = body.join('\n')
if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。` if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。`
return stage($, name, text) return stage($, name, text)
} }
if (head !== 'confirm') return stageFile($, args.trim()) if (head !== 'confirm') {
const src = parseWikiSource(args)
return src ? stageSource($, args, src) : stageFile($, args.trim())
}
const p = (await $.store.get(PENDING_KEY)) as Pending | undefined const p = (await $.store.get(PENDING_KEY)) as Pending | undefined
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。` if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
@@ -210,5 +303,5 @@ export async function runExtract($: Engine, args: string): Promise<string> {
const plan = p.plan! const plan = p.plan!
await $.store.delete(PENDING_KEY) await $.store.delete(PENDING_KEY)
return openPr($, p, plan) return nextChapter($, await openPr($, p, plan))
} }
+76
View File
@@ -0,0 +1,76 @@
import { expect, mock, test } from 'claude-code/testing'
import { CHARACTERS } from './characters'
const setup = (on: any, active?: string) => {
mock.store(on, active ? { active } : undefined)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
}
const compose = ($: any) => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
const persona = async ($: any): Promise<string | undefined> => (await compose($)).sections.find((s: any) => s.id === 'jsc-anima:persona')?.text
const start = ($: any) => $.session.start({ source: 'startup', cwd: '/tmp' } as never)
test('啟動狀態跨 session 保留:下次開啟自動恢復', async ($, on) => {
setup(on, 'sao-suguha')
await start($)
expect(await persona($)).toContain('直葉')
})
test('啟動角色後寫入儲存,新 session 恢復;離開後不再恢復', async ($, on) => {
setup(on)
await start($)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
await $.command.run({ command: 'anima-off', args: '' } as never)
await $.command.run({ command: 'sao-suguha', args: '' } as never)
await start($)
expect(await persona($)).toContain('直葉')
await $.command.run({ command: 'anima-off', args: '' } as never)
await start($)
expect(await persona($)).toBeUndefined()
})
test('在 /config 關閉 persist 後不恢復', { options: { persist: false } }, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toBeUndefined()
})
test('儲存的角色不存在時不恢復', async ($, on) => {
setup(on, 'no-such-character')
await start($)
expect(await persona($)).toBeUndefined()
})
test('微調數值軸:反映在 persona,其餘維持角色包預設,且不改動角色包', { options: { axisGentle: 2, axisHumor: 9 } }, async ($, on) => {
setup(on, 'sao-asuna')
const before = { ...CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes }
await start($)
const text = (await persona($))!
expect(text).toContain('溫柔 2')
expect(text).toContain('幽默 9')
expect(text).toContain(`傲嬌 ${before.tsundere}`)
expect(CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes).toEqual(before)
})
test('未微調(-1)沿用角色包預設', { options: { axisGentle: -1 } }, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toContain(`溫柔 ${CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes.gentle}`)
})
test('8 個數值軸都可微調', {
options: { axisGentle: 1, axisTsundere: 2, axisLively: 3, axisFormal: 4, axisTalkative: 5, axisHumor: 6, axisProactive: 7, axisExpressive: 8 },
}, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toContain('溫柔 1、傲嬌 2、活潑 3、正式 4、話量 5、幽默 6、主動 7、情緒外顯 8')
})
test('超出 0-10 的覆蓋值被忽略', { options: { axisGentle: 11 } }, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toContain(`溫柔 ${CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes.gentle}`)
})
+15 -13
View File
@@ -6,7 +6,7 @@ import type { Axes } from './characters'
import { EXTRACT_COMMAND, runExtract } from './extractCommand' import { EXTRACT_COMMAND, runExtract } from './extractCommand'
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity' import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity'
import type { Affinity } from './affinity' import type { Affinity } from './affinity'
import { relationOf, relationText } from './relation' import { latestEntry, relationOf, relationText } from './relation'
import { CLASSIFY_PROMPT, choose, parseRoute } from './router' import { CLASSIFY_PROMPT, choose, parseRoute } from './router'
import type { Effort, ModelName, Route } from './router' import type { Effort, ModelName, Route } from './router'
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite' import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
@@ -16,10 +16,9 @@ import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog,
import type { Entry } from './wiki' import type { Entry } from './wiki'
import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo' import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo'
import type { Todo } from './todo' import type { Todo } from './todo'
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool' import { NEED_ACTIVE, OFF_COMMAND, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
const ACTIVE_KEY = 'active' const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const AFFINITY_COMMAND = 'anima-affinity' const AFFINITY_COMMAND = 'anima-affinity'
const RAW_LOG_KEY = 'rawlog' const RAW_LOG_KEY = 'rawlog'
const RAW_LOG_MAX = 200 const RAW_LOG_MAX = 200
@@ -312,8 +311,7 @@ export const register: Register = (on, options) => {
await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` }) await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
} }
await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' }) await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' }) 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) const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
if (active && !toolRegistered) { if (active && !toolRegistered) {
@@ -331,6 +329,7 @@ export const register: Register = (on, options) => {
if (!isEnabled) return next(e) if (!isEnabled) return next(e)
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) } if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
if (e.command === OFF_COMMAND) { if (e.command === OFF_COMMAND) {
if (!findCharacter(active)) return { text: NEED_ACTIVE }
active = undefined active = undefined
await $.store.delete(ACTIVE_KEY) await $.store.delete(ACTIVE_KEY)
return { text: '已離開角色,回到一般模式。' } return { text: '已離開角色,回到一般模式。' }
@@ -343,18 +342,21 @@ export const register: Register = (on, options) => {
return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE } return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE }
} }
if (e.command === AFFINITY_COMMAND) { if (e.command === AFFINITY_COMMAND) {
const c = findCharacter(active) // 第一個字若是角色代號就指定該角色;否則用目前啟動的角色。
if (!c) return { text: '請先啟動角色。' } const parts = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
const arg = String(e.args ?? '').trim() const named = parts.length ? findCharacter(parts[0]) : undefined
const c = named ?? findCharacter(active)
if (!c) return { text: '未啟動角色時請指定角色代號:anima-affinity <角色代號> [reset|0-100]。' }
const arg = (named ? parts.slice(1) : parts).join(' ')
const cur = await loadAffinity($, c.slug) const cur = await loadAffinity($, c.slug)
if (arg === 'reset') { if (arg === 'reset') {
await $.store.set(affinityKey(c.slug), initialAffinity()) await $.store.set(affinityKey(c.slug), initialAffinity())
return { text: '好感度已重置為 0。' } return { text: `${c.name}的好感度已重置為 0。` }
} }
const n = Number(arg) const n = Number(arg)
if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `目前好感度 ${cur.score}(${stageName(cur.score)})。用法:reset 或 0-100 的數字。` } if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `${c.name}目前好感度 ${cur.score}(${stageName(cur.score)})。用法:[角色代號] reset 或 0-100 的數字。` }
await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) }) await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) })
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` } return { text: `${c.name}的好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
} }
const c = findCharacter(e.command) const c = findCharacter(e.command)
if (!c) { if (!c) {
@@ -383,9 +385,9 @@ export const register: Register = (on, options) => {
let text = personaSection(c, applyOverrides(c.axes, overrides())) let text = personaSection(c, applyOverrides(c.axes, overrides()))
if (flag('affinity', true)) { if (flag('affinity', true)) {
const a = await loadAffinity($, c.slug) const a = await loadAffinity($, c.slug)
text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)))}` text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)), latestEntry(c.timeline)?.romance)}`
} else { } else {
text += `\n${relationText(relationOf(c.timeline, 0, false))}` text += `\n${relationText(relationOf(c.timeline, 0, false), latestEntry(c.timeline)?.romance)}`
} }
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。' if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
return { return {
+196 -4
View File
@@ -1,11 +1,15 @@
import { expect, mock, test } from 'claude-code/testing' import { expect, mock, test } from 'claude-code/testing'
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[] } type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[]; clock?: any }
const setup = (on: any): Ctl => { const setup = (on: any, kv?: Map<string, unknown>): Ctl => {
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] } const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] }
mock.store(on) if (kv) {
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') }) // 自備 store 以便檢查寫入內容。
on('store.get', (_$: any, e: any) => ({ value: kv.get(e.key) }) as never)
on('store.set', (_$: any, e: any) => (kv.set(e.key, e.value), { value: undefined }) as never)
} else mock.store(on)
ctl.clock = mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
on('ui.log', () => ({ value: undefined }) as never) on('ui.log', () => ({ value: undefined }) as never)
on('command.register', () => ({ value: { name: 'x' } }) as never) on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never) on('session.start', () => ({ cwd: '/tmp' }) as never)
@@ -92,6 +96,96 @@ test('改寫失敗或遺失佔位符時顯示原文', async ($, on) => {
expect(r2.message.content[0].text).toBe('原文 `y` 5') expect(r2.message.content[0].text).toBe('原文 `y` 5')
}) })
test('改寫模型:長回覆用 Sonnet,一般回覆用 Haiku', async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), { isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('短回覆'))
await $.session.append(row('長'.repeat(1300)))
expect(asked).toEqual(['haiku', 'sonnet'])
})
test('longReply 可調整長回覆門檻', { options: { longReply: 10 } }, async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), { isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('十一個字的回覆內容喔喔'))
await $.session.append(row('短'))
expect(asked).toEqual(['sonnet', 'haiku'])
})
test('改寫逾時、額度不足或拋錯時直接顯示原文', async ($, on) => {
const ctl = setup(on)
await start($)
ctl.complete = () => ({ isAnswered: false, reason: 'timeout' })
const r1: any = await $.session.append(row('逾時 1'))
expect(r1.message.content[0].text).toBe('亞絲娜 > 逾時 1')
ctl.complete = () => ({ isAnswered: false, reason: 'rate_limited' })
const r2: any = await $.session.append(row('額度 2'))
expect(r2.message.content[0].text).toBe('額度 2')
ctl.complete = () => { throw new Error('quota') }
const r3: any = await $.session.append(row('拋錯 3'))
expect(r3.message.content[0].text).toBe('拋錯 3')
})
test('改寫逾時設定傳給模型呼叫', { options: { rewriteTimeoutSec: 5 } }, async ($, on) => {
const ctl = setup(on)
let timeout = 0
ctl.complete = e => (timeout = e.timeoutMs, { isAnswered: false, reason: 'timeout' })
await start($)
await $.session.append(row('x'))
expect(timeout).toBe(5000)
})
const rawLog = (kv: Map<string, unknown>): string[] => ((kv.get('rawlog') as { raw: string }[] | undefined) ?? []).map(x => x.raw)
test('原文 log 預設寫入,改寫失敗時不寫', async ($, on) => {
const kv = new Map<string, unknown>()
const ctl = setup(on, kv)
await start($)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await $.session.append(row('原文甲'))
ctl.complete = () => ({ isAnswered: false, reason: 'timeout' })
await $.session.append(row('原文乙'))
expect(rawLog(kv)).toEqual(['原文甲'])
})
test('在 /config 關閉原文 log 時不寫入', { options: { rawLog: false } }, async ($, on) => {
const kv = new Map<string, unknown>()
const ctl = setup(on, kv)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
const r: any = await $.session.append(row('原文丙'))
expect(r.message.content[0].text).toBe('亞絲娜 > 原文丙')
expect(rawLog(kv)).toEqual([])
})
test('前綴只加在整則回覆開頭一次,多段文字區塊與多次寫入都不重複', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
const multi = { message: { type: 'assistant', role: 'assistant', content: [{ type: 'text', text: '第一段' }, { type: 'tool_use', id: 't', name: 'x', input: {} }, { type: 'text', text: '第二段' }] }, door: 'response', origin: { kind: 'model', model: 'm' }, uuid: 'u' } as never
const r: any = await $.session.append(multi)
expect(r.message.content[0].text).toBe('亞絲娜 > 第一段')
expect(r.message.content[2].text).toBe('第二段')
const r2: any = await $.session.append(row('同一回覆後續'))
expect(r2.message.content[0].text).toBe('同一回覆後續')
})
test('新的使用者輸入後,下一則回覆重新加前綴', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('第一則'))
ctl.complete = () => route('haiku')
await submit($, '再問一次')
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
const r: any = await $.session.append(row('第二則'))
expect(r.message.content[0].text).toBe('亞絲娜 > 第二則')
})
test('好感度:由 0 開始,每日首次互動加分且有每日上限', async ($, on) => { test('好感度:由 0 開始,每日首次互動加分且有每日上限', async ($, on) => {
const ctl = setup(on) const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise') ctl.complete = () => route('haiku', 'praise')
@@ -143,6 +237,104 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依
expect((await personaText($)).includes('關係定位:友情')).toBe(true) expect((await personaText($)).includes('關係定位:友情')).toBe(true)
}) })
// 以 fs 事件邊界提供角色包(characters/index.json),讓外掛自行載入成年角色。
const packs = (on: any, list: { slug: string; romance?: string }[]) => {
const files = new Map<string, string>()
files.set('index.json', JSON.stringify({ packs: list.map(p => ({ work: 'tw', workName: '測試作品', slug: p.slug })) }))
for (const p of list) {
files.set(
`tw/${p.slug}.json`,
JSON.stringify({
work: 'tw',
slug: p.slug,
name: '成年測試',
persona: { selfRef: '我', address: '你', tone: '平穩', catchphrase: '好', values: '可靠', taboos: '無' },
axes: { gentle: 5, tsundere: 5, lively: 5, formal: 5, talkative: 5, humor: 5, proactive: 5, expressive: 5 },
anime: { moe: '', speech: '', emotions: '', kaomoji: '', themeColor: 'cyan' },
timeline: [
{ chapter: '第1章', age: 17, relationships: [] },
{ chapter: '第2章', age: 20, relationships: p.romance ? [{ with: p.romance, kind: 'romantic' }] : [] },
],
}),
)
}
const key = (path: string) => path.replace(/^.*\/characters\//, '')
on('fs.exists', (_$: any, e: any) => ({ value: files.has(key(e.path)) }) as never)
on('fs.read', (_$: any, e: any) => ({ value: files.get(key(e.path)) ?? '' }) as never)
}
test('成年且原作有戀愛關係:關係移轉給使用者,原戀人退為過去的重要人物', async ($, on) => {
setup(on)
packs(on, [{ slug: 'lover', romance: '桐人' }])
await start($, 'tw-lover')
const t = await personaText($)
expect(t.includes('已移轉給使用者')).toBe(true)
expect(t.includes('桐人')).toBe(true)
expect(t.includes('過去的重要人物')).toBe(true)
})
test('成年但原作無戀愛關係:需最高階段且明確開啟才進入戀愛式依賴', async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
})
test('成年無戀愛、最高階段但未在 /config 開啟:維持夥伴關係', { options: { romance: false } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('夥伴與朋友')).toBe(true)
})
test('成年無戀愛、已開啟但好感度未達最高階段:不進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '79' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
})
test('成年無戀愛、最高階段且已開啟:進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '80' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(true)
})
test('長期不互動好感度緩慢下降,但不跨階段掉落', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku')
await start($)
await submit($, '你好')
await $.command.run({ command: 'anima-affinity', args: '45' } as never)
await ctl.clock.advance(30 * 86_400_000)
await submit($, '我回來了')
const n = await affinity($)
expect(n < 45).toBe(true)
expect(n >= 40).toBe(true)
})
test('個性軸影響扣分倍率:傲嬌角色掉得比溫柔角色多', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku')
packs(on, [{ slug: 'plain' }])
const run = async (slug: string) => {
await start($, slug)
await submit($, '你好')
await $.command.run({ command: 'anima-affinity', args: '70' } as never)
await ctl.clock.advance(8 * 86_400_000)
await submit($, '我回來了')
return affinity($)
}
const proud = await run('tw-plain')
const calm = await run('sao-suguha')
expect(proud < calm).toBe(true)
})
const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` }) const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` })
test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => { test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => {
+1 -1
View File
@@ -62,7 +62,7 @@ export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
const USAGE_SECTIONS = [ const USAGE_SECTIONS = [
'## 角色與萃取', '## 角色與萃取',
'', '',
'- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。', '- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。',
'- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。', '- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。',
'', '',
'## 待辦事項', '## 待辦事項',
+2
View File
@@ -7,10 +7,12 @@ import {
} from './skillgen' } from './skillgen'
export const TOOL_COMMAND = 'anima-skill' export const TOOL_COMMAND = 'anima-skill'
export const OFF_COMMAND = 'anima-off'
export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。' export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
export async function registerTools($: Engine): Promise<void> { export async function registerTools($: Engine): Promise<void> {
await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' }) await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' })
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' }) await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' })
} }
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by Vitest.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1 @@
{"version":1,"projects":{"":{"files":{"hooks/anima.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/extract.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/persist.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/routing.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/skilltool.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/todo.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/wiki.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665}}}}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by Vitest.
# For information about cache directory tags see https://bford.info/cachedir/
+3
View File
@@ -0,0 +1,3 @@
{
"lockfileHash": "e3b0c442"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -17,7 +17,7 @@
## 角色與萃取 ## 角色與萃取
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。 - `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。
- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。 - `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。
## 待辦事項 ## 待辦事項