Compare commits
8
Commits
09fd67de45
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa41b6cb64 | ||
|
|
74a5d4949c | ||
|
|
1ce475024d | ||
|
|
20fc0a9c6c | ||
|
|
f112cf48f1 | ||
|
|
dd930160c5 | ||
|
|
8554b804c0 | ||
|
|
535c5b1437 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-anima",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.5",
|
||||
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
|
||||
"license": "MIT",
|
||||
"userConfig": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# jsc-anima
|
||||
|
||||
目前版本:0.0.2
|
||||
目前版本:0.0.5
|
||||
|
||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { parseWikiPages, parseWikiSource } from './extract'
|
||||
import type { Extracted } from './extract'
|
||||
|
||||
const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] =>
|
||||
@@ -21,6 +22,7 @@ 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 s: Setup = { files, cmds: [], prompts: [] }
|
||||
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[]; }) => {
|
||||
s.cmds.push(e.argv)
|
||||
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' : ''
|
||||
return { value: { exitCode: 0, stdout: out, stderr: '' } }
|
||||
}) as never)
|
||||
@@ -182,3 +188,55 @@ test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {
|
||||
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('已取消')
|
||||
})
|
||||
|
||||
@@ -228,3 +228,50 @@ export const parseRemote = (url: string): { host: string; repo: string } | undef
|
||||
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/)
|
||||
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(/&/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
@@ -1,14 +1,15 @@
|
||||
import type { Engine } from 'claude-code'
|
||||
|
||||
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'
|
||||
|
||||
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 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 = {
|
||||
files: { path: string; json: unknown }[]
|
||||
@@ -18,6 +19,8 @@ type Plan = {
|
||||
workName: string
|
||||
}
|
||||
|
||||
type Queue = { base: string; pages: string[] }
|
||||
|
||||
type Pending = {
|
||||
phase: 'slug' | 'summary'
|
||||
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> {
|
||||
const cwd = await $.session.cwd().catch(() => '')
|
||||
const full = path.startsWith('/') ? path : `${cwd}/${path}`
|
||||
@@ -208,6 +262,7 @@ export async function runExtract($: Engine, args: string): Promise<string> {
|
||||
|
||||
if (head === 'cancel') {
|
||||
await $.store.delete(PENDING_KEY)
|
||||
await $.store.delete(QUEUE_KEY)
|
||||
return '已取消,沒有寫入任何檔案。'
|
||||
}
|
||||
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 <章節名稱>,下一行起貼上章節內容。`
|
||||
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
|
||||
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
|
||||
@@ -245,5 +303,5 @@ export async function runExtract($: Engine, args: string): Promise<string> {
|
||||
|
||||
const plan = p.plan!
|
||||
await $.store.delete(PENDING_KEY)
|
||||
return openPr($, p, plan)
|
||||
return nextChapter($, await openPr($, p, plan))
|
||||
}
|
||||
|
||||
+12
-10
@@ -16,10 +16,9 @@ import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog,
|
||||
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 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 OFF_COMMAND = 'anima-off'
|
||||
const AFFINITY_COMMAND = 'anima-affinity'
|
||||
const RAW_LOG_KEY = 'rawlog'
|
||||
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: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
|
||||
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' })
|
||||
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
|
||||
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)好感度;未啟動時需指定角色代號' })
|
||||
const saved = await $.store.get(ACTIVE_KEY)
|
||||
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
|
||||
if (active && !toolRegistered) {
|
||||
@@ -331,6 +329,7 @@ export const register: Register = (on, options) => {
|
||||
if (!isEnabled) return next(e)
|
||||
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
|
||||
if (e.command === OFF_COMMAND) {
|
||||
if (!findCharacter(active)) return { text: NEED_ACTIVE }
|
||||
active = undefined
|
||||
await $.store.delete(ACTIVE_KEY)
|
||||
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 }
|
||||
}
|
||||
if (e.command === AFFINITY_COMMAND) {
|
||||
const c = findCharacter(active)
|
||||
if (!c) return { text: '請先啟動角色。' }
|
||||
const arg = String(e.args ?? '').trim()
|
||||
// 第一個字若是角色代號就指定該角色;否則用目前啟動的角色。
|
||||
const parts = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
|
||||
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)
|
||||
if (arg === 'reset') {
|
||||
await $.store.set(affinityKey(c.slug), initialAffinity())
|
||||
return { text: '好感度已重置為 0。' }
|
||||
return { text: `${c.name}的好感度已重置為 0。` }
|
||||
}
|
||||
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) })
|
||||
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
||||
return { text: `${c.name}的好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
||||
}
|
||||
const c = findCharacter(e.command)
|
||||
if (!c) {
|
||||
|
||||
+96
-2
@@ -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 }
|
||||
|
||||
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: [] }
|
||||
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') })
|
||||
on('ui.log', () => ({ value: undefined }) 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')
|
||||
})
|
||||
|
||||
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) => {
|
||||
const ctl = setup(on)
|
||||
ctl.complete = () => route('haiku', 'praise')
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from './skillgen'
|
||||
|
||||
export const TOOL_COMMAND = 'anima-skill'
|
||||
export const OFF_COMMAND = 'anima-off'
|
||||
export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
|
||||
|
||||
export async function registerTools($: Engine): Promise<void> {
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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/
|
||||
+1
@@ -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}}}}}
|
||||
+154
File diff suppressed because one or more lines are too long
+558
File diff suppressed because one or more lines are too long
+303
File diff suppressed because one or more lines are too long
+203
File diff suppressed because one or more lines are too long
+3
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"lockfileHash": "e3b0c442"
|
||||
}
|
||||
+190
File diff suppressed because one or more lines are too long
+104
File diff suppressed because one or more lines are too long
+150
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user