Compare commits

..
5 Commits
18 changed files with 1855 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-anima",
"version": "0.0.2",
"version": "0.0.5",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
"license": "MIT",
"userConfig": {
+1 -1
View File
@@ -2,7 +2,7 @@
# jsc-anima
目前版本:0.0.2
目前版本:0.0.5
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
+58
View File
@@ -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('已取消')
})
+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)?$/)
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 { 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
View File
@@ -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) {
+2
View File
@@ -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
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