dsh-lifecard 0.1.1 → 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 +1121 -67
- package/lib/game.js +373 -0
- package/lib/index.js +816 -7
- package/lib/novel.js +567 -0
- package/lib/presets.js +424 -0
- package/lib/store.js +86 -15
- package/lib/tavern.js +56 -1
- package/package.json +6 -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', 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)。
|
|
@@ -950,13 +950,40 @@ function LifeCardTab(props) {
|
|
|
950
950
|
// 按号码摆状态用着,中间插一个会让那一整排全部错位。
|
|
951
951
|
const [chat, setChat] = useState(null)
|
|
952
952
|
const [chats, setChats] = useState([])
|
|
953
|
+
// 回到「写小说」那扇门就重新拿一遍书架:刚写完一章、刚新建一本再回来,
|
|
954
|
+
// 还挂着进这一屏之前的旧列表,用户会以为刚才那一步没生效。
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
if (view !== VIEWS.novel) return
|
|
957
|
+
let gone = false
|
|
958
|
+
call('novel.books', {})
|
|
959
|
+
.then((r) => { if (!gone && r?.ok === true && Array.isArray(r.books)) setBooks(r.books) })
|
|
960
|
+
.catch(() => { /* 取不到就空着,不影响新建 */ })
|
|
961
|
+
return () => { gone = true }
|
|
962
|
+
}, [view])
|
|
963
|
+
|
|
953
964
|
// 「我现在在哪扇门里」必须自己记住,不能靠「当前在哪一屏」去猜。
|
|
954
965
|
// 以前 mode 是从 view 推的:一进「聊着建」,当前屏就变成 interview,
|
|
955
966
|
// 于是不管从哪扇门进来,都退回 real——三扇门点进去全都说
|
|
956
967
|
// 「你在记录一个现实里的人」。子页面不是门,门要跟着人走。
|
|
957
968
|
const [door, setDoor] = useState('real')
|
|
969
|
+
// 写到第几本书(book 视图用)。null = 还没打开任何一本。
|
|
970
|
+
const [activeBook, setActiveBook] = useState(null)
|
|
971
|
+
// 书架上的书(写小说那扇门要列出来)
|
|
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
|
+
}, [])
|
|
958
985
|
// 门自己的那两屏直接说了算;其余屏(聊着建、卡片、分析…)沿用上次选的门。
|
|
959
|
-
const mode = view === VIEWS.novel ? '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
|
|
960
987
|
// 回到酒馆那两屏就重新拿一遍说过的场次:刚说完、刚删完再回来,
|
|
961
988
|
// 还挂着进这一屏之前的旧列表,用户会以为刚才那一步没生效。
|
|
962
989
|
useEffect(() => {
|
|
@@ -968,6 +995,25 @@ function LifeCardTab(props) {
|
|
|
968
995
|
return () => { gone = true }
|
|
969
996
|
}, [view])
|
|
970
997
|
|
|
998
|
+
/** 打开一本书,进到那一屏。 */
|
|
999
|
+
const openBook = (book) => {
|
|
1000
|
+
setActiveBook({ id: book?.id ?? '', title: book?.title ?? '' })
|
|
1001
|
+
setView(VIEWS.book)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** 新开一本:问一个书名就够,别的都能后面补。 */
|
|
1005
|
+
const createBook = async () => {
|
|
1006
|
+
const ask = typeof window !== 'undefined' && typeof window.prompt === 'function' ? window.prompt : null
|
|
1007
|
+
const title = ask === null ? '新书' : String(ask('这本书叫什么?') ?? '').trim()
|
|
1008
|
+
if (title === '') return
|
|
1009
|
+
try {
|
|
1010
|
+
const r = await call('novel.create', { title })
|
|
1011
|
+
if (r?.ok !== true) { setError(r?.error ?? '这本书没能建起来'); return }
|
|
1012
|
+
setActiveBook({ id: r.id, title: r.title })
|
|
1013
|
+
setView(VIEWS.book)
|
|
1014
|
+
} catch (e) { setError(humanizeError(e)) }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
971
1017
|
const pickDoor = (next) => {
|
|
972
1018
|
setDoor(next)
|
|
973
1019
|
setError(null)
|
|
@@ -1036,7 +1082,7 @@ function LifeCardTab(props) {
|
|
|
1036
1082
|
try {
|
|
1037
1083
|
const r = await call('tavern.get', { id: row?.id })
|
|
1038
1084
|
if (r?.ok !== true) throw new Error(r?.error ?? '这一场打不开')
|
|
1039
|
-
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 })
|
|
1040
1086
|
setView(VIEWS.tavernChat)
|
|
1041
1087
|
} catch (e) {
|
|
1042
1088
|
setError(humanizeError(e))
|
|
@@ -1051,7 +1097,7 @@ function LifeCardTab(props) {
|
|
|
1051
1097
|
try {
|
|
1052
1098
|
const r = await call('tavern.start', { card: card?.path ?? String(card?.id ?? '') })
|
|
1053
1099
|
if (r?.ok !== true) throw new Error(r?.error ?? '这一场没开起来')
|
|
1054
|
-
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 })
|
|
1055
1101
|
setView(VIEWS.tavernChat)
|
|
1056
1102
|
} catch (e) {
|
|
1057
1103
|
setError(humanizeError(e))
|
|
@@ -1080,6 +1126,9 @@ function LifeCardTab(props) {
|
|
|
1080
1126
|
[VIEWS.doors]: '你要用哪个?',
|
|
1081
1127
|
[VIEWS.home]: '真实人物记录',
|
|
1082
1128
|
[VIEWS.novel]: '写小说',
|
|
1129
|
+
[VIEWS.game]: '玩一段',
|
|
1130
|
+
[VIEWS.career]: '简历与规划',
|
|
1131
|
+
[VIEWS.book]: activeBook?.title === undefined || String(activeBook.title).trim() === '' ? '这本书' : String(activeBook.title),
|
|
1083
1132
|
[VIEWS.tavern]: '酒馆',
|
|
1084
1133
|
[VIEWS.tavernChat]: chat === null ? '聊一场' : (String(chat.card_name ?? '').trim() === '' ? '聊一场' : String(chat.card_name)),
|
|
1085
1134
|
[VIEWS.card]: activeCard?.data?.name ?? '这张卡',
|
|
@@ -1151,7 +1200,7 @@ function LifeCardTab(props) {
|
|
|
1151
1200
|
})
|
|
1152
1201
|
}
|
|
1153
1202
|
// 门还没选:先问「你要用哪个」。选完才进首页,首页再按门决定露出什么。
|
|
1154
|
-
if (view === VIEWS.doors) return h(DoorsView, { state, onPick: pickDoor })
|
|
1203
|
+
if (view === VIEWS.doors) return h(DoorsView, { state, features, onPick: pickDoor })
|
|
1155
1204
|
if (view === VIEWS.tavernChat) {
|
|
1156
1205
|
return h(TavernView, {
|
|
1157
1206
|
chat,
|
|
@@ -1184,7 +1233,12 @@ function LifeCardTab(props) {
|
|
|
1184
1233
|
return h(InterviewView, {
|
|
1185
1234
|
state, listing, preset: interviewFor, onRecorded: refresh, flash, mode,
|
|
1186
1235
|
onDone: () => { setInterviewFor(null); setMaterialFor(null); setView(VIEWS.home); refresh() },
|
|
1187
|
-
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
|
+
},
|
|
1188
1242
|
})
|
|
1189
1243
|
case VIEWS.memory:
|
|
1190
1244
|
// 提取完直接跳到「让 AI 读」,省掉用户自己再想「我刚存的材料去哪了」。
|
|
@@ -1206,6 +1260,14 @@ function LifeCardTab(props) {
|
|
|
1206
1260
|
onProposal: showProposal,
|
|
1207
1261
|
onBack: () => { setInterviewFor(null); setMaterialFor(null); setView(VIEWS.home) },
|
|
1208
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
|
+
},
|
|
1209
1271
|
})
|
|
1210
1272
|
case VIEWS.card:
|
|
1211
1273
|
// 首页那条「还等着你看的建议」点进来时,如果这个人还没有卡(很正常:
|
|
@@ -1213,7 +1275,7 @@ function LifeCardTab(props) {
|
|
|
1213
1275
|
// 直接退回首页,用户点了没反应,还以为坏了。现在把清单单独摊开。
|
|
1214
1276
|
return activeCard === null
|
|
1215
1277
|
? proposal === null
|
|
1216
|
-
? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard })
|
|
1278
|
+
? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard, books, onOpenBook: openBook, onCreateBook: createBook })
|
|
1217
1279
|
: h('div', { className: 'lc-pad lc-stack' },
|
|
1218
1280
|
proposal.about === undefined || String(proposal.about).trim() === ''
|
|
1219
1281
|
? null
|
|
@@ -1272,18 +1334,32 @@ function LifeCardTab(props) {
|
|
|
1272
1334
|
})
|
|
1273
1335
|
case VIEWS.world:
|
|
1274
1336
|
return activeWorld === null
|
|
1275
|
-
? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard })
|
|
1337
|
+
? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard, books, onOpenBook: openBook, onCreateBook: createBook })
|
|
1276
1338
|
: h(WorldView, {
|
|
1277
1339
|
world: activeWorld,
|
|
1278
1340
|
listing,
|
|
1279
1341
|
onOpenCard: openCard,
|
|
1280
1342
|
})
|
|
1343
|
+
case VIEWS.game:
|
|
1344
|
+
return h(GameView, {
|
|
1345
|
+
bookId: String(activeBook?.id ?? ''),
|
|
1346
|
+
flash,
|
|
1347
|
+
onBack: () => { setActiveBook(null); setView(VIEWS.novel) },
|
|
1348
|
+
})
|
|
1349
|
+
case VIEWS.book:
|
|
1350
|
+
return h(BookView, {
|
|
1351
|
+
bookId: String(activeBook?.id ?? ''),
|
|
1352
|
+
flash,
|
|
1353
|
+
onBack: () => { setActiveBook(null); setView(VIEWS.novel) },
|
|
1354
|
+
})
|
|
1281
1355
|
case VIEWS.doctor:
|
|
1282
1356
|
return h(DoctorView, { report: doctor, onOpenCard: openCard, onRun: () => runDoctor(true) })
|
|
1357
|
+
case VIEWS.career:
|
|
1358
|
+
return h(CareerView, { flash, onBack: () => setView(VIEWS.home) })
|
|
1283
1359
|
case VIEWS.jobs:
|
|
1284
1360
|
return h(JobsView, { onBack: () => setView(VIEWS.home), flash })
|
|
1285
1361
|
default:
|
|
1286
|
-
return h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard })
|
|
1362
|
+
return h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard, books, onOpenBook: openBook, onCreateBook: createBook })
|
|
1287
1363
|
}
|
|
1288
1364
|
}
|
|
1289
1365
|
|
|
@@ -1396,15 +1472,37 @@ const DOORS = [
|
|
|
1396
1472
|
},
|
|
1397
1473
|
]
|
|
1398
1474
|
|
|
1399
|
-
|
|
1475
|
+
/**
|
|
1476
|
+
* 进门那一屏:三扇**立着的门**。
|
|
1477
|
+
*
|
|
1478
|
+
* 为什么做成门的样子而不是三张卡片:卡片是「选一个设置」,门是「走进去」。
|
|
1479
|
+
* 这一屏是这个插件唯一的分岔口,用户在这儿做的决定(记真人 / 写小说 / 演)
|
|
1480
|
+
* 会决定后面所有屏长什么样,值得给一个够重的画面。
|
|
1481
|
+
*
|
|
1482
|
+
* 门该有的东西:门框、门楣、门牌、把手、门槛。这里都做了——
|
|
1483
|
+
* 顶上一条门楣色块、中间竖着写门名、底部一道门槛写着「推门进去」。
|
|
1484
|
+
* 三扇并排、各占三分之一宽、高度撑满;窄窗口自动换行。
|
|
1485
|
+
*/
|
|
1486
|
+
function DoorsView({ onPick, state, features }) {
|
|
1400
1487
|
const workspace = String(state?.workspace ?? '')
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1403
|
-
|
|
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))
|
|
1404
1492
|
const tint = {
|
|
1405
|
-
real: {
|
|
1406
|
-
|
|
1407
|
-
|
|
1493
|
+
real: {
|
|
1494
|
+
fg: 'var(--lc-interview)', bg: 'var(--lc-interview-soft)', icon: 'people',
|
|
1495
|
+
// 门里那点光:不一样的暖冷,一眼能分出来
|
|
1496
|
+
glow: 'rgba(156, 123, 245, .18)',
|
|
1497
|
+
},
|
|
1498
|
+
novel: {
|
|
1499
|
+
fg: 'var(--lc-import)', bg: 'var(--lc-import-soft)', icon: 'book',
|
|
1500
|
+
glow: 'rgba(59, 201, 219, .18)',
|
|
1501
|
+
},
|
|
1502
|
+
tavern: {
|
|
1503
|
+
fg: 'var(--lc-analyze)', bg: 'var(--lc-analyze-soft)', icon: 'chat',
|
|
1504
|
+
glow: 'rgba(232, 185, 59, .18)',
|
|
1505
|
+
},
|
|
1408
1506
|
}
|
|
1409
1507
|
return h('div', {
|
|
1410
1508
|
style: {
|
|
@@ -1412,18 +1510,18 @@ function DoorsView({ onPick, state }) {
|
|
|
1412
1510
|
minHeight: '100%', boxSizing: 'border-box', padding: 12,
|
|
1413
1511
|
},
|
|
1414
1512
|
},
|
|
1415
|
-
h('div', { style: { flex: '0 0 auto', paddingBottom:
|
|
1416
|
-
h('h2', { style: { margin: '2px 0 0', fontSize: 22 } }, '你要用哪个?'),
|
|
1417
|
-
h('div', { className: 'lc-meta', style: { marginTop:
|
|
1513
|
+
h('div', { style: { flex: '0 0 auto', paddingBottom: 12 } },
|
|
1514
|
+
h('h2', { style: { margin: '2px 0 0', fontSize: 22, letterSpacing: '-.01em' } }, '你要用哪个?'),
|
|
1515
|
+
h('div', { className: 'lc-meta', style: { marginTop: 5 } }, '推一扇门进去。随时能换——卡和世界书都在同一个文件夹里。'),
|
|
1418
1516
|
),
|
|
1419
|
-
//
|
|
1517
|
+
// 三扇门横向并排;窄窗口自动换行(flexWrap),免得挤成三条缝
|
|
1420
1518
|
h('div', {
|
|
1421
1519
|
style: {
|
|
1422
1520
|
flex: '1 1 auto', display: 'flex', gap: 10,
|
|
1423
|
-
alignItems: 'stretch', flexWrap: 'wrap', minHeight:
|
|
1521
|
+
alignItems: 'stretch', flexWrap: 'wrap', minHeight: 280,
|
|
1424
1522
|
},
|
|
1425
1523
|
},
|
|
1426
|
-
|
|
1524
|
+
doors.map((door) => {
|
|
1427
1525
|
const t = tint[door.id] ?? tint.real
|
|
1428
1526
|
return h('button', {
|
|
1429
1527
|
key: door.id,
|
|
@@ -1432,50 +1530,84 @@ function DoorsView({ onPick, state }) {
|
|
|
1432
1530
|
onClick: () => onPick(door.id),
|
|
1433
1531
|
style: {
|
|
1434
1532
|
// 每扇门平分宽度、各自撑满整高(门是竖的,高度要顶天立地)
|
|
1435
|
-
flex: '1 1 30%', minWidth:
|
|
1436
|
-
display: 'flex', flexDirection: 'column', alignItems: '
|
|
1437
|
-
|
|
1438
|
-
padding: '18px 12px', textAlign: 'center', cursor: 'pointer',
|
|
1533
|
+
flex: '1 1 30%', minWidth: 156, minHeight: 260,
|
|
1534
|
+
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
|
|
1535
|
+
gap: 0, padding: 0, textAlign: 'center', cursor: 'pointer',
|
|
1439
1536
|
border: '1px solid var(--lc-line)', borderRadius: 16,
|
|
1440
|
-
|
|
1441
|
-
|
|
1537
|
+
// 门里透出来的光:上深下浅,像门后头有灯
|
|
1538
|
+
background: `linear-gradient(180deg, ${t.glow} 0%, var(--lc-panel) 55%, var(--lc-panel) 100%)`,
|
|
1539
|
+
boxShadow: 'var(--lc-shadow)', font: 'inherit', color: 'inherit',
|
|
1540
|
+
overflow: 'hidden', position: 'relative',
|
|
1442
1541
|
},
|
|
1443
1542
|
},
|
|
1444
|
-
//
|
|
1543
|
+
// ── 门楣:顶上那条色带 ──
|
|
1445
1544
|
h('div', {
|
|
1446
1545
|
style: {
|
|
1447
|
-
flex: '0 0 auto',
|
|
1448
|
-
|
|
1449
|
-
|
|
1546
|
+
flex: '0 0 auto', height: 6, width: '100%',
|
|
1547
|
+
background: t.fg, opacity: .85,
|
|
1548
|
+
},
|
|
1549
|
+
}),
|
|
1550
|
+
// ── 门牌:图标 + 门名 ──
|
|
1551
|
+
h('div', {
|
|
1552
|
+
style: {
|
|
1553
|
+
flex: '0 0 auto', display: 'flex', flexDirection: 'column',
|
|
1554
|
+
alignItems: 'center', gap: 8, padding: '16px 12px 10px',
|
|
1555
|
+
},
|
|
1556
|
+
},
|
|
1557
|
+
h('div', {
|
|
1558
|
+
style: {
|
|
1559
|
+
width: 50, height: 50, borderRadius: 14,
|
|
1560
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1561
|
+
background: t.bg, color: t.fg,
|
|
1562
|
+
border: `1px solid ${t.fg}`, boxSizing: 'border-box',
|
|
1563
|
+
},
|
|
1564
|
+
}, h(Icon, { name: t.icon, size: 25 })),
|
|
1565
|
+
h('div', { style: { fontSize: 16.5, fontWeight: 700, lineHeight: 1.3 } }, door.title),
|
|
1566
|
+
h('div', { className: 'lc-meta', style: { lineHeight: 1.6, padding: '0 2px' } }, door.desc),
|
|
1567
|
+
),
|
|
1568
|
+
// ── 门里有什么 ──
|
|
1569
|
+
h('div', {
|
|
1570
|
+
style: {
|
|
1571
|
+
flex: '1 1 auto', display: 'flex', flexDirection: 'column',
|
|
1572
|
+
gap: 4, padding: '0 12px 10px', textAlign: 'left', justifyContent: 'center',
|
|
1450
1573
|
},
|
|
1451
|
-
}, h(Icon, { name: t.icon, size: 26 })),
|
|
1452
|
-
h('div', { style: { fontSize: 17.5, fontWeight: 700, lineHeight: 1.35 } }, door.title),
|
|
1453
|
-
h('div', { className: 'lc-meta', style: { lineHeight: 1.6 } }, door.desc),
|
|
1454
|
-
h('div', { style: { flex: '1 1 auto' } }),
|
|
1455
|
-
(door.bullets ?? []).length === 0 ? null : h('div', {
|
|
1456
|
-
style: { display: 'flex', flexDirection: 'column', gap: 3, textAlign: 'left', width: '100%' },
|
|
1457
1574
|
},
|
|
1458
|
-
door.bullets.map((line, i) => h('div', {
|
|
1575
|
+
(door.bullets ?? []).map((line, i) => h('div', {
|
|
1459
1576
|
key: i,
|
|
1460
1577
|
className: 'lc-meta',
|
|
1461
|
-
style: { display: 'flex', gap:
|
|
1462
|
-
},
|
|
1463
|
-
|
|
1578
|
+
style: { display: 'flex', gap: 6, lineHeight: 1.55, alignItems: 'flex-start' },
|
|
1579
|
+
},
|
|
1580
|
+
h('span', { style: { color: t.fg, flex: '0 0 auto', fontWeight: 700 } }, '·'),
|
|
1581
|
+
h('span', null, line),
|
|
1582
|
+
)),
|
|
1583
|
+
),
|
|
1584
|
+
// ── 门槛:底部那条,写着推门 ──
|
|
1464
1585
|
h('div', {
|
|
1465
1586
|
style: {
|
|
1466
|
-
flex: '0 0 auto', marginTop:
|
|
1587
|
+
flex: '0 0 auto', marginTop: 'auto',
|
|
1467
1588
|
borderTop: '1px solid var(--lc-line)',
|
|
1468
|
-
|
|
1589
|
+
padding: '10px 8px', width: '100%', boxSizing: 'border-box',
|
|
1590
|
+
color: t.fg, fontWeight: 700, fontSize: 13.5,
|
|
1591
|
+
background: t.bg,
|
|
1469
1592
|
},
|
|
1470
1593
|
}, '推门进去 →'),
|
|
1594
|
+
// ── 把手:右下角一个小圆点,门的那个意思 ──
|
|
1595
|
+
h('div', {
|
|
1596
|
+
'aria-hidden': 'true',
|
|
1597
|
+
style: {
|
|
1598
|
+
position: 'absolute', right: 10, bottom: 52,
|
|
1599
|
+
width: 7, height: 7, borderRadius: 999,
|
|
1600
|
+
background: t.fg, opacity: .55,
|
|
1601
|
+
},
|
|
1602
|
+
}),
|
|
1471
1603
|
)
|
|
1472
1604
|
}),
|
|
1473
1605
|
),
|
|
1474
|
-
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 } }, '档案就在你选的那个文件夹里'),
|
|
1475
1607
|
)
|
|
1476
1608
|
}
|
|
1477
1609
|
|
|
1478
|
-
function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onProposal, busy, onDoDoctor, showMore, onMore, intro, onIntroDone, onMaterial, onDeleteMaterial, chats, onOpenChat, onForkCard, onDeleteCard }) {
|
|
1610
|
+
function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onProposal, busy, onDoDoctor, showMore, onMore, intro, onIntroDone, onMaterial, onDeleteMaterial, chats, onOpenChat, onForkCard, onDeleteCard, books, onOpenBook, onCreateBook }) {
|
|
1479
1611
|
// 这扇门是不是「真实人物记录」。写小说、酒馆那两扇门里不该出现
|
|
1480
1612
|
// 「贴一份招聘说明」「从记忆里认人」这类动作——那是记录真人才用得上的。
|
|
1481
1613
|
const isReal = mode !== 'novel' && mode !== 'tavern'
|
|
@@ -1493,6 +1625,17 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1493
1625
|
// 用户每次回首页都以为自己还有事没做。
|
|
1494
1626
|
const allProposals = listing?.proposals ?? []
|
|
1495
1627
|
const proposals = allProposals.filter((p) => (p.adopted ?? 0) < (p.accepted ?? 0))
|
|
1628
|
+
// 「还等着你看的建议」只在**它属于的那扇门**里出现。
|
|
1629
|
+
//
|
|
1630
|
+
// 以前这是个跨屏提醒:在酒馆里写着小说,头顶一直挂着「关于『妈妈』的建议」;
|
|
1631
|
+
// 记录真人的时候又挂着小说角色的建议。建议本身没错,错在它不属于这一屏。
|
|
1632
|
+
// 判据是这条建议说的是谁——那个人有卡就按卡的 kind,没卡(还没建卡)算现实。
|
|
1633
|
+
const pendingProposals = proposals.filter((p) => {
|
|
1634
|
+
const name = String(p.about ?? '').trim()
|
|
1635
|
+
const hit = name === '' ? undefined : cards.find((c) => String(c.id ?? '') === name || String(c.name ?? '').trim() === name)
|
|
1636
|
+
const isFiction = hit !== undefined && hit.kind === 'fictional'
|
|
1637
|
+
return isFiction === (mode !== 'real')
|
|
1638
|
+
})
|
|
1496
1639
|
const materials = listing?.materials ?? []
|
|
1497
1640
|
// 搜索也要筛材料:搜一个人的名字,上面卡筛掉了、下面材料还整整齐齐列着,
|
|
1498
1641
|
// 看着像「搜到了这一份」,其实根本没筛。
|
|
@@ -1612,6 +1755,9 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1612
1755
|
isReal
|
|
1613
1756
|
? h(Btn, { tone: 'primary', size: 'sm', icon: 'briefcase', onClick: () => onNavigate(VIEWS.jobs), title: '贴一份招聘说明,看看该不该投;或者练一轮面试' }, '下一步')
|
|
1614
1757
|
: null,
|
|
1758
|
+
isReal
|
|
1759
|
+
? h(Btn, { size: 'sm', icon: 'book', onClick: () => onNavigate(VIEWS.career), title: '把自己的情况写清楚,让 AI 帮你看简历、算差距、规划怎么走' }, '简历与规划')
|
|
1760
|
+
: null,
|
|
1615
1761
|
h('div', { className: 'lc-grow' }),
|
|
1616
1762
|
// 「贴一段文章让 AI 读」不是只有记录真人才用得上:写小说、酒馆里
|
|
1617
1763
|
// 手里有一段人物设定/小说片段,一样想让它读成人设。所以三扇门都给。
|
|
@@ -1620,6 +1766,67 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1620
1766
|
: null,
|
|
1621
1767
|
),
|
|
1622
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
|
+
|
|
1800
|
+
// 写小说那扇门:先列书架。没有它,用户建了一本书再回来就找不到了。
|
|
1801
|
+
mode === 'novel'
|
|
1802
|
+
? h('div', null,
|
|
1803
|
+
h(GroupTitle, { tone: 'import', icon: 'book' }, `我的书 · ${(books ?? []).length}`),
|
|
1804
|
+
(books ?? []).length === 0
|
|
1805
|
+
? h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } },
|
|
1806
|
+
'还没有书。新建一本,让 AI 先想大纲,再一章一章写。')
|
|
1807
|
+
: h('div', { className: 'lc-stack' }, (books ?? []).map((b) => h('button', {
|
|
1808
|
+
key: b.id,
|
|
1809
|
+
className: 'lc-card-item',
|
|
1810
|
+
type: 'button',
|
|
1811
|
+
onClick: () => onOpenBook?.(b),
|
|
1812
|
+
style: { textAlign: 'left', font: 'inherit', color: 'inherit', width: '100%' },
|
|
1813
|
+
},
|
|
1814
|
+
h('div', { className: 'lc-avatar', style: { background: 'var(--lc-import-soft)', color: 'var(--lc-import)' } }, h(Icon, { name: 'book', size: 14 })),
|
|
1815
|
+
h('div', { className: 'lc-grow', style: { minWidth: 0 } },
|
|
1816
|
+
h('div', { className: 'lc-ellipsis', style: { fontWeight: 650 } }, String(b.title ?? b.id)),
|
|
1817
|
+
h('div', { className: 'lc-meta' },
|
|
1818
|
+
[`${b.chapters ?? 0} 章`, `${Math.max(1, Math.round((b.words ?? 0) / 1000))} 千字`]
|
|
1819
|
+
.concat(String(b.genre ?? '').trim() === '' ? [] : [String(b.genre)])
|
|
1820
|
+
.join(' · ')),
|
|
1821
|
+
String(b.idea ?? '').trim() === '' ? null : h('div', { className: 'lc-meta lc-ellipsis' }, String(b.idea)),
|
|
1822
|
+
),
|
|
1823
|
+
))),
|
|
1824
|
+
h('div', { className: 'lc-row', style: { marginTop: 8 } },
|
|
1825
|
+
h(Btn, { size: 'sm', tone: 'import', icon: 'plus', onClick: () => onCreateBook?.() }, '新建一本书'),
|
|
1826
|
+
),
|
|
1827
|
+
)
|
|
1828
|
+
: null,
|
|
1829
|
+
|
|
1623
1830
|
// 酒馆门里先列「说过的场次」:这是回来的路。没有它,用户退出一场
|
|
1624
1831
|
// 就再也找不回来——只能重新开一场,前面那些话看着像丢了。
|
|
1625
1832
|
chatRows.length > 0
|
|
@@ -1724,10 +1931,16 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1724
1931
|
h('div', { className: 'lc-stack' }, worlds.map((w) => h(WorldRow, { key: w.path, world: w, onOpen: onOpenWorld, photo: avatars[w.id] }))),
|
|
1725
1932
|
),
|
|
1726
1933
|
|
|
1727
|
-
|
|
1934
|
+
// 「还等着你看的建议」只在**它属于的那扇门**里出现。
|
|
1935
|
+
//
|
|
1936
|
+
// 以前这是个跨屏提醒:在酒馆里写着小说,头顶一直挂着「关于『妈妈』的建议」;
|
|
1937
|
+
// 记录真人的时候又挂着小说角色的建议。建议本身没错,错在它不属于这一屏。
|
|
1938
|
+
// 判据是:这条建议说的是谁——那个人有卡就按卡的 kind(虚构/现实),
|
|
1939
|
+
// 没卡就按「还没建卡」算现实(绝大多数情况是给人建卡时读出来的)。
|
|
1940
|
+
pendingProposals.length > 0
|
|
1728
1941
|
? h('div', null,
|
|
1729
|
-
h(GroupTitle, { tone: 'analyze', icon: 'spark' }, `还等着你看的建议 · ${
|
|
1730
|
-
h('div', { className: 'lc-stack' },
|
|
1942
|
+
h(GroupTitle, { tone: 'analyze', icon: 'spark' }, `还等着你看的建议 · ${pendingProposals.length}`),
|
|
1943
|
+
h('div', { className: 'lc-stack' }, pendingProposals.slice(0, 6).map((p) => h('button', {
|
|
1731
1944
|
key: p.json, className: 'lc-card-item', type: 'button',
|
|
1732
1945
|
onClick: () => onProposal(p.json),
|
|
1733
1946
|
},
|
|
@@ -1743,8 +1956,8 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1743
1956
|
))),
|
|
1744
1957
|
// 一次只摆 6 份。不说明的话,第 7 份起就永远看不见——
|
|
1745
1958
|
// 用户会以为那些建议丢了。
|
|
1746
|
-
|
|
1747
|
-
? h('div', { className: 'lc-meta' }, `还有 ${
|
|
1959
|
+
pendingProposals.length > 6
|
|
1960
|
+
? h('div', { className: 'lc-meta' }, `还有 ${pendingProposals.length - 6} 份没列出来。处理掉上面任意一份,它就会顶上来。`)
|
|
1748
1961
|
: null,
|
|
1749
1962
|
)
|
|
1750
1963
|
: null,
|
|
@@ -1775,7 +1988,7 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
|
|
|
1775
1988
|
h(Btn, { size: 'sm', ghost: true, icon: 'folder', onClick: () => onNavigate(VIEWS.setup) }, '换个文件夹'),
|
|
1776
1989
|
// 路径照实写出来:这是唯一一处「档案到底在哪儿」的线索。
|
|
1777
1990
|
// 藏起来,选错文件夹的人就永远找不回去了。
|
|
1778
|
-
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 === '' ? '' : '档案就在你选的那个文件夹里'),
|
|
1779
1992
|
),
|
|
1780
1993
|
)
|
|
1781
1994
|
: null,
|
|
@@ -1951,7 +2164,667 @@ function WorldView({ world, listing, onOpenCard }) {
|
|
|
1951
2164
|
* 不能因为这个丢掉;
|
|
1952
2165
|
* 3. 退出就是退出,不弹「确定放弃吗」——每一轮都已经在文件里了。
|
|
1953
2166
|
*/
|
|
1954
|
-
|
|
2167
|
+
/**
|
|
2168
|
+
* 一本书:大纲 → 一章一章写 → 审。
|
|
2169
|
+
*
|
|
2170
|
+
* 这一屏是「写小说」那扇门真正干活的地方。三段对应三件事:
|
|
2171
|
+
* 1. **先想清楚**:让 AI 把整本书的骨架搭出来,落成一份大纲;人能直接改。
|
|
2172
|
+
* 2. **写这一章**:拿大纲里这一章那一句 + 上一章结尾 + 设定,让它写正文。
|
|
2173
|
+
* 3. **挑毛病**:写完让它只挑问题、不夸,人照着改。
|
|
2174
|
+
*
|
|
2175
|
+
* 刻意不做的事:
|
|
2176
|
+
* - 不自动一章接一章往下写。写完一章就停下来等人看——一次生成十章的书
|
|
2177
|
+
* 没人会读,也没人改得动。
|
|
2178
|
+
* - 不问「要不要继续」。按钮在那儿,要不要点是人自己的事。
|
|
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
|
+
|
|
2560
|
+
function BookView({ bookId, onBack, flash }) {
|
|
2561
|
+
const [book, setBook] = useState(null)
|
|
2562
|
+
const [outline, setOutline] = useState(null)
|
|
2563
|
+
const [chapterIndex, setChapterIndex] = useState(0)
|
|
2564
|
+
const [body, setBody] = useState('')
|
|
2565
|
+
const [draft, setDraft] = useState(null)
|
|
2566
|
+
const [review, setReview] = useState(null)
|
|
2567
|
+
const [busy, setBusy] = useState('')
|
|
2568
|
+
const [err, setErr] = useState(null)
|
|
2569
|
+
const [ask, setAsk] = useState('')
|
|
2570
|
+
const [editingOutline, setEditingOutline] = useState(false)
|
|
2571
|
+
const [outlineText, setOutlineText] = useState('')
|
|
2572
|
+
// 这一章的正文有没有还没存下的改动。切章之前必须先处理,否则写的东西会没。
|
|
2573
|
+
const [bodyDirty, setBodyDirty] = useState(false)
|
|
2574
|
+
|
|
2575
|
+
const load = async (want) => {
|
|
2576
|
+
setErr(null)
|
|
2577
|
+
try {
|
|
2578
|
+
const r = await call('novel.get', { id: bookId })
|
|
2579
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这本书打不开'); return }
|
|
2580
|
+
setBook(r)
|
|
2581
|
+
const o = await call('novel.outline.get', { id: bookId })
|
|
2582
|
+
if (o?.ok === true) { setOutline(o); setOutlineText(String(o.text ?? '')) }
|
|
2583
|
+
// 默认打开:有章节就打开最后一章,没有就从第一章开始
|
|
2584
|
+
const target = Number(want) > 0 ? Number(want) : (r.chapters.length === 0 ? 1 : r.chapters[r.chapters.length - 1].index)
|
|
2585
|
+
setChapterIndex(target)
|
|
2586
|
+
await openChapter(target)
|
|
2587
|
+
} catch (e) { setErr(humanizeError(e)) }
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const openChapter = async (index) => {
|
|
2591
|
+
setErr(null); setReview(null); setDraft(null)
|
|
2592
|
+
try {
|
|
2593
|
+
const r = await call('novel.chapter.get', { id: bookId, index })
|
|
2594
|
+
setBody(r?.ok === true ? String(r.body ?? '') : '')
|
|
2595
|
+
} catch (e) { setErr(humanizeError(e)) }
|
|
2596
|
+
}
|
|
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
|
+
|
|
2616
|
+
useEffect(() => { load(0) }, [bookId])
|
|
2617
|
+
|
|
2618
|
+
const doPlan = async () => {
|
|
2619
|
+
setBusy('plan'); setErr(null)
|
|
2620
|
+
try {
|
|
2621
|
+
const r = await call('novel.outline.draft', { id: bookId, chapters: 12 })
|
|
2622
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这次没想出大纲'); return }
|
|
2623
|
+
setOutline(r); setOutlineText(String(r.text ?? ''))
|
|
2624
|
+
flash?.('大纲想好了,你可以直接改')
|
|
2625
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
const saveOutline = async () => {
|
|
2629
|
+
setBusy('outline'); setErr(null)
|
|
2630
|
+
try {
|
|
2631
|
+
const r = await call('novel.outline.save', { id: bookId, text: outlineText })
|
|
2632
|
+
if (r?.ok !== true) { setErr(r?.error ?? '大纲没存上'); return }
|
|
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
|
+
})
|
|
2643
|
+
setEditingOutline(false)
|
|
2644
|
+
flash?.('大纲存好了')
|
|
2645
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
const doWrite = async () => {
|
|
2649
|
+
setBusy('write'); setErr(null); setReview(null)
|
|
2650
|
+
try {
|
|
2651
|
+
const r = await call('novel.chapter.draft', { id: bookId, index: chapterIndex, ask })
|
|
2652
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这次没写出来'); return }
|
|
2653
|
+
setDraft(String(r.draft ?? ''))
|
|
2654
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const acceptDraft = async () => {
|
|
2658
|
+
if (draft === null) return
|
|
2659
|
+
setBusy('save'); setErr(null)
|
|
2660
|
+
try {
|
|
2661
|
+
const merged = body.trim() === '' ? draft : `${body.trim()}\n\n${draft}`
|
|
2662
|
+
const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body: merged })
|
|
2663
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
|
|
2664
|
+
setBody(merged); setDraft(null); setBodyDirty(false)
|
|
2665
|
+
flash?.(`第 ${chapterIndex} 章存好了`)
|
|
2666
|
+
await load(chapterIndex)
|
|
2667
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
const saveBody = async () => {
|
|
2671
|
+
setBusy('save'); setErr(null)
|
|
2672
|
+
try {
|
|
2673
|
+
const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body })
|
|
2674
|
+
if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
|
|
2675
|
+
setBodyDirty(false)
|
|
2676
|
+
flash?.(`第 ${chapterIndex} 章存好了`)
|
|
2677
|
+
await load(chapterIndex)
|
|
2678
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
const doReview = async () => {
|
|
2682
|
+
// 挑毛病看的是存下来的那一版,所以正文有没存的改动就先存一次,
|
|
2683
|
+
// 否则你会以为它评的是刚写的那段。
|
|
2684
|
+
if ((await keepBody()) !== true) return
|
|
2685
|
+
setBusy('review'); setErr(null)
|
|
2686
|
+
try {
|
|
2687
|
+
const r = await call('novel.chapter.review', { id: bookId, index: chapterIndex })
|
|
2688
|
+
if (r?.ok !== true) { setErr(r?.error ?? '这次没审成'); return }
|
|
2689
|
+
setReview(String(r.review ?? ''))
|
|
2690
|
+
} catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
if (book === null) {
|
|
2694
|
+
return h('div', { className: 'lc-pad lc-stack' },
|
|
2695
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
|
|
2696
|
+
err === null ? h(Spinner) : h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2697
|
+
)
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
const chapters = Array.isArray(book.chapters) ? book.chapters : []
|
|
2701
|
+
const plan = outline?.ok === true && Array.isArray(outline.chapters) ? outline.chapters : []
|
|
2702
|
+
const planItem = plan.find((c) => c.index === chapterIndex)
|
|
2703
|
+
const title = String(book.data?.title ?? bookId)
|
|
2704
|
+
|
|
2705
|
+
return h('div', { className: 'lc-pad lc-stack', style: { gap: 12 } },
|
|
2706
|
+
h('div', { className: 'lc-row' },
|
|
2707
|
+
h(Btn, { size: 'sm', icon: 'back', onClick: async () => { if ((await keepBody()) === true) onBack() } }, '回书架'),
|
|
2708
|
+
h('div', { className: 'lc-grow lc-ellipsis', style: { fontWeight: 700, fontSize: 16.5 } }, `《${title}》`),
|
|
2709
|
+
h('span', { className: 'lc-meta' }, `${chapters.length} 章`),
|
|
2710
|
+
),
|
|
2711
|
+
String(book.idea ?? '').trim() === '' ? null : h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } }, String(book.idea).trim()),
|
|
2712
|
+
|
|
2713
|
+
// ── 第一段:规划 ──
|
|
2714
|
+
h('div', { className: 'lc-sec' },
|
|
2715
|
+
h('div', { className: 'lc-row' },
|
|
2716
|
+
h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, '① 先想清楚这本书怎么走')),
|
|
2717
|
+
h(Btn, { size: 'sm', tone: 'import', icon: 'spark', disabled: busy !== '', onClick: doPlan },
|
|
2718
|
+
busy === 'plan' ? '正在想…' : (plan.length === 0 ? '让 AI 想大纲' : '重新想一版')),
|
|
2719
|
+
plan.length === 0 ? null : h(Btn, { size: 'sm', ghost: true, icon: 'edit', onClick: () => setEditingOutline(!editingOutline) },
|
|
2720
|
+
editingOutline ? '收起' : '自己改'),
|
|
2721
|
+
),
|
|
2722
|
+
plan.length === 0
|
|
2723
|
+
? h('div', { className: 'lc-meta', style: { marginTop: 6, lineHeight: 1.7 } },
|
|
2724
|
+
'先让它把整本书的骨架搭出来:主线、每章要发生什么、结局。',
|
|
2725
|
+
h('br'),
|
|
2726
|
+
'搭完你能直接改——改一行,后面写出来的东西就跟着变。')
|
|
2727
|
+
: h('div', { className: 'lc-stack', style: { marginTop: 6 } },
|
|
2728
|
+
outline.theme === '' ? null : h('div', { className: 'lc-meta' }, `主线:${outline.theme}`),
|
|
2729
|
+
h('div', { className: 'lc-stack', style: { gap: 2 } }, plan.map((c) => h('div', {
|
|
2730
|
+
key: c.index,
|
|
2731
|
+
className: 'lc-meta',
|
|
2732
|
+
style: {
|
|
2733
|
+
padding: '3px 6px', borderRadius: 6, cursor: 'pointer', lineHeight: 1.6,
|
|
2734
|
+
background: c.index === chapterIndex ? 'var(--lc-accent-soft)' : 'transparent',
|
|
2735
|
+
color: c.index === chapterIndex ? 'var(--lc-accent)' : undefined,
|
|
2736
|
+
},
|
|
2737
|
+
onClick: () => { goChapter(c.index) },
|
|
2738
|
+
}, `第 ${c.index} 章 · ${c.plan}`))),
|
|
2739
|
+
outline.ending === '' ? null : h('div', { className: 'lc-meta' }, `结局:${outline.ending}`),
|
|
2740
|
+
),
|
|
2741
|
+
editingOutline
|
|
2742
|
+
? h('div', { className: 'lc-stack', style: { marginTop: 6 } },
|
|
2743
|
+
h('textarea', {
|
|
2744
|
+
className: 'lc-textarea', value: outlineText, style: { minHeight: 180 },
|
|
2745
|
+
onChange: (e) => setOutlineText(e.target.value), spellCheck: false,
|
|
2746
|
+
}),
|
|
2747
|
+
h('div', { className: 'lc-row' },
|
|
2748
|
+
h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveOutline }, busy === 'outline' ? '正在存…' : '存好'),
|
|
2749
|
+
h('div', { className: 'lc-meta' }, '每行一章,写清这一章要发生什么。'),
|
|
2750
|
+
),
|
|
2751
|
+
)
|
|
2752
|
+
: null,
|
|
2753
|
+
),
|
|
2754
|
+
|
|
2755
|
+
// ── 第二段:写这一章 ──
|
|
2756
|
+
h('div', { className: 'lc-sec' },
|
|
2757
|
+
h('div', { className: 'lc-row' },
|
|
2758
|
+
h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, `② 写第 ${chapterIndex} 章`)),
|
|
2759
|
+
h(Btn, { size: 'sm', tone: 'analyze', icon: 'spark', disabled: busy !== '', onClick: doWrite },
|
|
2760
|
+
busy === 'write' ? '正在写…' : '让 AI 写这一章'),
|
|
2761
|
+
),
|
|
2762
|
+
planItem === undefined
|
|
2763
|
+
? h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '大纲里还没有这一章。先让 AI 想一版大纲,或者直接写也行。')
|
|
2764
|
+
: h('div', { className: 'lc-meta', style: { marginTop: 4, lineHeight: 1.6 } }, `这一章要写:${planItem.plan}`),
|
|
2765
|
+
h('div', { className: 'lc-row', style: { marginTop: 6 } },
|
|
2766
|
+
h('input', {
|
|
2767
|
+
className: 'lc-input', value: ask, spellCheck: false,
|
|
2768
|
+
placeholder: '(可选)这一章你想强调什么?比如「写他们吵架那场」',
|
|
2769
|
+
onChange: (e) => setAsk(e.target.value),
|
|
2770
|
+
}),
|
|
2771
|
+
),
|
|
2772
|
+
draft === null
|
|
2773
|
+
? null
|
|
2774
|
+
: h('div', { className: 'lc-stack', style: { marginTop: 8 } },
|
|
2775
|
+
h(Note, { tone: 'ok', icon: 'spark' }, '写好了。看一眼,满意就收下,不满意可以不收。'),
|
|
2776
|
+
h('div', {
|
|
2777
|
+
style: {
|
|
2778
|
+
whiteSpace: 'pre-wrap', lineHeight: 1.85, fontSize: 14.5,
|
|
2779
|
+
padding: '10px 12px', borderRadius: 10,
|
|
2780
|
+
background: 'var(--lc-analyze-soft)', border: '1px solid var(--lc-analyze)',
|
|
2781
|
+
},
|
|
2782
|
+
}, draft),
|
|
2783
|
+
h('div', { className: 'lc-row' },
|
|
2784
|
+
h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: acceptDraft },
|
|
2785
|
+
busy === 'save' ? '正在存…' : '收下,写进这一章'),
|
|
2786
|
+
h(Btn, { size: 'sm', ghost: true, icon: 'trash', disabled: busy !== '', onClick: () => setDraft(null) }, '不要这段'),
|
|
2787
|
+
),
|
|
2788
|
+
),
|
|
2789
|
+
),
|
|
2790
|
+
|
|
2791
|
+
// ── 正文 ──
|
|
2792
|
+
h('div', { className: 'lc-sec' },
|
|
2793
|
+
h('div', { className: 'lc-row' },
|
|
2794
|
+
h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, '正文')),
|
|
2795
|
+
h('span', { className: 'lc-meta' }, `${body.length} 字`),
|
|
2796
|
+
),
|
|
2797
|
+
h('textarea', {
|
|
2798
|
+
className: 'lc-textarea',
|
|
2799
|
+
value: body,
|
|
2800
|
+
spellCheck: false,
|
|
2801
|
+
placeholder: '这一章还空着。让 AI 写一段,或者自己动手。',
|
|
2802
|
+
style: { minHeight: 260, marginTop: 6, lineHeight: 1.85 },
|
|
2803
|
+
onChange: (e) => { setBody(e.target.value); setBodyDirty(true) },
|
|
2804
|
+
}),
|
|
2805
|
+
h('div', { className: 'lc-row' },
|
|
2806
|
+
h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveBody }, busy === 'save' ? '正在存…' : '存好这一章'),
|
|
2807
|
+
h(Btn, { size: 'sm', icon: 'doctor', disabled: busy !== '' || body.trim() === '', onClick: doReview },
|
|
2808
|
+
busy === 'review' ? '正在挑毛病…' : '挑挑毛病'),
|
|
2809
|
+
h('div', { className: 'lc-grow' }),
|
|
2810
|
+
h(Btn, { size: 'sm', ghost: true, icon: 'plus', onClick: async () => {
|
|
2811
|
+
const next = chapterIndex + 1
|
|
2812
|
+
await goChapter(next)
|
|
2813
|
+
} }, `写第 ${chapterIndex + 1} 章`),
|
|
2814
|
+
),
|
|
2815
|
+
review === null
|
|
2816
|
+
? null
|
|
2817
|
+
: h('div', { className: 'lc-stack', style: { marginTop: 8 } },
|
|
2818
|
+
h(Note, { tone: 'warn', icon: 'warning' }, '它挑出来的问题(不是夸奖,也不用全听)'),
|
|
2819
|
+
h('div', { style: { whiteSpace: 'pre-wrap', lineHeight: 1.8, fontSize: 14 } }, review),
|
|
2820
|
+
),
|
|
2821
|
+
),
|
|
2822
|
+
|
|
2823
|
+
err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
|
|
2824
|
+
)
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function TavernView({ chat, onChat, onBack, flash }) {
|
|
1955
2828
|
const [draft, setDraft] = useState('')
|
|
1956
2829
|
const [busy, setBusy] = useState(false)
|
|
1957
2830
|
const [err, setErr] = useState(null)
|
|
@@ -1959,6 +2832,16 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
1959
2832
|
// 「到这儿为止」那段备忘。它存在会话文件里,跟着 chat 一起下发。
|
|
1960
2833
|
const [notes, setNotes] = useState(Array.isArray(chat?.notes) ? chat.notes : [])
|
|
1961
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('')
|
|
1962
2845
|
const turns = Array.isArray(chat?.turns) ? chat.turns : []
|
|
1963
2846
|
const rawName = String(chat?.card_name ?? '').trim()
|
|
1964
2847
|
const name = rawName === '' ? 'TA' : rawName
|
|
@@ -2008,6 +2891,52 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2008
2891
|
}
|
|
2009
2892
|
}
|
|
2010
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
|
+
|
|
2011
2940
|
const remove = async () => {
|
|
2012
2941
|
if (chat?.id === undefined) return
|
|
2013
2942
|
setBusy(true)
|
|
@@ -2105,8 +3034,36 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2105
3034
|
onClick: summarize,
|
|
2106
3035
|
title: '把前面发生过的事记成几句,之后每轮都带上',
|
|
2107
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 ? '正在挑…' : '挑几句进卡里'),
|
|
2108
3039
|
h(Btn, { size: 'sm', ghost: true, icon: 'trash', disabled: busy, onClick: () => setConfirmDelete(true) }, '这一场不要了'),
|
|
2109
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,
|
|
2110
3067
|
confirmDelete === true
|
|
2111
3068
|
? h('div', { className: 'lc-row' },
|
|
2112
3069
|
h('span', { className: 'lc-meta', className: 'lc-meta' }, '这一场的记录会删掉,删了就找不回来了。'),
|
|
@@ -2114,6 +3071,7 @@ function TavernView({ chat, onChat, onBack }) {
|
|
|
2114
3071
|
h(Btn, { size: 'sm', ghost: true, onClick: () => setConfirmDelete(false), disabled: busy }, '先不删'),
|
|
2115
3072
|
)
|
|
2116
3073
|
: null,
|
|
3074
|
+
exNote === '' ? null : h(Note, { tone: 'ok', icon: 'check' }, exNote),
|
|
2117
3075
|
h('div', { className: 'lc-meta' }, '每说一句都会存进「chats」里,退出再进来还能接着说。'),
|
|
2118
3076
|
)
|
|
2119
3077
|
}
|
|
@@ -3435,6 +4393,9 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3435
4393
|
// 结束页以前一律说「你说的都记下来了」;可要是手一滑点了结束,
|
|
3436
4394
|
// 正在写的那句其实没存进去——那句话就得改口。
|
|
3437
4395
|
const [dropped, setDropped] = useState(false)
|
|
4396
|
+
// 这一场访谈写出去的材料(interview.record 每次回传一份)。结束页要带上它们,
|
|
4397
|
+
// 不然分析页一条都不勾,而「一条都不勾」= 全读——别人的材料会被读进这张卡。
|
|
4398
|
+
const [materialPaths, setMaterialPaths] = useState([])
|
|
3438
4399
|
const scrollRef = useRef(null)
|
|
3439
4400
|
|
|
3440
4401
|
// 宿主给什么就用什么;一个都没有(还没重启、旧版宿主)就用本地这份。
|
|
@@ -3575,6 +4536,12 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3575
4536
|
setErr(rec?.error ?? '这一句没记上。检查一下文件夹还在不在,再点一次。')
|
|
3576
4537
|
return
|
|
3577
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
|
+
}
|
|
3578
4545
|
if (status === 'answered') {
|
|
3579
4546
|
setLog((prev) => [...prev, { role: 'bot', text: q.ask, options: q.options }, { role: 'me', text: answer.trim() }])
|
|
3580
4547
|
} else {
|
|
@@ -3585,6 +4552,12 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3585
4552
|
const rest = queue.slice(1)
|
|
3586
4553
|
if (rest.length === 0) {
|
|
3587
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
|
+
}
|
|
3588
4561
|
setPlan(p)
|
|
3589
4562
|
setQueue(p.next ?? [])
|
|
3590
4563
|
setCurrent((p.next ?? [])[0] ?? null)
|
|
@@ -3742,7 +4715,7 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
|
|
|
3742
4715
|
plan === null || (plan.empty ?? []).length === 0
|
|
3743
4716
|
? null
|
|
3744
4717
|
: h('div', { className: 'lc-meta' }, `还空着:${(plan.empty ?? []).slice(0, 8).join('、')}`),
|
|
3745
|
-
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 }) }, '把刚才说的整理进卡里'),
|
|
3746
4719
|
h(Btn, { block: true, onClick: () => { onDone() } }, '先这样,回头再说'),
|
|
3747
4720
|
h('div', { className: 'lc-meta', style: { textAlign: 'center' } }, '你说的每句话都存成了材料。整理时会标出原文出处,编不出来的。'),
|
|
3748
4721
|
)
|
|
@@ -3805,9 +4778,16 @@ function ImportView({ busy, onImport, onAnalyze, listing }) {
|
|
|
3805
4778
|
const [drag, setDrag] = useState(false)
|
|
3806
4779
|
const [tab, setTab] = useState('card')
|
|
3807
4780
|
const inputRef = useRef(null)
|
|
4781
|
+
const [skipNote, setSkipNote] = useState('')
|
|
3808
4782
|
|
|
3809
4783
|
const handleFiles = (fileList) => {
|
|
3810
|
-
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)。想贴一段文字,就切到上面「一段文字」。`)
|
|
3811
4791
|
if (files.length === 0) return
|
|
3812
4792
|
onImport(files)
|
|
3813
4793
|
}
|
|
@@ -3843,6 +4823,7 @@ function ImportView({ busy, onImport, onAnalyze, listing }) {
|
|
|
3843
4823
|
style: { display: 'none' }, onChange: (e) => handleFiles(e.target.files),
|
|
3844
4824
|
}),
|
|
3845
4825
|
),
|
|
4826
|
+
skipNote === '' ? null : h(Note, { tone: 'warn', icon: 'warning' }, skipNote),
|
|
3846
4827
|
busy === 'import' ? h('div', { className: 'lc-row' }, h(Spinner), h('span', { className: 'lc-meta' }, '正在读…')) : null,
|
|
3847
4828
|
h(Note, { tone: undefined, icon: 'check' }, '导入进来的卡会原样保留所有内容,包括这个软件不认识的字段。你在这里改过的部分才会变,导出时一起带走。'),
|
|
3848
4829
|
),
|
|
@@ -3890,7 +4871,7 @@ function TextImport({ onAnalyze }) {
|
|
|
3890
4871
|
h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } },
|
|
3891
4872
|
'把一大段话贴进来——小说片段、人物小传、你随口讲的关于某个人的事都行。',
|
|
3892
4873
|
h('br'),
|
|
3893
|
-
'存完点「让 AI
|
|
4874
|
+
'存完点「让 AI 读一读」,它会把里面的信息整理成一张卡。只认原文里写过的,编的会被丢掉。'),
|
|
3894
4875
|
h(Field, { label: '这是要建成什么人' },
|
|
3895
4876
|
h('div', { className: 'lc-row' },
|
|
3896
4877
|
h(Btn, { size: 'sm', tone: kind === 'real' ? 'interview' : undefined, onClick: () => setKind('real') }, '现实里的人'),
|
|
@@ -4083,7 +5064,7 @@ function peopleKey(p) {
|
|
|
4083
5064
|
|
|
4084
5065
|
// ── 让 AI 读材料 ──────────────────────────────────────────────────
|
|
4085
5066
|
|
|
4086
|
-
function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMaterials, onBack, onNeedMaterial }) {
|
|
5067
|
+
function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMaterials, onBack, onNeedMaterial, onAppliedCard }) {
|
|
4087
5068
|
const materials = listing?.materials ?? []
|
|
4088
5069
|
const cards = listing?.cards ?? []
|
|
4089
5070
|
// 从别的页带过来的人,可能只有一个名字:材料刚存下、卡还没建。
|
|
@@ -4092,7 +5073,13 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4092
5073
|
const presetId = String(presetAbout?.id ?? '').trim()
|
|
4093
5074
|
const presetName = String(presetAbout?.name ?? '').trim()
|
|
4094
5075
|
const [about, setAbout] = useState(presetId !== '' ? presetId : presetName)
|
|
4095
|
-
|
|
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
|
+
})
|
|
4096
5083
|
const [busy, setBusy] = useState(false)
|
|
4097
5084
|
const [err, setErr] = useState(null)
|
|
4098
5085
|
const [result, setResult] = useState(null)
|
|
@@ -4126,12 +5113,28 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4126
5113
|
// 卸载时补存一次,别让刚想出来的那句话白写。
|
|
4127
5114
|
const conceptDraftRef = useRef(null)
|
|
4128
5115
|
conceptDraftRef.current = conceptDraft
|
|
5116
|
+
// 下面这两个特意放在所有 hook 之后:上面那些槽位是有测试按号码摆状态的,
|
|
5117
|
+
// 中间插一个新 state 会让它们全体错位(conceptDraft 就是被这么挪过一次)。
|
|
5118
|
+
const [oneClick, setOneClick] = useState('')
|
|
5119
|
+
// 这一趟读的是虚构角色还是真人:虚构的才给「直接建成卡」,
|
|
5120
|
+
// 真人的卡要人自己填关系那些,不能一键生成。
|
|
5121
|
+
const isFiction = String(presetAbout?.kind ?? '').trim() === 'fictional'
|
|
4129
5122
|
useEffect(() => () => {
|
|
4130
5123
|
const pending = conceptDraftRef.current
|
|
4131
5124
|
if (pending === null || pending === undefined) return
|
|
4132
5125
|
call('concept.set', { concept: pending }).catch(() => {})
|
|
4133
5126
|
}, [])
|
|
4134
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
|
+
|
|
4135
5138
|
const flushConcept = async (value) => {
|
|
4136
5139
|
const next = value ?? conceptDraft ?? concept
|
|
4137
5140
|
setConceptDraft(null)
|
|
@@ -4171,6 +5174,11 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4171
5174
|
})
|
|
4172
5175
|
}
|
|
4173
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
|
+
|
|
4174
5182
|
const run = async () => {
|
|
4175
5183
|
// 「这一趟等了多久」从这里算。以前写成 startedAt 却没有声明,后台一连不上
|
|
4176
5184
|
// 就在 catch 里抛 ReferenceError,setErr 永远走不到——用户只看到按钮弹回来,
|
|
@@ -4312,14 +5320,45 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
|
|
|
4312
5320
|
h(Btn, { tone: 'analyze', big: true, onClick: run, disabled: busy || about === '' || materials.length === 0, icon: 'spark' }, busy ? '正在读…' : '开始读'),
|
|
4313
5321
|
busy ? h(Spinner) : null,
|
|
4314
5322
|
),
|
|
5323
|
+
// 这一趟是真等:模型先在心里想一遍再写(实测小材料也要四十秒上下,
|
|
5324
|
+
// 长材料一分多钟)。不给时间预期的话,转圈超过半分钟人就会以为卡死了、
|
|
5325
|
+
// 去点第二次,两个请求叠在一起更慢。
|
|
5326
|
+
busy
|
|
5327
|
+
? h('div', { className: 'lc-meta' }, longRead
|
|
5328
|
+
? `材料有点长(约 ${Math.round(readChars / 1000)} 千字),这一趟可能一两分钟。已经等了 ${waited} 秒,别关这一页,等它说完。`
|
|
5329
|
+
: `它在先想一遍再写,一般要半分钟到一分钟。已经等了 ${waited} 秒,别关这一页,等它说完。`)
|
|
5330
|
+
: null,
|
|
4315
5331
|
|
|
4316
5332
|
result === null ? null : h('div', null,
|
|
4317
5333
|
h(Note, { tone: 'ok', icon: 'check' },
|
|
4318
5334
|
h('div', null, `读出了 ${result.accepted} 条。`),
|
|
4319
5335
|
result.rejected > 0 ? h('div', { className: 'lc-meta', style: { marginTop: 3 } }, `另有 ${result.rejected} 条在材料里找不到原句,已经丢掉——AI 编的东西进不来。`) : null,
|
|
4320
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,
|
|
4321
5360
|
h('div', { style: { marginTop: 8 } },
|
|
4322
|
-
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 () => {
|
|
4323
5362
|
try {
|
|
4324
5363
|
const p = await call('proposal.get', { json: result.json })
|
|
4325
5364
|
// 读不出来当「没有新东西」会让界面绿字说「这一份里没有还没写进去的了」,
|
|
@@ -4479,6 +5518,7 @@ function JobsView({ onBack, flash }) {
|
|
|
4479
5518
|
// 这一页开头要读好几样东西(求职设置、以前读过的、投递表、故事)。
|
|
4480
5519
|
// 读失败时以前是静默吞掉——界面上就是「一片空」,跟「你还没填」长得一样。
|
|
4481
5520
|
const [loadFail, setLoadFail] = useState(false)
|
|
5521
|
+
const [savingStory, setSavingStory] = useState(false)
|
|
4482
5522
|
|
|
4483
5523
|
useEffect(() => {
|
|
4484
5524
|
let alive = true
|
|
@@ -4776,14 +5816,28 @@ function JobsView({ onBack, flash }) {
|
|
|
4776
5816
|
h('input', { className: 'lc-input', value: newStoryTitle, onChange: (e) => setNewStoryTitle(e.target.value), placeholder: '一次线上事故', spellCheck: false }),
|
|
4777
5817
|
),
|
|
4778
5818
|
h('textarea', { className: 'lc-textarea', value: newStoryBody, onChange: (e) => setNewStoryBody(e.target.value), placeholder: '凌晨两点被告警叫起来…', spellCheck: false }),
|
|
4779
|
-
h(Btn, {
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
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 ? '正在存…' : '存下这段'),
|
|
4787
5841
|
),
|
|
4788
5842
|
),
|
|
4789
5843
|
|