Compare commits

...
8 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
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
19 changed files with 1951 additions and 18 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、系統資源)。
+58
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] =>
@@ -21,6 +22,7 @@ let cloneMissing = false
let registered: string[] = [] let registered: string[] = []
const probed = new Set<string>() const probed = new Set<string>()
let sessionCwd = '/repo' 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
@@ -39,6 +41,10 @@ const setup = (on: never, answers: string[], files = new Map<string, string>()):
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(' ')
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' : '' 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)
@@ -182,3 +188,55 @@ test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {
await $.session.start({ source: 'startup', cwd: '/anywhere' } as never) await $.session.start({ source: 'startup', cwd: '/anywhere' } as never)
expect(registered).toContain('ts-aoi') 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 }))
}
+62 -4
View File
@@ -1,14 +1,15 @@
import type { Engine } from 'claude-code' import type { Engine } from 'claude-code'
import { teaToken } from './wiki' import { teaToken } from './wiki'
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract' 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 讀寫角色包。 // PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。
export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' 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 }[]
@@ -18,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
@@ -75,6 +78,57 @@ async function syncRepo($: Engine): Promise<Repo | string> {
} }
} }
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}`
@@ -208,6 +262,7 @@ 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') {
@@ -218,7 +273,10 @@ export async function runExtract($: Engine, args: string): Promise<string> {
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} <章節檔案路徑>。`
@@ -245,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))
} }
+12 -10
View File
@@ -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) {
+96 -2
View File
@@ -2,9 +2,13 @@ import { expect, mock, test } from 'claude-code/testing'
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[]; clock?: any } 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) {
// 自備 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') }) 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)
@@ -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')
+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