dsh-lifecard 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -1
- package/lib/career.js +488 -0
- package/lib/client.js +699 -33
- package/lib/game.js +373 -0
- package/lib/index.js +558 -35
- package/lib/novel.js +109 -10
- package/lib/presets.js +424 -0
- package/lib/store.js +86 -15
- package/lib/tavern.js +56 -1
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -727,7 +727,7 @@ const SECTIONS_REL = [
|
|
|
727
727
|
|
|
728
728
|
// ── 主界面 ────────────────────────────────────────────────────────
|
|
729
729
|
|
|
730
|
-
const VIEWS = { doors: 'doors', novel: 'novel', book: 'book', tavern: 'tavern', tavernChat: 'tavernChat', home: 'home', card: 'card', interview: 'interview', import: 'import', analyze: 'analyze', doctor: 'doctor', setup: 'setup', newCard: 'newCard', world: 'world', memory: 'memory', jobs: 'jobs' }
|
|
730
|
+
const VIEWS = { doors: 'doors', novel: 'novel', book: 'book', game: 'game', career: 'career', tavern: 'tavern', tavernChat: 'tavernChat', home: 'home', card: 'card', interview: 'interview', import: 'import', analyze: 'analyze', doctor: 'doctor', setup: 'setup', newCard: 'newCard', world: 'world', memory: 'memory', jobs: 'jobs' }
|
|
731
731
|
|
|
732
732
|
/**
|
|
733
733
|
* tab 组件。接收宿主给的 props(ctx / store / scope / tab / visible)。
|
|
@@ -970,8 +970,20 @@ function LifeCardTab(props) {
|
|
|
970
970
|
const [activeBook, setActiveBook] = useState(null)
|
|
971
971
|
// 书架上的书(写小说那扇门要列出来)
|
|
972
972
|
const [books, setBooks] = useState([])
|
|
973
|
+
// 哪些功能装了。null = 还没问到(先都按「装了」显示,别闪一下少一扇门)。
|
|
974
|
+
//
|
|
975
|
+
// 这个插件的每一块都能整个删掉:删了酒馆的文件,酒馆就没了,别的照常。
|
|
976
|
+
// 所以界面不能写死「一定有三扇门」,得先问一句「现在有哪些」。
|
|
977
|
+
const [features, setFeatures] = useState(null)
|
|
978
|
+
useEffect(() => {
|
|
979
|
+
let gone = false
|
|
980
|
+
call('features.list', {})
|
|
981
|
+
.then((r) => { if (!gone && r?.ok === true && r.features !== undefined) setFeatures(r.features) })
|
|
982
|
+
.catch(() => { /* 问不到就按「都装了」显示,不影响用 */ })
|
|
983
|
+
return () => { gone = true }
|
|
984
|
+
}, [])
|
|
973
985
|
// 门自己的那两屏直接说了算;其余屏(聊着建、卡片、分析…)沿用上次选的门。
|
|
974
|
-
const mode = (view === VIEWS.novel || view === VIEWS.book) ? 'novel' : (view === VIEWS.tavern || view === VIEWS.tavernChat) ? 'tavern' : door
|
|
986
|
+
const mode = (view === VIEWS.novel || view === VIEWS.book || view === VIEWS.game) ? 'novel' : (view === VIEWS.tavern || view === VIEWS.tavernChat) ? 'tavern' : door
|
|
975
987
|
// 回到酒馆那两屏就重新拿一遍说过的场次:刚说完、刚删完再回来,
|
|
976
988
|
// 还挂着进这一屏之前的旧列表,用户会以为刚才那一步没生效。
|
|
977
989
|
useEffect(() => {
|
|
@@ -1070,7 +1082,7 @@ function LifeCardTab(props) {
|
|
|
1070
1082
|
try {
|
|
1071
1083
|
const r = await call('tavern.get', { id: row?.id })
|
|
1072
1084
|
if (r?.ok !== true) throw new Error(r?.error ?? '这一场打不开')
|
|
1073
|
-
setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], err: null, busy: false })
|
|
1085
|
+
setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], notes: r.notes ?? [], err: null, busy: false })
|
|
1074
1086
|
setView(VIEWS.tavernChat)
|
|
1075
1087
|
} catch (e) {
|
|
1076
1088
|
setError(humanizeError(e))
|
|
@@ -1085,7 +1097,7 @@ function LifeCardTab(props) {
|
|
|
1085
1097
|
try {
|
|
1086
1098
|
const r = await call('tavern.start', { card: card?.path ?? String(card?.id ?? '') })
|
|
1087
1099
|
if (r?.ok !== true) throw new Error(r?.error ?? '这一场没开起来')
|
|
1088
|
-
setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], err: null, busy: false })
|
|
1100
|
+
setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], notes: r.notes ?? [], err: null, busy: false })
|
|
1089
1101
|
setView(VIEWS.tavernChat)
|
|
1090
1102
|
} catch (e) {
|
|
1091
1103
|
setError(humanizeError(e))
|
|
@@ -1114,6 +1126,8 @@ function LifeCardTab(props) {
|
|
|
1114
1126
|
[VIEWS.doors]: '你要用哪个?',
|
|
1115
1127
|
[VIEWS.home]: '真实人物记录',
|
|
1116
1128
|
[VIEWS.novel]: '写小说',
|
|
1129
|
+
[VIEWS.game]: '玩一段',
|
|
1130
|
+
[VIEWS.career]: '简历与规划',
|
|
1117
1131
|
[VIEWS.book]: activeBook?.title === undefined || String(activeBook.title).trim() === '' ? '这本书' : String(activeBook.title),
|
|
1118
1132
|
[VIEWS.tavern]: '酒馆',
|
|
1119
1133
|
[VIEWS.tavernChat]: chat === null ? '聊一场' : (String(chat.card_name ?? '').trim() === '' ? '聊一场' : String(chat.card_name)),
|
|
@@ -1186,7 +1200,7 @@ function LifeCardTab(props) {
|
|
|
1186
1200
|
})
|
|
1187
1201
|
}
|
|
1188
1202
|
// 门还没选:先问「你要用哪个」。选完才进首页,首页再按门决定露出什么。
|
|
1189
|
-
if (view === VIEWS.doors) return h(DoorsView, { state, onPick: pickDoor })
|
|
1203
|
+
if (view === VIEWS.doors) return h(DoorsView, { state, features, onPick: pickDoor })
|
|
1190
1204
|
if (view === VIEWS.tavernChat) {
|
|
1191
1205
|
return h(TavernView, {
|
|
1192
1206
|
chat,
|
|
@@ -1219,7 +1233,12 @@ function LifeCardTab(props) {
|
|
|
1219
1233
|
return h(InterviewView, {
|
|
1220
1234
|
state, listing, preset: interviewFor, onRecorded: refresh, flash, mode,
|
|
1221
1235
|
onDone: () => { setInterviewFor(null); setMaterialFor(null); setView(VIEWS.home); refresh() },
|
|
1222
|
-
onAnalyze: (about) => {
|
|
1236
|
+
onAnalyze: (about) => {
|
|
1237
|
+
const list = Array.isArray(about?.materials) ? about.materials.filter((x) => typeof x === 'string' && x !== '') : []
|
|
1238
|
+
setMaterialFor(list.length > 0 ? list : null)
|
|
1239
|
+
setInterviewFor(about ?? null)
|
|
1240
|
+
setView(VIEWS.analyze)
|
|
1241
|
+
},
|
|
1223
1242
|
})
|
|
1224
1243
|
case VIEWS.memory:
|
|
1225
1244
|
// 提取完直接跳到「让 AI 读」,省掉用户自己再想「我刚存的材料去哪了」。
|
|
@@ -1241,6 +1260,14 @@ function LifeCardTab(props) {
|
|
|
1241
1260
|
onProposal: showProposal,
|
|
1242
1261
|
onBack: () => { setInterviewFor(null); setMaterialFor(null); setView(VIEWS.home) },
|
|
1243
1262
|
onNeedMaterial: () => setView(VIEWS.import),
|
|
1263
|
+
// 一键建成卡之后直接把人带到那张卡上:建完就站在门口看着,
|
|
1264
|
+
// 不知道它在哪儿、还得自己回列表找,等于没建。
|
|
1265
|
+
onAppliedCard: async (path) => {
|
|
1266
|
+
const hit = (listing?.cards ?? []).find((c) => c.path === path)
|
|
1267
|
+
await refresh(state?.workspace)
|
|
1268
|
+
if (hit !== undefined) await openCard(hit)
|
|
1269
|
+
else setView(VIEWS.home)
|
|
1270
|
+
},
|
|
1244
1271
|
})
|
|
1245
1272
|
case VIEWS.card:
|
|
1246
1273
|
// 首页那条「还等着你看的建议」点进来时,如果这个人还没有卡(很正常:
|
|
@@ -1313,6 +1340,12 @@ function LifeCardTab(props) {
|
|
|
1313
1340
|
listing,
|
|
1314
1341
|
onOpenCard: openCard,
|
|
1315
1342
|
})
|
|
1343
|
+
case VIEWS.game:
|
|
1344
|
+
return h(GameView, {
|
|
1345
|
+
bookId: String(activeBook?.id ?? ''),
|
|
1346
|
+
flash,
|
|
1347
|
+
onBack: () => { setActiveBook(null); setView(VIEWS.novel) },
|
|
1348
|
+
})
|
|
1316
1349
|
case VIEWS.book:
|
|
1317
1350
|
return h(BookView, {
|
|
1318
1351
|
bookId: String(activeBook?.id ?? ''),
|
|
@@ -1321,6 +1354,8 @@ function LifeCardTab(props) {
|
|
|
1321
1354
|
})
|
|
1322
1355
|
case VIEWS.doctor:
|
|
1323
1356
|
return h(DoctorView, { report: doctor, onOpenCard: openCard, onRun: () => runDoctor(true) })
|
|
1357
|
+
case VIEWS.career:
|
|
1358
|
+
return h(CareerView, { flash, onBack: () => setView(VIEWS.home) })
|
|
1324
1359
|
case VIEWS.jobs:
|
|
1325
1360
|
return h(JobsView, { onBack: () => setView(VIEWS.home), flash })
|
|
1326
1361
|
default:
|
|
@@ -1448,8 +1483,12 @@ const DOORS = [
|
|
|
1448
1483
|
* 顶上一条门楣色块、中间竖着写门名、底部一道门槛写着「推门进去」。
|
|
1449
1484
|
* 三扇并排、各占三分之一宽、高度撑满;窄窗口自动换行。
|
|
1450
1485
|
*/
|
|
1451
|
-
function DoorsView({ onPick, state }) {
|
|
1486
|
+
function DoorsView({ onPick, state, features }) {
|
|
1452
1487
|
const workspace = String(state?.workspace ?? '')
|
|
1488
|
+
// 没装的门不摆出来。`features === null` 时全摆(还没问到,别闪一下少一扇)。
|
|
1489
|
+
const doors = (features === null || features === undefined)
|
|
1490
|
+
? DOORS
|
|
1491
|
+
: DOORS.filter((d) => (d.id === 'real' ? true : d.id === 'novel' ? features.novel !== false : features.tavern !== false))
|
|
1453
1492
|
const tint = {
|
|
1454
1493
|
real: {
|
|
1455
1494
|
fg: 'var(--lc-interview)', bg: 'var(--lc-interview-soft)', icon: 'people',
|
|
@@ -1482,7 +1521,7 @@ function DoorsView({ onPick, state }) {
|
|
|
1482
1521
|
alignItems: 'stretch', flexWrap: 'wrap', minHeight: 280,
|
|
1483
1522
|
},
|
|
1484
1523
|
},
|
|
1485
|
-
|
|
1524
|
+
doors.map((door) => {
|
|
1486
1525
|
const t = tint[door.id] ?? tint.real
|
|
1487
1526
|
return h('button', {
|
|
1488
1527
|
key: door.id,
|
|
@@ -1564,7 +1603,7 @@ function DoorsView({ onPick, state }) {
|
|
|
1564
1603
|
)
|
|
1565
1604
|
}),
|
|
1566
1605
|
),
|
|
1567
|
-
workspace === '' ? null : h('div', { className: 'lc-meta', style: { flex: '0 0 auto', paddingTop: 10 } },
|
|
1606
|
+
workspace === '' ? null : h('div', { className: 'lc-meta', style: { flex: '0 0 auto', paddingTop: 10 } }, '档案就在你选的那个文件夹里'),
|
|
1568
1607
|
)
|
|
1569
1608
|
}
|
|
1570
1609
|
|
|
@@ -1716,6 +1755,9 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1716
1755
|
isReal
|
|
1717
1756
|
? h(Btn, { tone: 'primary', size: 'sm', icon: 'briefcase', onClick: () => onNavigate(VIEWS.jobs), title: '贴一份招聘说明,看看该不该投;或者练一轮面试' }, '下一步')
|
|
1718
1757
|
: null,
|
|
1758
|
+
isReal
|
|
1759
|
+
? h(Btn, { size: 'sm', icon: 'book', onClick: () => onNavigate(VIEWS.career), title: '把自己的情况写清楚,让 AI 帮你看简历、算差距、规划怎么走' }, '简历与规划')
|
|
1760
|
+
: null,
|
|
1719
1761
|
h('div', { className: 'lc-grow' }),
|
|
1720
1762
|
// 「贴一段文章让 AI 读」不是只有记录真人才用得上:写小说、酒馆里
|
|
1721
1763
|
// 手里有一段人物设定/小说片段,一样想让它读成人设。所以三扇门都给。
|
|
@@ -1724,6 +1766,37 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1724
1766
|
: null,
|
|
1725
1767
|
),
|
|
1726
1768
|
|
|
1769
|
+
// 一眼看到「这里有多少东西」:以前首页只有标题和一排按钮,一大片空。
|
|
1770
|
+
// 数字本身就是内容,也让人知道还有多少没用起来。
|
|
1771
|
+
h('div', { className: 'lc-sec', style: { paddingTop: 2 } },
|
|
1772
|
+
h('div', { className: 'lc-row', style: { flexWrap: 'wrap', gap: 14 } },
|
|
1773
|
+
h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
|
|
1774
|
+
h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String(cards.length)),
|
|
1775
|
+
h('div', { className: 'lc-meta' }, isReal ? '认识的人' : '角色'),
|
|
1776
|
+
),
|
|
1777
|
+
h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
|
|
1778
|
+
h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String(materials.length)),
|
|
1779
|
+
h('div', { className: 'lc-meta' }, '材料'),
|
|
1780
|
+
),
|
|
1781
|
+
h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
|
|
1782
|
+
h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String(pendingProposals.length)),
|
|
1783
|
+
h('div', { className: 'lc-meta' }, '待你看'),
|
|
1784
|
+
),
|
|
1785
|
+
mode === 'novel'
|
|
1786
|
+
? h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
|
|
1787
|
+
h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String((books ?? []).length)),
|
|
1788
|
+
h('div', { className: 'lc-meta' }, '书'),
|
|
1789
|
+
)
|
|
1790
|
+
: null,
|
|
1791
|
+
mode === 'tavern'
|
|
1792
|
+
? h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
|
|
1793
|
+
h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String((chats ?? []).length)),
|
|
1794
|
+
h('div', { className: 'lc-meta' }, '聊过的场'),
|
|
1795
|
+
)
|
|
1796
|
+
: null,
|
|
1797
|
+
),
|
|
1798
|
+
),
|
|
1799
|
+
|
|
1727
1800
|
// 写小说那扇门:先列书架。没有它,用户建了一本书再回来就找不到了。
|
|
1728
1801
|
mode === 'novel'
|
|
1729
1802
|
? h('div', null,
|
|
@@ -1915,7 +1988,7 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1915
1988
|
h(Btn, { size: 'sm', ghost: true, icon: 'folder', onClick: () => onNavigate(VIEWS.setup) }, '换个文件夹'),
|
|
1916
1989
|
// 路径照实写出来:这是唯一一处「档案到底在哪儿」的线索。
|
|
1917
1990
|
// 藏起来,选错文件夹的人就永远找不回去了。
|
|
1918
|
-
h('span', { className: 'lc-meta lc-ellipsis' }, state?.workspace === undefined || state?.workspace === '' ? '' :
|
|
1991
|
+
h('span', { className: 'lc-meta lc-ellipsis' }, state?.workspace === undefined || state?.workspace === '' ? '' : '档案就在你选的那个文件夹里'),
|
|
1919
1992
|
),
|
|
1920
1993
|
)
|
|
1921
1994
|
: null,
|
|
@@ -2104,6 +2177,386 @@ function WorldView({ world, listing, onOpenCard }) {
|
|
|
2104
2177
|
* 没人会读,也没人改得动。
|
|
2105
2178
|
* - 不问「要不要继续」。按钮在那儿,要不要点是人自己的事。
|
|
2106
2179
|
*/
|
|
2180
|
+
/**
|
|
2181
|
+
* 预设选择条:三种玩法共用。
|
|
2182
|
+
*
|
|
2183
|
+
* 为什么做成一行小按钮而不是下拉框:这些名字(慢热 / 直球 / 冷峻白描 /
|
|
2184
|
+
* 查一件事)本身就说明了效果,扫一眼就能选。下拉框要点两下才看见有什么。
|
|
2185
|
+
*/
|
|
2186
|
+
function PresetBar({ mode, value, onChange, onManage }) {
|
|
2187
|
+
const [groups, setGroups] = useState(null)
|
|
2188
|
+
const [err, setErr] = useState(null)
|
|
2189
|
+
useEffect(() => {
|
|
2190
|
+
let gone = false
|
|
2191
|
+
call('preset.list', {})
|
|
2192
|
+
.then((r) => { if (!gone && r?.ok === true) setGroups(Array.isArray(r.groups) ? r.groups : []) })
|
|
2193
|
+
.catch((e) => { if (!gone) setErr(humanizeError(e)) })
|
|
2194
|
+
return () => { gone = true }
|
|
2195
|
+
}, [])
|
|
2196
|
+
if (groups === null) return null
|
|
2197
|
+
const mine = groups.find((g) => String(g.mode) === mode)
|
|
2198
|
+
const items = mine === undefined ? [] : (mine.items ?? [])
|
|
2199
|
+
if (items.length === 0) return null
|
|
2200
|
+
return h('div', { className: 'lc-stack', style: { gap: 6 } },
|
|
2201
|
+
h('div', { className: 'lc-row' },
|
|
2202
|
+
h('div', { className: 'lc-grow lc-meta' }, '演法 / 写法(选一个,能不选)'),
|
|
2203
|
+
onManage === undefined ? null : h(Btn, { size: 'sm', ghost: true, icon: 'edit', onClick: onManage }, '管理'),
|
|
2204
|
+
),
|
|
2205
|
+
h('div', { className: 'lc-row', style: { flexWrap: 'wrap', gap: 6 } },
|
|
2206
|
+
items.map((p) => h(Btn, {
|
|
2207
|
+
key: p.id,
|
|
2208
|
+
size: 'sm',
|
|
2209
|
+
tone: value === p.id ? 'primary' : undefined,
|
|
2210
|
+
onClick: () => onChange(value === p.id ? '' : p.id),
|
|
2211
|
+
title: String(p.desc ?? ''),
|
|
2212
|
+
}, String(p.name ?? p.id))),
|
|
2213
|
+
),
|
|
2214
|
+
err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2215
|
+
)
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* 文字小说游戏:进到一本书的某一段情节里,你来选,它来推。
|
|
2220
|
+
*
|
|
2221
|
+
* 和「写小说」摆在一起但不是一个东西:那边是你在写,这边是你在里面。
|
|
2222
|
+
* 所以这一屏的重点是**局面清楚**——现在在哪、手上有什么、这一步要付什么代价。
|
|
2223
|
+
*/
|
|
2224
|
+
function GameView({ bookId, onBack, flash }) {
|
|
2225
|
+
const [play, setPlay] = useState(null)
|
|
2226
|
+
const [move, setMove] = useState('')
|
|
2227
|
+
const [busy, setBusy] = useState('')
|
|
2228
|
+
const [err, setErr] = useState(null)
|
|
2229
|
+
const [preset, setPreset] = useState('')
|
|
2230
|
+
const [chapter, setChapter] = useState(0)
|
|
2231
|
+
const [chapterList, setChapterList] = useState([])
|
|
2232
|
+
// 「把这一局写成小说的一章」。这几个 state 放在所有 hook 之后:
|
|
2233
|
+
// 上面那几个槽位有测试按号码摆状态,插在中间会全体错位。
|
|
2234
|
+
const [toOpen, setToOpen] = useState(false)
|
|
2235
|
+
const [toNote, setToNote] = useState('')
|
|
2236
|
+
const [toBusy, setToBusy] = useState(false)
|
|
2237
|
+
// 以前这一局退出就找不回来了,而界面还写着「退出再进来还能接着走」。
|
|
2238
|
+
// 存档后台一直都有(game.list),缺的就是这半截入口。
|
|
2239
|
+
const [plays, setPlays] = useState([])
|
|
2240
|
+
|
|
2241
|
+
const loadPlays = async () => {
|
|
2242
|
+
try {
|
|
2243
|
+
const r = await call('game.list', { book: bookId })
|
|
2244
|
+
if (r?.ok === true) setPlays(Array.isArray(r.items) ? r.items : [])
|
|
2245
|
+
} catch { /* 列不出来不影响新开一局 */ }
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
const toChapter = async () => {
|
|
2249
|
+
if (play === null) return
|
|
2250
|
+
setToBusy(true); setErr(null)
|
|
2251
|
+
try {
|
|
2252
|
+
const r = await call('game.toChapter', { book: bookId, id: play.id, note: toNote })
|
|
2253
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没写成'); return }
|
|
2254
|
+
setToOpen(false); setToNote('')
|
|
2255
|
+
if (typeof flash === 'function') flash(`写成了第 ${r.chapter ?? '?'} 章`)
|
|
2256
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setToBusy(false) }
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
const open = async (row) => {
|
|
2260
|
+
setErr(null)
|
|
2261
|
+
try {
|
|
2262
|
+
const r = await call('game.get', { book: bookId, id: row.id })
|
|
2263
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这一局打不开'); return }
|
|
2264
|
+
setPlay({ id: row.id, turns: r.turns ?? [], choices: r.choices ?? [], scene: r.scene ?? '', holding: r.holding ?? [], chapter: r.chapter })
|
|
2265
|
+
} catch (e) { setErr(humanizeError(e)) }
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
useEffect(() => {
|
|
2269
|
+
let gone = false
|
|
2270
|
+
loadPlays()
|
|
2271
|
+
call('novel.get', { id: bookId })
|
|
2272
|
+
.then((r) => { if (!gone && r?.ok === true) setChapterList(Array.isArray(r.chapters) ? r.chapters : []) })
|
|
2273
|
+
.catch(() => {})
|
|
2274
|
+
return () => { gone = true }
|
|
2275
|
+
}, [bookId])
|
|
2276
|
+
|
|
2277
|
+
const start = async () => {
|
|
2278
|
+
setBusy('start'); setErr(null)
|
|
2279
|
+
try {
|
|
2280
|
+
const r = await call('game.start', { book: bookId, chapter, preset })
|
|
2281
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这一局开不起来'); return }
|
|
2282
|
+
setPlay({ id: r.id, turns: r.turns ?? [], choices: r.choices ?? [], scene: '', holding: [], chapter: r.chapter })
|
|
2283
|
+
flash?.('开局了')
|
|
2284
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
const step = async (text) => {
|
|
2288
|
+
const said = String(text ?? '').trim()
|
|
2289
|
+
if (said === '' || play === null) return
|
|
2290
|
+
setBusy('step'); setErr(null)
|
|
2291
|
+
try {
|
|
2292
|
+
const r = await call('game.say', { book: bookId, id: play.id, move: said })
|
|
2293
|
+
if (Array.isArray(r?.turns)) setPlay({ ...play, turns: r.turns, choices: r.choices ?? [] })
|
|
2294
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这一步没成'); return }
|
|
2295
|
+
setMove('')
|
|
2296
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
if (play === null) {
|
|
2300
|
+
return h('div', { className: 'lc-pad lc-stack' },
|
|
2301
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
|
|
2302
|
+
h('h3', { style: { margin: '4px 0 0' } }, '挑一个地方进去'),
|
|
2303
|
+
h('div', { className: 'lc-meta' }, '从哪一章接着演都行。没有正文也行,它会自己起头。'),
|
|
2304
|
+
h('div', { className: 'lc-row', style: { flexWrap: 'wrap', gap: 6 } },
|
|
2305
|
+
h(Btn, { size: 'sm', tone: chapter === 0 ? 'primary' : undefined, onClick: () => setChapter(0) }, '从头开始'),
|
|
2306
|
+
chapterList.map((c) => h(Btn, {
|
|
2307
|
+
key: c.index, size: 'sm', tone: chapter === c.index ? 'primary' : undefined,
|
|
2308
|
+
onClick: () => setChapter(c.index),
|
|
2309
|
+
}, `第 ${c.index} 章`)),
|
|
2310
|
+
),
|
|
2311
|
+
h(PresetBar, { mode: 'game', value: preset, onChange: setPreset }),
|
|
2312
|
+
h(Btn, { tone: 'primary', block: true, icon: 'spark', disabled: busy !== '', onClick: start },
|
|
2313
|
+
busy === 'start' ? '正在开局…' : '进这一段'),
|
|
2314
|
+
plays.length === 0
|
|
2315
|
+
? null
|
|
2316
|
+
: h('div', { className: 'lc-stack', style: { gap: 6, marginTop: 10 } },
|
|
2317
|
+
h('div', { className: 'lc-fhint' }, `之前玩到一半的 · ${plays.length}`),
|
|
2318
|
+
plays.map((row) => h(Btn, {
|
|
2319
|
+
key: row.id, block: true, size: 'sm', icon: 'history',
|
|
2320
|
+
onClick: () => open(row),
|
|
2321
|
+
}, `${String(row.scene ?? '').trim() === '' ? '那一局' : String(row.scene)} · 走了 ${Number(row.turns ?? 0)} 步`)),
|
|
2322
|
+
),
|
|
2323
|
+
err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2324
|
+
)
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
const narrations = (play.turns ?? []).filter((t) => t.kind === 'narrate' || t.kind === 'choose')
|
|
2328
|
+
return h('div', { className: 'lc-pad lc-stack', style: { gap: 10 } },
|
|
2329
|
+
h('div', { className: 'lc-row' },
|
|
2330
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '这一局先放着'),
|
|
2331
|
+
h('div', { className: 'lc-grow lc-meta' },
|
|
2332
|
+
[play.chapter > 0 ? `从第 ${play.chapter} 章进来的` : '从头开的',
|
|
2333
|
+
`已经走了 ${(play.turns ?? []).filter((t) => t.kind === 'choose').length} 步`].join(' · ')),
|
|
2334
|
+
h(Btn, {
|
|
2335
|
+
size: 'sm', icon: 'book',
|
|
2336
|
+
disabled: busy !== '' || (play.turns ?? []).length === 0,
|
|
2337
|
+
onClick: () => setToOpen(true),
|
|
2338
|
+
}, '写成小说的一章'),
|
|
2339
|
+
),
|
|
2340
|
+
toOpen === true
|
|
2341
|
+
? h('div', { className: 'lc-sec lc-stack' },
|
|
2342
|
+
h('div', { className: 'lc-fhint' }, '这一局会改写成《' + bookId + '》的下一章'),
|
|
2343
|
+
h('input', {
|
|
2344
|
+
className: 'lc-input', value: toNote, spellCheck: false,
|
|
2345
|
+
placeholder: '这一章要往哪走(可以不写)',
|
|
2346
|
+
onChange: (e) => setToNote(e.target.value),
|
|
2347
|
+
}),
|
|
2348
|
+
h('div', { className: 'lc-row' },
|
|
2349
|
+
h(Btn, { tone: 'primary', disabled: toBusy, onClick: toChapter }, toBusy ? '写着…' : '写成这一章'),
|
|
2350
|
+
h(Btn, { ghost: true, disabled: toBusy, onClick: () => setToOpen(false) }, '先不变'),
|
|
2351
|
+
),
|
|
2352
|
+
)
|
|
2353
|
+
: null,
|
|
2354
|
+
String(play.scene ?? '').trim() === '' ? null : h(Note, { icon: 'book' }, play.scene),
|
|
2355
|
+
Array.isArray(play.holding) && play.holding.length > 0
|
|
2356
|
+
? h('div', { className: 'lc-meta' }, `手上的:${play.holding.join('、')}`)
|
|
2357
|
+
: null,
|
|
2358
|
+
h('div', { className: 'lc-stack', style: { gap: 10 } },
|
|
2359
|
+
narrations.map((t, i) => h('div', {
|
|
2360
|
+
key: i,
|
|
2361
|
+
style: {
|
|
2362
|
+
whiteSpace: 'pre-wrap', lineHeight: 1.85, fontSize: 14.5,
|
|
2363
|
+
padding: '10px 12px', borderRadius: 10,
|
|
2364
|
+
background: t.kind === 'choose' ? 'var(--lc-accent-soft)' : 'var(--lc-panel)',
|
|
2365
|
+
border: `1px solid ${t.kind === 'choose' ? 'var(--lc-accent)' : 'var(--lc-line)'}`,
|
|
2366
|
+
color: t.kind === 'choose' ? 'var(--lc-accent)' : undefined,
|
|
2367
|
+
},
|
|
2368
|
+
}, t.kind === 'choose' ? `我:${t.text}${String(t.result ?? '') === '' ? '' : `\n\n${t.result}`}` : String(t.text ?? ''))),
|
|
2369
|
+
),
|
|
2370
|
+
(play.choices ?? []).length > 0
|
|
2371
|
+
? h('div', { className: 'lc-stack', style: { gap: 6 } },
|
|
2372
|
+
h('div', { className: 'lc-meta' }, '这一步可以走:'),
|
|
2373
|
+
(play.choices ?? []).map((c, i) => h(Btn, {
|
|
2374
|
+
key: i, block: true, disabled: busy !== '', onClick: () => step(c.text),
|
|
2375
|
+
}, `${c.label}. ${c.text}`)),
|
|
2376
|
+
)
|
|
2377
|
+
: null,
|
|
2378
|
+
h('div', { className: 'lc-row' },
|
|
2379
|
+
h('input', {
|
|
2380
|
+
className: 'lc-input', value: move, spellCheck: false,
|
|
2381
|
+
placeholder: '或者自己写一句这一步怎么做',
|
|
2382
|
+
onChange: (e) => setMove(e.target.value),
|
|
2383
|
+
onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); step(move) } },
|
|
2384
|
+
}),
|
|
2385
|
+
h(Btn, { tone: 'primary', disabled: busy !== '' || move.trim() === '', onClick: () => step(move) },
|
|
2386
|
+
busy === 'step' ? '等一下…' : '走这一步'),
|
|
2387
|
+
),
|
|
2388
|
+
err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2389
|
+
h('div', { className: 'lc-meta' }, '每一步都会存下来,退出再进来还能接着走。'),
|
|
2390
|
+
)
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* 简历与规划。放在「真实人物记录」那扇门里(写小说和酒馆用不上)。
|
|
2395
|
+
*
|
|
2396
|
+
* 为什么分成「先把自己写清楚」和「再让 AI 帮忙」两步:
|
|
2397
|
+
* 简历这东西最怕 AI 编。先把真实情况填进去,AI 只做整理和措辞,
|
|
2398
|
+
* 不编经历——不然投出去被问一句就露了。
|
|
2399
|
+
*/
|
|
2400
|
+
function CareerView({ onBack, flash }) {
|
|
2401
|
+
const [profile, setProfile] = useState(null)
|
|
2402
|
+
const [target, setTarget] = useState('')
|
|
2403
|
+
const [goal, setGoal] = useState('')
|
|
2404
|
+
const [horizon, setHorizon] = useState('1年')
|
|
2405
|
+
const [out, setOut] = useState(null)
|
|
2406
|
+
const [busy, setBusy] = useState('')
|
|
2407
|
+
const [err, setErr] = useState(null)
|
|
2408
|
+
const [dirty, setDirty] = useState(false)
|
|
2409
|
+
|
|
2410
|
+
useEffect(() => {
|
|
2411
|
+
let gone = false
|
|
2412
|
+
call('career.get', {})
|
|
2413
|
+
.then((r) => {
|
|
2414
|
+
if (gone) return
|
|
2415
|
+
if (r?.ok !== true) { setErr(r?.error ?? '你的简历这一份读不出来'); return }
|
|
2416
|
+
setProfile(buildCareerForm(r.profileSections ?? {}))
|
|
2417
|
+
setGoal(String(r.plan?.goal ?? ''))
|
|
2418
|
+
setHorizon(String(r.plan?.horizon ?? '1年'))
|
|
2419
|
+
})
|
|
2420
|
+
.catch((e) => { if (!gone) setErr(humanizeError(e)) })
|
|
2421
|
+
return () => { gone = true }
|
|
2422
|
+
}, [])
|
|
2423
|
+
|
|
2424
|
+
const setField = (key, value) => { setProfile({ ...(profile ?? {}), [key]: value }); setDirty(true) }
|
|
2425
|
+
|
|
2426
|
+
const save = async () => {
|
|
2427
|
+
setBusy('save'); setErr(null)
|
|
2428
|
+
try {
|
|
2429
|
+
const r = await call('career.profile.save', { profile: profile ?? {} })
|
|
2430
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没存下来'); return }
|
|
2431
|
+
setDirty(false); flash?.('存好了')
|
|
2432
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
const run = async (kind) => {
|
|
2436
|
+
if (dirty) { setErr('简历改过了,先点「存好」再让它做。'); return }
|
|
2437
|
+
setBusy(kind); setErr(null); setOut(null)
|
|
2438
|
+
try {
|
|
2439
|
+
const r = kind === 'resume'
|
|
2440
|
+
? await call('career.resume', { target })
|
|
2441
|
+
: kind === 'gap'
|
|
2442
|
+
? await call('career.gap', { target })
|
|
2443
|
+
: await call('career.path', { goal, horizon })
|
|
2444
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没做成'); return }
|
|
2445
|
+
setOut({ kind, text: String(r.text ?? ''), steps: r.steps ?? [] })
|
|
2446
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// 先看有没有出错:出错的渲染在下面(要 profile 读出来才轮得到),
|
|
2450
|
+
// 所以这里必须自己先把错说出来,否则用户看到的是一辈子转圈。
|
|
2451
|
+
if (profile === null) {
|
|
2452
|
+
return h('div', { className: 'lc-pad lc-stack' },
|
|
2453
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回首页'),
|
|
2454
|
+
err === null
|
|
2455
|
+
? h(Spinner)
|
|
2456
|
+
: h('div', { className: 'lc-stack' },
|
|
2457
|
+
h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2458
|
+
h(Btn, { block: true, onClick: () => { setErr(null); call('career.get', {}) } }, '再试一次'),
|
|
2459
|
+
),
|
|
2460
|
+
)
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const field = (key, label, rows, placeholder) => h('div', { className: 'lc-stack', style: { gap: 6 } },
|
|
2464
|
+
h('div', { className: 'lc-fhint' }, label),
|
|
2465
|
+
h('textarea', {
|
|
2466
|
+
className: 'lc-textarea', rows, value: String(profile[key] ?? ''), placeholder, spellCheck: false,
|
|
2467
|
+
onChange: (e) => setField(key, e.target.value),
|
|
2468
|
+
}),
|
|
2469
|
+
)
|
|
2470
|
+
|
|
2471
|
+
return h('div', { className: 'lc-pad lc-stack', style: { gap: 12 } },
|
|
2472
|
+
h('div', { className: 'lc-row' },
|
|
2473
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回首页'),
|
|
2474
|
+
h('div', { className: 'lc-grow' }),
|
|
2475
|
+
h(Btn, { size: 'sm', tone: dirty ? 'primary' : undefined, icon: 'check', disabled: busy !== '', onClick: save },
|
|
2476
|
+
busy === 'save' ? '存着…' : (dirty ? '存好' : '存好了')),
|
|
2477
|
+
),
|
|
2478
|
+
h('div', { className: 'lc-meta' }, '先把真事写在这儿。AI 只帮你整理,不会替你编经历。'),
|
|
2479
|
+
|
|
2480
|
+
h('div', { className: 'lc-sec lc-stack' },
|
|
2481
|
+
field('基本情况', '基本情况', 3, '哪个学校、什么专业、几年级、现在在哪'),
|
|
2482
|
+
field('学过什么', '学过什么(一行一门)', 4, '现代汉语|能讲清音韵那套\n写作|能改到能发'),
|
|
2483
|
+
field('做过什么', '做过什么(一行一件)', 4, '校刊编辑|2025 秋|出了两期\n家教|2025 夏|带过一个高三'),
|
|
2484
|
+
field('会用哪些工具', '会用哪些工具(一行一个)', 2, 'Word\n剪映'),
|
|
2485
|
+
field('想去哪', '想去哪', 3, '公司加岗位,或者学校加专业。写具体一点。'),
|
|
2486
|
+
field('还差什么', '你自己觉得还差什么(一行一条)', 3, '六级还没过\n没写过正式的报告'),
|
|
2487
|
+
),
|
|
2488
|
+
|
|
2489
|
+
h('div', { className: 'lc-sec lc-stack' },
|
|
2490
|
+
h('div', { className: 'lc-fhint' }, '让 AI 帮忙'),
|
|
2491
|
+
h('input', {
|
|
2492
|
+
className: 'lc-input', value: target, spellCheck: false,
|
|
2493
|
+
placeholder: '要投哪儿:公司加岗位,或者学校加专业',
|
|
2494
|
+
onChange: (e) => setTarget(e.target.value),
|
|
2495
|
+
}),
|
|
2496
|
+
h('div', { className: 'lc-row', style: { flexWrap: 'wrap' } },
|
|
2497
|
+
h(Btn, { disabled: busy !== '' || target.trim() === '', onClick: () => run('resume') }, busy === 'resume' ? '写着…' : '照这个写简历'),
|
|
2498
|
+
h(Btn, { disabled: busy !== '' || target.trim() === '', onClick: () => run('gap') }, busy === 'gap' ? '算着…' : '看差在哪'),
|
|
2499
|
+
),
|
|
2500
|
+
h('div', { className: 'lc-row' },
|
|
2501
|
+
h('input', {
|
|
2502
|
+
className: 'lc-input', value: goal, spellCheck: false,
|
|
2503
|
+
placeholder: '想到哪儿去(一句具体的)',
|
|
2504
|
+
onChange: (e) => setGoal(e.target.value),
|
|
2505
|
+
}),
|
|
2506
|
+
h('select', { className: 'lc-select', value: horizon, onChange: (e) => setHorizon(e.target.value), style: { maxWidth: 110 } },
|
|
2507
|
+
['3个月', '1年', '4年'].map((x) => h('option', { key: x, value: x }, x))),
|
|
2508
|
+
),
|
|
2509
|
+
h(Btn, { block: true, disabled: busy !== '' || goal.trim() === '', onClick: () => run('path') },
|
|
2510
|
+
busy === 'path' ? '规划着…' : '规划怎么走'),
|
|
2511
|
+
),
|
|
2512
|
+
|
|
2513
|
+
err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2514
|
+
out === null ? null : h('div', { className: 'lc-sec lc-stack' },
|
|
2515
|
+
h('div', { className: 'lc-row' },
|
|
2516
|
+
h('div', { className: 'lc-grow lc-fhint' },
|
|
2517
|
+
out.kind === 'resume' ? '写好的简历' : out.kind === 'gap' ? '差在哪' : '怎么走'),
|
|
2518
|
+
h(Btn, { size: 'sm', icon: 'download', onClick: () => copyText(out.text) }, '复制'),
|
|
2519
|
+
),
|
|
2520
|
+
h('div', { style: { whiteSpace: 'pre-wrap', lineHeight: 1.8, fontSize: 14.5 } }, out.text),
|
|
2521
|
+
out.steps.length === 0 ? null : h('div', { className: 'lc-meta' }, `认出 ${out.steps.length} 步`),
|
|
2522
|
+
),
|
|
2523
|
+
)
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/** 把后端读出来的小节拍平成表单能直接用的字段。 */
|
|
2527
|
+
function buildCareerForm(sections) {
|
|
2528
|
+
const pick = (key) => {
|
|
2529
|
+
const v = sections?.[key]
|
|
2530
|
+
if (Array.isArray(v)) return v.join('\n')
|
|
2531
|
+
return String(v ?? '')
|
|
2532
|
+
}
|
|
2533
|
+
return {
|
|
2534
|
+
基本情况: pick('基本情况'),
|
|
2535
|
+
学过什么: pick('学过什么'),
|
|
2536
|
+
做过什么: pick('做过什么'),
|
|
2537
|
+
会用哪些工具: pick('会用哪些工具'),
|
|
2538
|
+
想去哪: pick('想去哪'),
|
|
2539
|
+
还差什么: pick('还差什么'),
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
/** 复制到剪贴板;不支持就退回「选中让用户自己复制」。 */
|
|
2544
|
+
function copyText(text) {
|
|
2545
|
+
const value = String(text ?? '')
|
|
2546
|
+
if (typeof navigator !== 'undefined' && navigator?.clipboard?.writeText !== undefined) {
|
|
2547
|
+
navigator.clipboard.writeText(value).catch(() => {})
|
|
2548
|
+
return
|
|
2549
|
+
}
|
|
2550
|
+
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
|
2551
|
+
const box = document.createElement('textarea')
|
|
2552
|
+
box.value = value
|
|
2553
|
+
document.body.appendChild(box)
|
|
2554
|
+
box.select()
|
|
2555
|
+
try { document.execCommand('copy') } catch { /* 复制不了就算了 */ }
|
|
2556
|
+
document.body.removeChild(box)
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2107
2560
|
function BookView({ bookId, onBack, flash }) {
|
|
2108
2561
|
const [book, setBook] = useState(null)
|
|
2109
2562
|
const [outline, setOutline] = useState(null)
|
|
@@ -2116,6 +2569,8 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2116
2569
|
const [ask, setAsk] = useState('')
|
|
2117
2570
|
const [editingOutline, setEditingOutline] = useState(false)
|
|
2118
2571
|
const [outlineText, setOutlineText] = useState('')
|
|
2572
|
+
// 这一章的正文有没有还没存下的改动。切章之前必须先处理,否则写的东西会没。
|
|
2573
|
+
const [bodyDirty, setBodyDirty] = useState(false)
|
|
2119
2574
|
|
|
2120
2575
|
const load = async (want) => {
|
|
2121
2576
|
setErr(null)
|
|
@@ -2140,6 +2595,24 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2140
2595
|
} catch (e) { setErr(humanizeError(e)) }
|
|
2141
2596
|
}
|
|
2142
2597
|
|
|
2598
|
+
// 切章之前先把没存的正文收好。存不上就留在这一章,别让人以为写进去了。
|
|
2599
|
+
const keepBody = async () => {
|
|
2600
|
+
if (bodyDirty !== true) return true
|
|
2601
|
+
if (body.trim() === '') { setBodyDirty(false); return true }
|
|
2602
|
+
try {
|
|
2603
|
+
const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body })
|
|
2604
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这一章没存上,先别走'); return false }
|
|
2605
|
+
setBodyDirty(false)
|
|
2606
|
+
return true
|
|
2607
|
+
} catch (e) { setErr(humanizeError(e)); return false }
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
const goChapter = async (index) => {
|
|
2611
|
+
if ((await keepBody()) !== true) return
|
|
2612
|
+
setChapterIndex(index)
|
|
2613
|
+
await openChapter(index)
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2143
2616
|
useEffect(() => { load(0) }, [bookId])
|
|
2144
2617
|
|
|
2145
2618
|
const doPlan = async () => {
|
|
@@ -2157,7 +2630,16 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2157
2630
|
try {
|
|
2158
2631
|
const r = await call('novel.outline.save', { id: bookId, text: outlineText })
|
|
2159
2632
|
if (r?.ok !== true) { setErr(r?.error ?? '大纲没存上'); return }
|
|
2160
|
-
|
|
2633
|
+
// 只取存盘结果里跟大纲正文有关的那几样。服务端回的 chapters 是「几条」这个数字,
|
|
2634
|
+
// 整包铺出去会把本地那份逐章列表盖掉,下一屏渲染就会拿数字去 find。
|
|
2635
|
+
setOutline({
|
|
2636
|
+
ok: true,
|
|
2637
|
+
path: r.path,
|
|
2638
|
+
text: outlineText,
|
|
2639
|
+
theme: r.theme,
|
|
2640
|
+
ending: r.ending,
|
|
2641
|
+
chapters: Array.isArray(outline?.chapters) ? outline.chapters : [],
|
|
2642
|
+
})
|
|
2161
2643
|
setEditingOutline(false)
|
|
2162
2644
|
flash?.('大纲存好了')
|
|
2163
2645
|
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
@@ -2179,7 +2661,7 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2179
2661
|
const merged = body.trim() === '' ? draft : `${body.trim()}\n\n${draft}`
|
|
2180
2662
|
const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body: merged })
|
|
2181
2663
|
if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
|
|
2182
|
-
setBody(merged); setDraft(null)
|
|
2664
|
+
setBody(merged); setDraft(null); setBodyDirty(false)
|
|
2183
2665
|
flash?.(`第 ${chapterIndex} 章存好了`)
|
|
2184
2666
|
await load(chapterIndex)
|
|
2185
2667
|
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
@@ -2190,12 +2672,16 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2190
2672
|
try {
|
|
2191
2673
|
const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body })
|
|
2192
2674
|
if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
|
|
2675
|
+
setBodyDirty(false)
|
|
2193
2676
|
flash?.(`第 ${chapterIndex} 章存好了`)
|
|
2194
2677
|
await load(chapterIndex)
|
|
2195
2678
|
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2196
2679
|
}
|
|
2197
2680
|
|
|
2198
2681
|
const doReview = async () => {
|
|
2682
|
+
// 挑毛病看的是存下来的那一版,所以正文有没存的改动就先存一次,
|
|
2683
|
+
// 否则你会以为它评的是刚写的那段。
|
|
2684
|
+
if ((await keepBody()) !== true) return
|
|
2199
2685
|
setBusy('review'); setErr(null)
|
|
2200
2686
|
try {
|
|
2201
2687
|
const r = await call('novel.chapter.review', { id: bookId, index: chapterIndex })
|
|
@@ -2212,13 +2698,13 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2212
2698
|
}
|
|
2213
2699
|
|
|
2214
2700
|
const chapters = Array.isArray(book.chapters) ? book.chapters : []
|
|
2215
|
-
const plan = outline?.ok === true
|
|
2701
|
+
const plan = outline?.ok === true && Array.isArray(outline.chapters) ? outline.chapters : []
|
|
2216
2702
|
const planItem = plan.find((c) => c.index === chapterIndex)
|
|
2217
2703
|
const title = String(book.data?.title ?? bookId)
|
|
2218
2704
|
|
|
2219
2705
|
return h('div', { className: 'lc-pad lc-stack', style: { gap: 12 } },
|
|
2220
2706
|
h('div', { className: 'lc-row' },
|
|
2221
|
-
h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
|
|
2707
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: async () => { if ((await keepBody()) === true) onBack() } }, '回书架'),
|
|
2222
2708
|
h('div', { className: 'lc-grow lc-ellipsis', style: { fontWeight: 700, fontSize: 16.5 } }, `《${title}》`),
|
|
2223
2709
|
h('span', { className: 'lc-meta' }, `${chapters.length} 章`),
|
|
2224
2710
|
),
|
|
@@ -2248,7 +2734,7 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2248
2734
|
background: c.index === chapterIndex ? 'var(--lc-accent-soft)' : 'transparent',
|
|
2249
2735
|
color: c.index === chapterIndex ? 'var(--lc-accent)' : undefined,
|
|
2250
2736
|
},
|
|
2251
|
-
onClick: () => {
|
|
2737
|
+
onClick: () => { goChapter(c.index) },
|
|
2252
2738
|
}, `第 ${c.index} 章 · ${c.plan}`))),
|
|
2253
2739
|
outline.ending === '' ? null : h('div', { className: 'lc-meta' }, `结局:${outline.ending}`),
|
|
2254
2740
|
),
|
|
@@ -2314,7 +2800,7 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2314
2800
|
spellCheck: false,
|
|
2315
2801
|
placeholder: '这一章还空着。让 AI 写一段,或者自己动手。',
|
|
2316
2802
|
style: { minHeight: 260, marginTop: 6, lineHeight: 1.85 },
|
|
2317
|
-
onChange: (e) => setBody(e.target.value),
|
|
2803
|
+
onChange: (e) => { setBody(e.target.value); setBodyDirty(true) },
|
|
2318
2804
|
}),
|
|
2319
2805
|
h('div', { className: 'lc-row' },
|
|
2320
2806
|
h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveBody }, busy === 'save' ? '正在存…' : '存好这一章'),
|
|
@@ -2323,8 +2809,7 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2323
2809
|
h('div', { className: 'lc-grow' }),
|
|
2324
2810
|
h(Btn, { size: 'sm', ghost: true, icon: 'plus', onClick: async () => {
|
|
2325
2811
|
const next = chapterIndex + 1
|
|
2326
|
-
|
|
2327
|
-
await openChapter(next)
|
|
2812
|
+
await goChapter(next)
|
|
2328
2813
|
} }, `写第 ${chapterIndex + 1} 章`),
|
|
2329
2814
|
),
|
|
2330
2815
|
review === null
|
|
@@ -2339,7 +2824,7 @@ function BookView({ bookId, onBack, flash }) {
|
|
|
2339
2824
|
)
|
|
2340
2825
|
}
|
|
2341
2826
|
|
|
2342
|
-
function TavernView({ chat, onChat, onBack }) {
|
|
2827
|
+
function TavernView({ chat, onChat, onBack, flash }) {
|
|
2343
2828
|
const [draft, setDraft] = useState('')
|
|
2344
2829
|
const [busy, setBusy] = useState(false)
|
|
2345
2830
|
const [err, setErr] = useState(null)
|
|
@@ -2347,6 +2832,16 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2347
2832
|
// 「到这儿为止」那段备忘。它存在会话文件里,跟着 chat 一起下发。
|
|
2348
2833
|
const [notes, setNotes] = useState(Array.isArray(chat?.notes) ? chat.notes : [])
|
|
2349
2834
|
const scrollRef = useRef(null)
|
|
2835
|
+
// 「把这一场变成小说的一章」。这几个 state 特意放在所有 hook 之后:
|
|
2836
|
+
// 上面那几个槽位有测试按号码摆状态,插在中间会让它们全体错位。
|
|
2837
|
+
const [toOpen, setToOpen] = useState(false)
|
|
2838
|
+
const [toBooks, setToBooks] = useState([])
|
|
2839
|
+
const [toBook, setToBook] = useState('')
|
|
2840
|
+
const [toBusy, setToBusy] = useState(false)
|
|
2841
|
+
const [toNote, setToNote] = useState('')
|
|
2842
|
+
// 「挑几句写进卡里」。同样排在最后。
|
|
2843
|
+
const [exBusy, setExBusy] = useState(false)
|
|
2844
|
+
const [exNote, setExNote] = useState('')
|
|
2350
2845
|
const turns = Array.isArray(chat?.turns) ? chat.turns : []
|
|
2351
2846
|
const rawName = String(chat?.card_name ?? '').trim()
|
|
2352
2847
|
const name = rawName === '' ? 'TA' : rawName
|
|
@@ -2396,6 +2891,52 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2396
2891
|
}
|
|
2397
2892
|
}
|
|
2398
2893
|
|
|
2894
|
+
// 打开「变成小说的一章」时先问一次有哪些书。以前没有这条路,
|
|
2895
|
+
// 酒馆里演得好的一场只能靠人手动复制粘贴进小说。
|
|
2896
|
+
/**
|
|
2897
|
+
* 把这一场里最能代表 TA 的几句话写回那张卡的「对话示例」。
|
|
2898
|
+
*
|
|
2899
|
+
* 「性格:沉默寡言」这种话模型只能猜;「他说话只答半句,句尾带个『呗』」
|
|
2900
|
+
* 是能直接照做的。演着演着发现 TA 说话的样子比卡上写的准,就该固化下来。
|
|
2901
|
+
*/
|
|
2902
|
+
const toExample = async () => {
|
|
2903
|
+
if (exBusy || chat?.id === undefined) return
|
|
2904
|
+
setExBusy(true); setExNote('')
|
|
2905
|
+
try {
|
|
2906
|
+
const r = await call('tavern.toExample', { id: chat.id })
|
|
2907
|
+
if (r?.ok !== true) { setExNote(r?.error ?? '这次没挑出来'); return }
|
|
2908
|
+
const picked = Array.isArray(r.picked) ? r.picked : []
|
|
2909
|
+
setExNote(`写进卡里了,这次挑的是:${picked.map((x) => `「${x}」`).join('、')}`)
|
|
2910
|
+
flash?.('卡上的「对话示例」又厚了一点')
|
|
2911
|
+
} catch (e) {
|
|
2912
|
+
setExNote(humanizeError(e))
|
|
2913
|
+
} finally {
|
|
2914
|
+
setExBusy(false)
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
const openToChapter = async () => {
|
|
2919
|
+
setToOpen(true); setToBusy(true); setErr(null)
|
|
2920
|
+
try {
|
|
2921
|
+
const r = await call('novel.books', {})
|
|
2922
|
+
if (r?.ok !== true) { setErr(r?.error ?? '看不到书架'); return }
|
|
2923
|
+
const list = Array.isArray(r.books) ? r.books : []
|
|
2924
|
+
setToBooks(list)
|
|
2925
|
+
setToBook(toBook !== '' ? toBook : String(list[0]?.id ?? ''))
|
|
2926
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setToBusy(false) }
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
const toChapter = async () => {
|
|
2930
|
+
if (toBook === '') { setErr('还没有书。先回「写小说」那扇门建一本。'); return }
|
|
2931
|
+
setToBusy(true); setErr(null)
|
|
2932
|
+
try {
|
|
2933
|
+
const r = await call('tavern.toChapter', { id: chat?.id, book: toBook, note: toNote })
|
|
2934
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没改成'); return }
|
|
2935
|
+
setToOpen(false); setToNote('')
|
|
2936
|
+
if (typeof flash === 'function') flash(`写成了第 ${r.chapter ?? '?'} 章`)
|
|
2937
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setToBusy(false) }
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2399
2940
|
const remove = async () => {
|
|
2400
2941
|
if (chat?.id === undefined) return
|
|
2401
2942
|
setBusy(true)
|
|
@@ -2493,8 +3034,36 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2493
3034
|
onClick: summarize,
|
|
2494
3035
|
title: '把前面发生过的事记成几句,之后每轮都带上',
|
|
2495
3036
|
}, '收个尾'),
|
|
3037
|
+
h(Btn, { size: 'sm', icon: 'book', disabled: busy || turns.length === 0, onClick: openToChapter, title: '把这一场改写成某本小说的一章' }, '变成小说的一章'),
|
|
3038
|
+
h(Btn, { size: 'sm', icon: 'chat', disabled: busy || exBusy || turns.length === 0, onClick: toExample, title: '挑几句 TA 说过的话,写进那张卡的「对话示例」' }, exBusy ? '正在挑…' : '挑几句进卡里'),
|
|
2496
3039
|
h(Btn, { size: 'sm', ghost: true, icon: 'trash', disabled: busy, onClick: () => setConfirmDelete(true) }, '这一场不要了'),
|
|
2497
3040
|
),
|
|
3041
|
+
toOpen === true
|
|
3042
|
+
? h('div', { className: 'lc-sec lc-stack' },
|
|
3043
|
+
h('div', { className: 'lc-fhint' }, '挑一本书,这一场会改写成它的下一章'),
|
|
3044
|
+
toBusy && toBooks.length === 0
|
|
3045
|
+
? h(Spinner)
|
|
3046
|
+
: toBooks.length === 0
|
|
3047
|
+
? h('div', { className: 'lc-meta' }, '还没有书。先去「写小说」那扇门建一本,再回来。')
|
|
3048
|
+
: h('div', { className: 'lc-stack' },
|
|
3049
|
+
h('div', { className: 'lc-chips' }, toBooks.map((b) => h(Btn, {
|
|
3050
|
+
key: b.id,
|
|
3051
|
+
size: 'sm',
|
|
3052
|
+
tone: toBook === b.id ? 'primary' : undefined,
|
|
3053
|
+
onClick: () => setToBook(b.id),
|
|
3054
|
+
}, String(b.title ?? b.id)))),
|
|
3055
|
+
h('input', {
|
|
3056
|
+
className: 'lc-input', value: toNote, spellCheck: false,
|
|
3057
|
+
placeholder: '这一章要往哪走(可以不写)',
|
|
3058
|
+
onChange: (e) => setToNote(e.target.value),
|
|
3059
|
+
}),
|
|
3060
|
+
h('div', { className: 'lc-row' },
|
|
3061
|
+
h(Btn, { tone: 'primary', disabled: toBusy || toBook === '', onClick: toChapter }, toBusy ? '写着…' : '写成这一章'),
|
|
3062
|
+
h(Btn, { ghost: true, disabled: toBusy, onClick: () => setToOpen(false) }, '先不变'),
|
|
3063
|
+
),
|
|
3064
|
+
),
|
|
3065
|
+
)
|
|
3066
|
+
: null,
|
|
2498
3067
|
confirmDelete === true
|
|
2499
3068
|
? h('div', { className: 'lc-row' },
|
|
2500
3069
|
h('span', { className: 'lc-meta', className: 'lc-meta' }, '这一场的记录会删掉,删了就找不回来了。'),
|
|
@@ -2502,6 +3071,7 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2502
3071
|
h(Btn, { size: 'sm', ghost: true, onClick: () => setConfirmDelete(false), disabled: busy }, '先不删'),
|
|
2503
3072
|
)
|
|
2504
3073
|
: null,
|
|
3074
|
+
exNote === '' ? null : h(Note, { tone: 'ok', icon: 'check' }, exNote),
|
|
2505
3075
|
h('div', { className: 'lc-meta' }, '每说一句都会存进「chats」里,退出再进来还能接着说。'),
|
|
2506
3076
|
)
|
|
2507
3077
|
}
|
|
@@ -3823,6 +4393,9 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3823
4393
|
// 结束页以前一律说「你说的都记下来了」;可要是手一滑点了结束,
|
|
3824
4394
|
// 正在写的那句其实没存进去——那句话就得改口。
|
|
3825
4395
|
const [dropped, setDropped] = useState(false)
|
|
4396
|
+
// 这一场访谈写出去的材料(interview.record 每次回传一份)。结束页要带上它们,
|
|
4397
|
+
// 不然分析页一条都不勾,而「一条都不勾」= 全读——别人的材料会被读进这张卡。
|
|
4398
|
+
const [materialPaths, setMaterialPaths] = useState([])
|
|
3826
4399
|
const scrollRef = useRef(null)
|
|
3827
4400
|
|
|
3828
4401
|
// 宿主给什么就用什么;一个都没有(还没重启、旧版宿主)就用本地这份。
|
|
@@ -3963,6 +4536,12 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3963
4536
|
setErr(rec?.error ?? '这一句没记上。检查一下文件夹还在不在,再点一次。')
|
|
3964
4537
|
return
|
|
3965
4538
|
}
|
|
4539
|
+
// interview.record 会把你这一场的问答另存成一份材料。攒着它的路径,
|
|
4540
|
+
// 结束页「整理进卡里」必须带上——分析页一条不勾等于「全读」,
|
|
4541
|
+
// 别人说的那些也会被读进这张卡。
|
|
4542
|
+
if (typeof rec.material === 'string' && rec.material !== '') {
|
|
4543
|
+
setMaterialPaths((prev) => (prev.includes(rec.material) ? prev : [...prev, rec.material]))
|
|
4544
|
+
}
|
|
3966
4545
|
if (status === 'answered') {
|
|
3967
4546
|
setLog((prev) => [...prev, { role: 'bot', text: q.ask, options: q.options }, { role: 'me', text: answer.trim() }])
|
|
3968
4547
|
} else {
|
|
@@ -3973,6 +4552,12 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3973
4552
|
const rest = queue.slice(1)
|
|
3974
4553
|
if (rest.length === 0) {
|
|
3975
4554
|
const p = await call('interview.plan', { about: aboutId, relation, limit: 3, kind: fiction ? 'fictional' : 'real' })
|
|
4555
|
+
// 没问着问题不等于问完了。以前这里不看结果:AI 没连上、模型抽风、
|
|
4556
|
+
// 文件夹读不出来,界面都会直接跳到结束页,还写「现在写满了 undefined%」。
|
|
4557
|
+
if (p?.ok !== true) {
|
|
4558
|
+
setErr(`${p?.error ?? '想不出下一题'}。刚才那一句已经记下了,这里是没接上——过一会儿点「再问一题」接着来。`)
|
|
4559
|
+
return
|
|
4560
|
+
}
|
|
3976
4561
|
setPlan(p)
|
|
3977
4562
|
setQueue(p.next ?? [])
|
|
3978
4563
|
setCurrent((p.next ?? [])[0] ?? null)
|
|
@@ -4130,7 +4715,7 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
4130
4715
|
plan === null || (plan.empty ?? []).length === 0
|
|
4131
4716
|
? null
|
|
4132
4717
|
: h('div', { className: 'lc-meta' }, `还空着:${(plan.empty ?? []).slice(0, 8).join('、')}`),
|
|
4133
|
-
h(Btn, { tone: 'primary', big: true, block: true, onClick: () => onAnalyze({ id: aboutId, name: preset?.name ?? name }) }, '把刚才说的整理进卡里'),
|
|
4718
|
+
h(Btn, { tone: 'primary', big: true, block: true, onClick: () => onAnalyze({ id: aboutId, name: preset?.name ?? name, materials: materialPaths }) }, '把刚才说的整理进卡里'),
|
|
4134
4719
|
h(Btn, { block: true, onClick: () => { onDone() } }, '先这样,回头再说'),
|
|
4135
4720
|
h('div', { className: 'lc-meta', style: { textAlign: 'center' } }, '你说的每句话都存成了材料。整理时会标出原文出处,编不出来的。'),
|
|
4136
4721
|
)
|
|
@@ -4193,9 +4778,16 @@ function ImportView({ busy, onImport, onAnalyze, listing }) {
|
|
|
4193
4778
|
const [drag, setDrag] = useState(false)
|
|
4194
4779
|
const [tab, setTab] = useState('card')
|
|
4195
4780
|
const inputRef = useRef(null)
|
|
4781
|
+
const [skipNote, setSkipNote] = useState('')
|
|
4196
4782
|
|
|
4197
4783
|
const handleFiles = (fileList) => {
|
|
4198
|
-
const
|
|
4784
|
+
const all = Array.from(fileList ?? [])
|
|
4785
|
+
const files = all.filter((f) => /\.(png|json|charx|zip)$/i.test(f.name))
|
|
4786
|
+
// 以前过滤完直接就 return:拖进来的是一张 jpg 头像或一份 md 设定,
|
|
4787
|
+
// 界面上什么都不发生,用户只会以为这块坏了。
|
|
4788
|
+
setSkipNote(files.length > 0 || all.length === 0
|
|
4789
|
+
? ''
|
|
4790
|
+
: `这些认不出来:${all.map((f) => f.name).join('、')}。只认角色卡文件(.png / .json / .charx / .zip)。想贴一段文字,就切到上面「一段文字」。`)
|
|
4199
4791
|
if (files.length === 0) return
|
|
4200
4792
|
onImport(files)
|
|
4201
4793
|
}
|
|
@@ -4231,6 +4823,7 @@ function ImportView({ busy, onImport, onAnalyze, listing }) {
|
|
|
4231
4823
|
style: { display: 'none' }, onChange: (e) => handleFiles(e.target.files),
|
|
4232
4824
|
}),
|
|
4233
4825
|
),
|
|
4826
|
+
skipNote === '' ? null : h(Note, { tone: 'warn', icon: 'warning' }, skipNote),
|
|
4234
4827
|
busy === 'import' ? h('div', { className: 'lc-row' }, h(Spinner), h('span', { className: 'lc-meta' }, '正在读…')) : null,
|
|
4235
4828
|
h(Note, { tone: undefined, icon: 'check' }, '导入进来的卡会原样保留所有内容,包括这个软件不认识的字段。你在这里改过的部分才会变,导出时一起带走。'),
|
|
4236
4829
|
),
|
|
@@ -4278,7 +4871,7 @@ function TextImport({ onAnalyze }) {
|
|
|
4278
4871
|
h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } },
|
|
4279
4872
|
'把一大段话贴进来——小说片段、人物小传、你随口讲的关于某个人的事都行。',
|
|
4280
4873
|
h('br'),
|
|
4281
|
-
'存完点「让 AI
|
|
4874
|
+
'存完点「让 AI 读一读」,它会把里面的信息整理成一张卡。只认原文里写过的,编的会被丢掉。'),
|
|
4282
4875
|
h(Field, { label: '这是要建成什么人' },
|
|
4283
4876
|
h('div', { className: 'lc-row' },
|
|
4284
4877
|
h(Btn, { size: 'sm', tone: kind === 'real' ? 'interview' : undefined, onClick: () => setKind('real') }, '现实里的人'),
|
|
@@ -4471,7 +5064,7 @@ function peopleKey(p) {
|
|
|
4471
5064
|
|
|
4472
5065
|
// ── 让 AI 读材料 ──────────────────────────────────────────────────
|
|
4473
5066
|
|
|
4474
|
-
function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMaterials, onBack, onNeedMaterial }) {
|
|
5067
|
+
function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMaterials, onBack, onNeedMaterial, onAppliedCard }) {
|
|
4475
5068
|
const materials = listing?.materials ?? []
|
|
4476
5069
|
const cards = listing?.cards ?? []
|
|
4477
5070
|
// 从别的页带过来的人,可能只有一个名字:材料刚存下、卡还没建。
|
|
@@ -4480,7 +5073,13 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4480
5073
|
const presetId = String(presetAbout?.id ?? '').trim()
|
|
4481
5074
|
const presetName = String(presetAbout?.name ?? '').trim()
|
|
4482
5075
|
const [about, setAbout] = useState(presetId !== '' ? presetId : presetName)
|
|
4483
|
-
|
|
5076
|
+
// 带过来的材料可能是一份,也可能是好几份(访谈一路上记下来的)。
|
|
5077
|
+
// 不认数组的话会变成「[一份, 另一份]」被当成一个路径,一条都勾不上,
|
|
5078
|
+
// 而一条都不勾等于全读。
|
|
5079
|
+
const [chosen, setChosen] = useState(() => {
|
|
5080
|
+
if (Array.isArray(presetMaterials)) return presetMaterials.filter((x) => typeof x === 'string' && x !== '')
|
|
5081
|
+
return presetMaterials === undefined || presetMaterials === null ? [] : [presetMaterials]
|
|
5082
|
+
})
|
|
4484
5083
|
const [busy, setBusy] = useState(false)
|
|
4485
5084
|
const [err, setErr] = useState(null)
|
|
4486
5085
|
const [result, setResult] = useState(null)
|
|
@@ -4514,12 +5113,28 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4514
5113
|
// 卸载时补存一次,别让刚想出来的那句话白写。
|
|
4515
5114
|
const conceptDraftRef = useRef(null)
|
|
4516
5115
|
conceptDraftRef.current = conceptDraft
|
|
5116
|
+
// 下面这两个特意放在所有 hook 之后:上面那些槽位是有测试按号码摆状态的,
|
|
5117
|
+
// 中间插一个新 state 会让它们全体错位(conceptDraft 就是被这么挪过一次)。
|
|
5118
|
+
const [oneClick, setOneClick] = useState('')
|
|
5119
|
+
// 这一趟读的是虚构角色还是真人:虚构的才给「直接建成卡」,
|
|
5120
|
+
// 真人的卡要人自己填关系那些,不能一键生成。
|
|
5121
|
+
const isFiction = String(presetAbout?.kind ?? '').trim() === 'fictional'
|
|
4517
5122
|
useEffect(() => () => {
|
|
4518
5123
|
const pending = conceptDraftRef.current
|
|
4519
5124
|
if (pending === null || pending === undefined) return
|
|
4520
5125
|
call('concept.set', { concept: pending }).catch(() => {})
|
|
4521
5126
|
}, [])
|
|
4522
5127
|
|
|
5128
|
+
// 这一趟到底等了多久,明明白白数给人看。
|
|
5129
|
+
// 只说「正在读…」的话,超过半分钟人就开始怀疑卡死,去点第二次;
|
|
5130
|
+
// 两个请求叠在一起,后台更慢,看起来就更像卡死。
|
|
5131
|
+
const [waited, setWaited] = useState(0)
|
|
5132
|
+
useEffect(() => {
|
|
5133
|
+
if (busy !== true) { setWaited(0); return undefined }
|
|
5134
|
+
const timer = setInterval(() => setWaited((n) => n + 1), 1000)
|
|
5135
|
+
return () => clearInterval(timer)
|
|
5136
|
+
}, [busy])
|
|
5137
|
+
|
|
4523
5138
|
const flushConcept = async (value) => {
|
|
4524
5139
|
const next = value ?? conceptDraft ?? concept
|
|
4525
5140
|
setConceptDraft(null)
|
|
@@ -4559,6 +5174,11 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4559
5174
|
})
|
|
4560
5175
|
}
|
|
4561
5176
|
|
|
5177
|
+
// 选了哪几份就按哪几份算字数:「一条都不勾 = 全读」。
|
|
5178
|
+
const readChars = (chosen.length > 0 ? materials.filter((m) => chosen.includes(m.path)) : materials)
|
|
5179
|
+
.reduce((sum, m) => sum + (Number(m.chars) || Number(m.size) || 0), 0)
|
|
5180
|
+
const longRead = readChars > 20000
|
|
5181
|
+
|
|
4562
5182
|
const run = async () => {
|
|
4563
5183
|
// 「这一趟等了多久」从这里算。以前写成 startedAt 却没有声明,后台一连不上
|
|
4564
5184
|
// 就在 catch 里抛 ReferenceError,setErr 永远走不到——用户只看到按钮弹回来,
|
|
@@ -4700,14 +5320,45 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4700
5320
|
h(Btn, { tone: 'analyze', big: true, onClick: run, disabled: busy || about === '' || materials.length === 0, icon: 'spark' }, busy ? '正在读…' : '开始读'),
|
|
4701
5321
|
busy ? h(Spinner) : null,
|
|
4702
5322
|
),
|
|
5323
|
+
// 这一趟是真等:模型先在心里想一遍再写(实测小材料也要四十秒上下,
|
|
5324
|
+
// 长材料一分多钟)。不给时间预期的话,转圈超过半分钟人就会以为卡死了、
|
|
5325
|
+
// 去点第二次,两个请求叠在一起更慢。
|
|
5326
|
+
busy
|
|
5327
|
+
? h('div', { className: 'lc-meta' }, longRead
|
|
5328
|
+
? `材料有点长(约 ${Math.round(readChars / 1000)} 千字),这一趟可能一两分钟。已经等了 ${waited} 秒,别关这一页,等它说完。`
|
|
5329
|
+
: `它在先想一遍再写,一般要半分钟到一分钟。已经等了 ${waited} 秒,别关这一页,等它说完。`)
|
|
5330
|
+
: null,
|
|
4703
5331
|
|
|
4704
5332
|
result === null ? null : h('div', null,
|
|
4705
5333
|
h(Note, { tone: 'ok', icon: 'check' },
|
|
4706
5334
|
h('div', null, `读出了 ${result.accepted} 条。`),
|
|
4707
5335
|
result.rejected > 0 ? h('div', { className: 'lc-meta', style: { marginTop: 3 } }, `另有 ${result.rejected} 条在材料里找不到原句,已经丢掉——AI 编的东西进不来。`) : null,
|
|
4708
5336
|
),
|
|
5337
|
+
// 写小说和酒馆里,用户要的是「读完就有一张能用的角色卡」,
|
|
5338
|
+
// 不是再去访谈一遍——虚构角色本来就没有「他是我的什么人」这种问题,
|
|
5339
|
+
// 那些细节它自己会从材料里编。所以这里直接给一个一键建卡的入口。
|
|
5340
|
+
isFiction && result.json !== undefined
|
|
5341
|
+
? h('div', { style: { marginTop: 8 } },
|
|
5342
|
+
h(Btn, { tone: 'primary', big: true, block: true, icon: 'check', disabled: oneClick !== '', onClick: async () => {
|
|
5343
|
+
setOneClick('on')
|
|
5344
|
+
try {
|
|
5345
|
+
// 提案里默认一条都不勾(勾选是人的动作),所以这里要先把
|
|
5346
|
+
// 所有条目都勾上再采纳,否则只会得到「还没有勾选任何一条」。
|
|
5347
|
+
const got = await call('proposal.get', { json: result.json })
|
|
5348
|
+
if (got?.ok !== true) { setErr(got?.error ?? '这份建议读不出来'); return }
|
|
5349
|
+
const every = (got.items ?? []).map((item) => item.index).filter((n) => typeof n === 'number')
|
|
5350
|
+
if (every.length === 0) { setErr('这一份里没有能写进卡里的条目。'); return }
|
|
5351
|
+
const r = await call('proposal.apply', { json: result.json, checked: every })
|
|
5352
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没能建成卡'); return }
|
|
5353
|
+
flash(`建好了,写进去 ${r.applied ?? every.length} 条`)
|
|
5354
|
+
if (typeof onAppliedCard === 'function' && r.path !== undefined) onAppliedCard(r.path)
|
|
5355
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setOneClick('') }
|
|
5356
|
+
} }, oneClick === 'on' ? '正在建…' : '直接建成角色卡'),
|
|
5357
|
+
h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '不用再访谈一遍。材料里读到什么就写什么,建完还能在卡上改。'),
|
|
5358
|
+
)
|
|
5359
|
+
: null,
|
|
4709
5360
|
h('div', { style: { marginTop: 8 } },
|
|
4710
|
-
h(Btn, { tone: 'primary', big: true, block: true, icon: 'check', onClick: async () => {
|
|
5361
|
+
h(Btn, { tone: isFiction ? undefined : 'primary', big: true, block: true, icon: 'check', onClick: async () => {
|
|
4711
5362
|
try {
|
|
4712
5363
|
const p = await call('proposal.get', { json: result.json })
|
|
4713
5364
|
// 读不出来当「没有新东西」会让界面绿字说「这一份里没有还没写进去的了」,
|
|
@@ -4867,6 +5518,7 @@ function JobsView({ onBack, flash }) {
|
|
|
4867
5518
|
// 这一页开头要读好几样东西(求职设置、以前读过的、投递表、故事)。
|
|
4868
5519
|
// 读失败时以前是静默吞掉——界面上就是「一片空」,跟「你还没填」长得一样。
|
|
4869
5520
|
const [loadFail, setLoadFail] = useState(false)
|
|
5521
|
+
const [savingStory, setSavingStory] = useState(false)
|
|
4870
5522
|
|
|
4871
5523
|
useEffect(() => {
|
|
4872
5524
|
let alive = true
|
|
@@ -5164,14 +5816,28 @@ function JobsView({ onBack, flash }) {
|
|
|
5164
5816
|
h('input', { className: 'lc-input', value: newStoryTitle, onChange: (e) => setNewStoryTitle(e.target.value), placeholder: '一次线上事故', spellCheck: false }),
|
|
5165
5817
|
),
|
|
5166
5818
|
h('textarea', { className: 'lc-textarea', value: newStoryBody, onChange: (e) => setNewStoryBody(e.target.value), placeholder: '凌晨两点被告警叫起来…', spellCheck: false }),
|
|
5167
|
-
h(Btn, {
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5819
|
+
h(Btn, {
|
|
5820
|
+
size: 'sm', icon: 'plus',
|
|
5821
|
+
disabled: savingStory || newStoryTitle.trim() === '' || newStoryBody.trim() === '',
|
|
5822
|
+
onClick: async () => {
|
|
5823
|
+
// 以前这里没有 try/catch:后台断了点它一声不响,用户只会以为按钮坏了;
|
|
5824
|
+
// 也没有忙等,网络慢连点两下会存出两条同名的记录。
|
|
5825
|
+
if (savingStory) return
|
|
5826
|
+
setSavingStory(true); setErr(null)
|
|
5827
|
+
try {
|
|
5828
|
+
const r = await call('job.story.add', { story: { title: newStoryTitle.trim(), body: newStoryBody.trim() } })
|
|
5829
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
|
|
5830
|
+
setNewStoryTitle(''); setNewStoryBody('')
|
|
5831
|
+
const again = await call('job.stories', {})
|
|
5832
|
+
if (again?.ok === true) setStories(again.stories ?? [])
|
|
5833
|
+
flash('记下来了')
|
|
5834
|
+
} catch (e) {
|
|
5835
|
+
setErr(humanizeError(e))
|
|
5836
|
+
} finally {
|
|
5837
|
+
setSavingStory(false)
|
|
5838
|
+
}
|
|
5839
|
+
},
|
|
5840
|
+
}, savingStory ? '正在存…' : '存下这段'),
|
|
5175
5841
|
),
|
|
5176
5842
|
),
|
|
5177
5843
|
|