Merge pull request 'feat: companion 狀態列與 info 輸出對齊設計稿' (#55) from feat/companion-info-design into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/55
This commit was merged in pull request #55.
This commit is contained in:
@@ -10,7 +10,7 @@ JSC 自用 Claude Code mods 的 marketplace。每個插件皆可單獨安裝、
|
||||
|
||||
輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟。
|
||||
|
||||
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.3`
|
||||
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.4`
|
||||
|
||||
小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-mod-companion",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、作息與主動關心、外部素材與待辦,並可用 ingest skill 解析小說建立角色",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
### `/jsc-mod-companion:info`
|
||||
|
||||
查看目前角色、心情、關係階段與四維度(好感、信賴、親密、依賴)與待辦數量。
|
||||
預設是短版:名字與主情緒、關係階段(好感)、前 3 強情緒、適應度、待辦數。
|
||||
|
||||
查看目前角色、心情、好感與待辦數量。情緒有 8 個維度(心情、精力、害羞、忌妒、想念、心跳、想家、不悅),預設列出前 3 強,`--all` 顯示完整數值(▰▱ 量表依維度上色);band 上的情緒名稱為目前最強的主情緒。
|
||||
`--all` 顯示完整數值:時間感與天氣、8 維度情緒(心情、精力、害羞、忌妒、想念、心跳、想家、不悅,12 格 ▰▱ 量表依維度上色)、關係四維度(好感、信賴、親密、依賴)長條、人格 18 參數(基準、存檔偏移、今日偏移與有效值)、世界(適應度、想家)與角色基本資訊。
|
||||
|
||||
輸入框上方的狀態列顯示最強的兩種情緒(不含精力)與關係階段;寬度不夠時依序捨棄氣泡、待辦、情緒條、階段。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:info
|
||||
|
||||
@@ -7,6 +7,7 @@ import { lineWidth } from './seg'
|
||||
const VIEW: View = {
|
||||
core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } },
|
||||
mood: { p: 0.5, a: 0, d: -0.5 },
|
||||
emotions: { mood: 0.75, energy: 0.5, shy: 0.4, jealous: 0, missing: 0.1, heartbeat: 0, homesick: 0, upset: 0 },
|
||||
label: '放鬆',
|
||||
kaomoji: '(´ᴗ`)',
|
||||
relation: { affection: 35, trust: 20, intimacy: 10, dependence: 5 },
|
||||
@@ -17,8 +18,8 @@ const VIEW: View = {
|
||||
const text = (v: View, cols: number, compact = false, bubble?: string) => fitCompanion(v, cols, compact, bubble).map(s => s.text).join('')
|
||||
|
||||
describe('角色行', () => {
|
||||
test('寬版顯示顏文字、名字、心情、PAD 條、階段、待辦、氣泡', () => {
|
||||
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟人 · ☐2 · 💬 早安')
|
||||
test('寬版顯示顏文字、名字、心情、情緒條、階段、待辦、氣泡', () => {
|
||||
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · 心情▰▰▰▱ 害羞▰▰▱▱ · 熟人 · ☐2 · 💬 早安')
|
||||
})
|
||||
|
||||
test('緊湊模式只剩「顏文字 名字 心情」', () => {
|
||||
@@ -36,7 +37,7 @@ describe('角色行', () => {
|
||||
}
|
||||
expect(text(VIEW, 40, false, '早安')).not.toContain('早安')
|
||||
expect(text(VIEW, 36, false, '早安')).not.toContain('☐')
|
||||
expect(text(VIEW, 30, false, '早安')).not.toContain('PAD')
|
||||
expect(text(VIEW, 30, false, '早安')).not.toContain('心情▰')
|
||||
expect(text(VIEW, 22, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆')
|
||||
expect(lineWidth(fitCompanion(VIEW, 24))).toBeLessThanOrEqual(24)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DIM_INFO, ranked } from './emotion'
|
||||
import { bar, lineWidth } from './seg'
|
||||
import type { Seg } from './seg'
|
||||
import type { View } from './core'
|
||||
|
||||
const axis = (v: number) => bar(((v + 1) / 2) * 100, 3)
|
||||
// 精力隨作息起伏,不列入主要情緒;取最強的兩種
|
||||
const top2 = (v: View) => ranked(v.emotions).filter(d => d !== 'energy').slice(0, 2)
|
||||
|
||||
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → PAD 條 → 好感階段;緊湊版只留「顏文字 名字 心情」
|
||||
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → 情緒條 → 好感階段;緊湊版只留「顏文字 名字 心情」
|
||||
function line(v: View, level: number, compact: boolean, bubble?: string): Seg[] {
|
||||
const out: Seg[] = [
|
||||
{ text: `${v.kaomoji} `, color: 'claude' },
|
||||
@@ -14,7 +16,7 @@ function line(v: View, level: number, compact: boolean, bubble?: string): Seg[]
|
||||
]
|
||||
if (compact) return out
|
||||
const dot = { text: ' · ', dim: true }
|
||||
if (level < 3) out.push(dot, { text: `P${axis(v.mood.p)} A${axis(v.mood.a)} D${axis(v.mood.d)}`, dim: true })
|
||||
if (level < 3) out.push(dot, { text: top2(v).map(d => `${DIM_INFO[d].name}${bar(v.emotions[d] * 100, 4)}`).join(' '), dim: true })
|
||||
if (level < 4) out.push(dot, { text: v.stage })
|
||||
if (level < 2 && v.todos > 0) out.push(dot, { text: `☐${v.todos}`, color: 'warning' })
|
||||
if (level < 1 && bubble) out.push(dot, { text: `💬 ${bubble}`, dim: true })
|
||||
|
||||
@@ -285,10 +285,10 @@ describe('舊 affinity 存檔升級', () => {
|
||||
})
|
||||
|
||||
describe('指令與設定', () => {
|
||||
test('status 展開事實欄位與來源,查不到為 unknown', async () => {
|
||||
test('info --all 展開事實欄位與來源,查不到為 unknown', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const out = await c.command('status')
|
||||
const out = await c.command('status --all')
|
||||
expect(out).toContain('月見ミコ')
|
||||
expect(out).toContain('birthday:3/3(novel,信心 1,test)')
|
||||
expect(out).toContain('bloodType:unknown')
|
||||
@@ -310,7 +310,7 @@ describe('指令與設定', () => {
|
||||
const fs = memoryFs()
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
|
||||
await c.ensureActivated()
|
||||
const out = await c.command('status')
|
||||
const out = await c.command('status --all')
|
||||
expect(out).toContain('白上吹雪')
|
||||
expect(out).toContain('hololivepro.com')
|
||||
expect(out).toContain('weight:unknown')
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { LegacyAffinity } from './relation'
|
||||
import { FUBUKI } from './defaults'
|
||||
import { readJson, writeJson } from './fs'
|
||||
import type { Clock, Fs } from './fs'
|
||||
import { applyEvent, baselineOf, clampDim, decayEmotions, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
||||
import { applyEvent, baselineOf, clampDim, decayEmotions, DIM_INFO, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
||||
import type { Emotions } from './emotion'
|
||||
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
|
||||
import type { MoodEvent } from './mood'
|
||||
import { extraversionOf, neuroticismOf } from './core/params'
|
||||
import { extraversionOf, neuroticismOf, PARAM_LABELS, PARAMS, value } from './core/params'
|
||||
import type { ParamLayers } from './core/params'
|
||||
import { bar, width } from './seg'
|
||||
import { extractTag, stripTag } from './tags'
|
||||
import { createCare, IDLE_MS } from './care'
|
||||
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
|
||||
@@ -53,6 +55,8 @@ export type View = {
|
||||
period: string
|
||||
/** 快取中仍有效的天氣標題;未啟用或過期為 null */
|
||||
weather: string | null
|
||||
/** 人格 18 參數的三層值;角色包沒有 params 時為 null */
|
||||
layers: ParamLayers | null
|
||||
}
|
||||
|
||||
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
|
||||
@@ -164,6 +168,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
return {
|
||||
core, mood, emotions, ...(main ?? moodLabel(mood)), relation: effective(rel), stage: STAGES[rel.stage]!, todos,
|
||||
adapt: adaptOf(core, st, now, tzMinutes(env.user.tz)).value, absentMs, period: periodOf(now, tzMinutes(env.user.tz)), weather: env.mats.weather?.title ?? null,
|
||||
layers: core.params ? { base: core.params, save: st.params?.save ?? {}, today: st.params?.today ?? {} } : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,22 +198,40 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
await mutate((core, st, now) => ({ ...st, relation: interact(st.relation, kind, now, core.relationTraits) }))
|
||||
}
|
||||
|
||||
const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2)
|
||||
|
||||
async function status(all = false): Promise<string> {
|
||||
const v = await view()
|
||||
if (!v) return '目前沒有啟用角色。'
|
||||
const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`]
|
||||
lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`)
|
||||
const dims = ranked(v.emotions)
|
||||
lines.push(all ? '情緒(完整):' : '情緒(前 3 強,/jsc-mod-companion:info --all 看完整):')
|
||||
for (const d of all ? dims : dims.slice(0, 3)) lines.push(` ${gauge(d, v.emotions)}`)
|
||||
lines.push(`好感:${v.stage}(${v.relation.affection.toFixed(1)})`)
|
||||
lines.push(`關係:好感 ${v.relation.affection.toFixed(1)} 信賴 ${v.relation.trust.toFixed(1)} 親密 ${v.relation.intimacy.toFixed(1)} 依賴 ${v.relation.dependence.toFixed(1)}`)
|
||||
lines.push(`適應度:${Math.round(v.adapt)}(${describeAdapt(v.adapt)})`)
|
||||
lines.push(`時間感:現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`)
|
||||
lines.push(`待辦:${v.todos}`)
|
||||
lines.push('基本資訊:')
|
||||
const head = `${v.core.name} ${v.kaomoji} ${v.label}`
|
||||
if (!all) {
|
||||
return [
|
||||
head,
|
||||
`關係 ${v.stage}(好感 ${Math.round(v.relation.affection)})`,
|
||||
`情緒 ${dims.slice(0, 3).map(d => `${DIM_INFO[d].name} ${Math.round(v.emotions[d] * 100)}`).join('、')}`,
|
||||
`適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}) 待辦 ${v.todos} 件`,
|
||||
'加 --all 看完整數值',
|
||||
].join('\n')
|
||||
}
|
||||
const rel = Object.entries({ 好感: v.relation.affection, 信賴: v.relation.trust, 親密: v.relation.intimacy, 依賴: v.relation.dependence })
|
||||
const lines = [
|
||||
`${head} 現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`,
|
||||
'',
|
||||
'情緒(8 維度)',
|
||||
...dims.map(d => ` ${gauge(d, v.emotions)}`),
|
||||
'',
|
||||
`關係(${v.stage})`,
|
||||
...rel.map(([k, x]) => ` ${k} ${bar(x, 12)} ${String(Math.round(x)).padStart(3)}`),
|
||||
'',
|
||||
`人格(${PARAMS.length} 參數:基準 + 存檔偏移 + 今日偏移)`,
|
||||
]
|
||||
if (v.layers) {
|
||||
const sign = (n: number) => (n >= 0 ? '+' : '') + Math.round(n)
|
||||
for (const p of PARAMS) {
|
||||
const l = v.layers
|
||||
lines.push(` ${PARAM_LABELS[p]}${' '.repeat(Math.max(1, 20 - width(PARAM_LABELS[p])))}基準 ${Math.round(l.base[p])} 偏移 ${sign(l.save[p] ?? 0)} 今日 ${sign(l.today[p] ?? 0)} 有效 ${Math.round(value(l, p))}`)
|
||||
}
|
||||
} else lines.push(' (此角色包沒有人格參數)')
|
||||
lines.push('', '世界', ` 適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}) 想家 ${Math.round(v.emotions.homesick * 100)}`, '', '基本資訊')
|
||||
for (const [k, f] of Object.entries(v.core.facts)) {
|
||||
lines.push(f === 'unknown' ? ` ${k}:unknown` : ` ${k}:${f.value}(${f.kind ?? '?'},信心 ${f.confidence ?? '?'},${f.source ?? '無來源'})`)
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ describe('/status', () => {
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] })
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('scold')
|
||||
const brief = (await c.command('status')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
|
||||
expect(brief).toHaveLength(3)
|
||||
const brief = (await c.command('status')).split('\n').find(l => l.startsWith('情緒 '))!
|
||||
expect(brief.slice(4).split('、')).toHaveLength(3)
|
||||
const all = (await c.command('status --all')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
|
||||
expect(all).toHaveLength(8)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clamp, MOOD_EVENTS } from './mood'
|
||||
import type { MoodEvent } from './mood'
|
||||
import { bar } from './seg'
|
||||
import { bar, width } from './seg'
|
||||
import type { Pad } from './types'
|
||||
|
||||
const HOUR = 3_600_000
|
||||
@@ -172,9 +172,8 @@ export function dominant(e: Emotions): { dim: Dim; label: string; kaomoji: strin
|
||||
return { dim, label: poles ? poles[e[dim] >= 0.5 ? 1 : 0] : DIM_INFO[dim].name, kaomoji: DIM_INFO[dim].kaomoji }
|
||||
}
|
||||
|
||||
/** /status 的情緒量表:▰▱ 依維度上色 */
|
||||
export function gauge(d: Dim, e: Emotions): string {
|
||||
const poles = POLES[d]
|
||||
const name = poles ? `${DIM_INFO[d].name}(${poles[e[d] >= 0.5 ? 1 : 0]})` : DIM_INFO[d].name
|
||||
return `${name} \x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100)}\x1b[0m ${Math.round(e[d] * 100)}`
|
||||
/** info --all 的情緒量表:▰▱ 依維度上色,名稱補齊到同寬 */
|
||||
export function gauge(d: Dim, e: Emotions, cells = 12): string {
|
||||
const name = DIM_INFO[d].name
|
||||
return `${name}${' '.repeat(Math.max(0, 6 - width(name)))}\x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100, cells)}\x1b[0m ${String(Math.round(e[d] * 100)).padStart(3)}`
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('適應度', () => {
|
||||
const v = (await c.view())!
|
||||
expect(v.adapt).toBeGreaterThan(15)
|
||||
expect(v.adapt).toBeLessThan(25)
|
||||
expect((await c.command('status'))).toContain('適應度:')
|
||||
expect((await c.command('status'))).toContain('適應度 ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -424,11 +424,19 @@ describe('setup 與 materials 指令', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('/status 相容', () => {
|
||||
test('保留原有各行,並多出適應度與時間感', async () => {
|
||||
describe('info 輸出', () => {
|
||||
test('短版只有名字、關係、情緒、適應度與待辦', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const s = await c.command('status')
|
||||
for (const k of ['心情 PAD:', '情緒(前 3 強', '好感:', '關係:', '待辦:', '基本資訊:', '適應度:', '時間感:']) expect(s).toContain(k)
|
||||
for (const k of ['關係 ', '情緒 ', '適應度 ', '待辦 ', '--all']) expect(s).toContain(k)
|
||||
for (const k of ['PAD', '基本資訊', '時間感']) expect(s).not.toContain(k)
|
||||
})
|
||||
|
||||
test('--all 多出關係長條、人格參數、世界與時間感', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const s = await c.command('status --all')
|
||||
for (const k of ['情緒(8 維度)', '關係(', '人格(', '世界', '基本資訊', '現在是', '距離上次互動']) expect(s).toContain(k)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user