dsh-lifecard 0.1.1 → 0.1.2

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/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', 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,28 @@ 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([])
958
973
  // 门自己的那两屏直接说了算;其余屏(聊着建、卡片、分析…)沿用上次选的门。
959
- const mode = view === VIEWS.novel ? 'novel' : (view === VIEWS.tavern || view === VIEWS.tavernChat) ? 'tavern' : door
974
+ const mode = (view === VIEWS.novel || view === VIEWS.book) ? 'novel' : (view === VIEWS.tavern || view === VIEWS.tavernChat) ? 'tavern' : door
960
975
  // 回到酒馆那两屏就重新拿一遍说过的场次:刚说完、刚删完再回来,
961
976
  // 还挂着进这一屏之前的旧列表,用户会以为刚才那一步没生效。
962
977
  useEffect(() => {
@@ -968,6 +983,25 @@ function LifeCardTab(props) {
968
983
  return () => { gone = true }
969
984
  }, [view])
970
985
 
986
+ /** 打开一本书,进到那一屏。 */
987
+ const openBook = (book) => {
988
+ setActiveBook({ id: book?.id ?? '', title: book?.title ?? '' })
989
+ setView(VIEWS.book)
990
+ }
991
+
992
+ /** 新开一本:问一个书名就够,别的都能后面补。 */
993
+ const createBook = async () => {
994
+ const ask = typeof window !== 'undefined' && typeof window.prompt === 'function' ? window.prompt : null
995
+ const title = ask === null ? '新书' : String(ask('这本书叫什么?') ?? '').trim()
996
+ if (title === '') return
997
+ try {
998
+ const r = await call('novel.create', { title })
999
+ if (r?.ok !== true) { setError(r?.error ?? '这本书没能建起来'); return }
1000
+ setActiveBook({ id: r.id, title: r.title })
1001
+ setView(VIEWS.book)
1002
+ } catch (e) { setError(humanizeError(e)) }
1003
+ }
1004
+
971
1005
  const pickDoor = (next) => {
972
1006
  setDoor(next)
973
1007
  setError(null)
@@ -1080,6 +1114,7 @@ function LifeCardTab(props) {
1080
1114
  [VIEWS.doors]: '你要用哪个?',
1081
1115
  [VIEWS.home]: '真实人物记录',
1082
1116
  [VIEWS.novel]: '写小说',
1117
+ [VIEWS.book]: activeBook?.title === undefined || String(activeBook.title).trim() === '' ? '这本书' : String(activeBook.title),
1083
1118
  [VIEWS.tavern]: '酒馆',
1084
1119
  [VIEWS.tavernChat]: chat === null ? '聊一场' : (String(chat.card_name ?? '').trim() === '' ? '聊一场' : String(chat.card_name)),
1085
1120
  [VIEWS.card]: activeCard?.data?.name ?? '这张卡',
@@ -1213,7 +1248,7 @@ function LifeCardTab(props) {
1213
1248
  // 直接退回首页,用户点了没反应,还以为坏了。现在把清单单独摊开。
1214
1249
  return activeCard === null
1215
1250
  ? 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 })
1251
+ ? 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
1252
  : h('div', { className: 'lc-pad lc-stack' },
1218
1253
  proposal.about === undefined || String(proposal.about).trim() === ''
1219
1254
  ? null
@@ -1272,18 +1307,24 @@ function LifeCardTab(props) {
1272
1307
  })
1273
1308
  case VIEWS.world:
1274
1309
  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 })
1310
+ ? 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
1311
  : h(WorldView, {
1277
1312
  world: activeWorld,
1278
1313
  listing,
1279
1314
  onOpenCard: openCard,
1280
1315
  })
1316
+ case VIEWS.book:
1317
+ return h(BookView, {
1318
+ bookId: String(activeBook?.id ?? ''),
1319
+ flash,
1320
+ onBack: () => { setActiveBook(null); setView(VIEWS.novel) },
1321
+ })
1281
1322
  case VIEWS.doctor:
1282
1323
  return h(DoctorView, { report: doctor, onOpenCard: openCard, onRun: () => runDoctor(true) })
1283
1324
  case VIEWS.jobs:
1284
1325
  return h(JobsView, { onBack: () => setView(VIEWS.home), flash })
1285
1326
  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 })
1327
+ 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
1328
  }
1288
1329
  }
1289
1330
 
@@ -1396,15 +1437,33 @@ const DOORS = [
1396
1437
  },
1397
1438
  ]
1398
1439
 
1440
+ /**
1441
+ * 进门那一屏:三扇**立着的门**。
1442
+ *
1443
+ * 为什么做成门的样子而不是三张卡片:卡片是「选一个设置」,门是「走进去」。
1444
+ * 这一屏是这个插件唯一的分岔口,用户在这儿做的决定(记真人 / 写小说 / 演)
1445
+ * 会决定后面所有屏长什么样,值得给一个够重的画面。
1446
+ *
1447
+ * 门该有的东西:门框、门楣、门牌、把手、门槛。这里都做了——
1448
+ * 顶上一条门楣色块、中间竖着写门名、底部一道门槛写着「推门进去」。
1449
+ * 三扇并排、各占三分之一宽、高度撑满;窄窗口自动换行。
1450
+ */
1399
1451
  function DoorsView({ onPick, state }) {
1400
1452
  const workspace = String(state?.workspace ?? '')
1401
- // 三扇**立着的门**并排站满整屏:每一块都是一个竖长的门框,
1402
- // 高度撑到整屏、宽度三等分。不是三个横躺的卡片叠着——那样看着像
1403
- // 一列设置项,不像「推门进去」。
1404
1453
  const tint = {
1405
- real: { fg: 'var(--lc-interview)', bg: 'var(--lc-interview-soft)', icon: 'people' },
1406
- novel: { fg: 'var(--lc-import)', bg: 'var(--lc-import-soft)', icon: 'book' },
1407
- tavern: { fg: 'var(--lc-analyze)', bg: 'var(--lc-analyze-soft)', icon: 'chat' },
1454
+ real: {
1455
+ fg: 'var(--lc-interview)', bg: 'var(--lc-interview-soft)', icon: 'people',
1456
+ // 门里那点光:不一样的暖冷,一眼能分出来
1457
+ glow: 'rgba(156, 123, 245, .18)',
1458
+ },
1459
+ novel: {
1460
+ fg: 'var(--lc-import)', bg: 'var(--lc-import-soft)', icon: 'book',
1461
+ glow: 'rgba(59, 201, 219, .18)',
1462
+ },
1463
+ tavern: {
1464
+ fg: 'var(--lc-analyze)', bg: 'var(--lc-analyze-soft)', icon: 'chat',
1465
+ glow: 'rgba(232, 185, 59, .18)',
1466
+ },
1408
1467
  }
1409
1468
  return h('div', {
1410
1469
  style: {
@@ -1412,15 +1471,15 @@ function DoorsView({ onPick, state }) {
1412
1471
  minHeight: '100%', boxSizing: 'border-box', padding: 12,
1413
1472
  },
1414
1473
  },
1415
- h('div', { style: { flex: '0 0 auto', paddingBottom: 10 } },
1416
- h('h2', { style: { margin: '2px 0 0', fontSize: 22 } }, '你要用哪个?'),
1417
- h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '推一扇门进去。随时能换——卡和世界书都在同一个文件夹里。'),
1474
+ h('div', { style: { flex: '0 0 auto', paddingBottom: 12 } },
1475
+ h('h2', { style: { margin: '2px 0 0', fontSize: 22, letterSpacing: '-.01em' } }, '你要用哪个?'),
1476
+ h('div', { className: 'lc-meta', style: { marginTop: 5 } }, '推一扇门进去。随时能换——卡和世界书都在同一个文件夹里。'),
1418
1477
  ),
1419
- // 三扇门横向并排;窄窗口时自动换行(flexWrap),免得挤成三条缝
1478
+ // 三扇门横向并排;窄窗口自动换行(flexWrap),免得挤成三条缝
1420
1479
  h('div', {
1421
1480
  style: {
1422
1481
  flex: '1 1 auto', display: 'flex', gap: 10,
1423
- alignItems: 'stretch', flexWrap: 'wrap', minHeight: 260,
1482
+ alignItems: 'stretch', flexWrap: 'wrap', minHeight: 280,
1424
1483
  },
1425
1484
  },
1426
1485
  DOORS.map((door) => {
@@ -1432,42 +1491,76 @@ function DoorsView({ onPick, state }) {
1432
1491
  onClick: () => onPick(door.id),
1433
1492
  style: {
1434
1493
  // 每扇门平分宽度、各自撑满整高(门是竖的,高度要顶天立地)
1435
- flex: '1 1 30%', minWidth: 150, minHeight: 240,
1436
- display: 'flex', flexDirection: 'column', alignItems: 'center',
1437
- justifyContent: 'flex-start', gap: 10,
1438
- padding: '18px 12px', textAlign: 'center', cursor: 'pointer',
1494
+ flex: '1 1 30%', minWidth: 156, minHeight: 260,
1495
+ display: 'flex', flexDirection: 'column', alignItems: 'stretch',
1496
+ gap: 0, padding: 0, textAlign: 'center', cursor: 'pointer',
1439
1497
  border: '1px solid var(--lc-line)', borderRadius: 16,
1440
- background: 'var(--lc-panel)', boxShadow: 'var(--lc-shadow)',
1441
- font: 'inherit', color: 'inherit',
1498
+ // 门里透出来的光:上深下浅,像门后头有灯
1499
+ background: `linear-gradient(180deg, ${t.glow} 0%, var(--lc-panel) 55%, var(--lc-panel) 100%)`,
1500
+ boxShadow: 'var(--lc-shadow)', font: 'inherit', color: 'inherit',
1501
+ overflow: 'hidden', position: 'relative',
1442
1502
  },
1443
1503
  },
1444
- // 门顶上的那块色:像一个门楣
1504
+ // ── 门楣:顶上那条色带 ──
1445
1505
  h('div', {
1446
1506
  style: {
1447
- flex: '0 0 auto', width: 52, height: 52, borderRadius: 14,
1448
- display: 'flex', alignItems: 'center', justifyContent: 'center',
1449
- background: t.bg, color: t.fg, marginTop: 4,
1507
+ flex: '0 0 auto', height: 6, width: '100%',
1508
+ background: t.fg, opacity: .85,
1509
+ },
1510
+ }),
1511
+ // ── 门牌:图标 + 门名 ──
1512
+ h('div', {
1513
+ style: {
1514
+ flex: '0 0 auto', display: 'flex', flexDirection: 'column',
1515
+ alignItems: 'center', gap: 8, padding: '16px 12px 10px',
1450
1516
  },
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
1517
  },
1458
- door.bullets.map((line, i) => h('div', {
1518
+ h('div', {
1519
+ style: {
1520
+ width: 50, height: 50, borderRadius: 14,
1521
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1522
+ background: t.bg, color: t.fg,
1523
+ border: `1px solid ${t.fg}`, boxSizing: 'border-box',
1524
+ },
1525
+ }, h(Icon, { name: t.icon, size: 25 })),
1526
+ h('div', { style: { fontSize: 16.5, fontWeight: 700, lineHeight: 1.3 } }, door.title),
1527
+ h('div', { className: 'lc-meta', style: { lineHeight: 1.6, padding: '0 2px' } }, door.desc),
1528
+ ),
1529
+ // ── 门里有什么 ──
1530
+ h('div', {
1531
+ style: {
1532
+ flex: '1 1 auto', display: 'flex', flexDirection: 'column',
1533
+ gap: 4, padding: '0 12px 10px', textAlign: 'left', justifyContent: 'center',
1534
+ },
1535
+ },
1536
+ (door.bullets ?? []).map((line, i) => h('div', {
1459
1537
  key: i,
1460
1538
  className: 'lc-meta',
1461
- style: { display: 'flex', gap: 5, lineHeight: 1.5 },
1462
- }, h('span', { style: { color: t.fg, flex: '0 0 auto' } }, '·'), h('span', null, line)))),
1463
- // 门底那道横杠:推门的那一下
1539
+ style: { display: 'flex', gap: 6, lineHeight: 1.55, alignItems: 'flex-start' },
1540
+ },
1541
+ h('span', { style: { color: t.fg, flex: '0 0 auto', fontWeight: 700 } }, '·'),
1542
+ h('span', null, line),
1543
+ )),
1544
+ ),
1545
+ // ── 门槛:底部那条,写着推门 ──
1464
1546
  h('div', {
1465
1547
  style: {
1466
- flex: '0 0 auto', marginTop: 4, paddingTop: 10, width: '100%',
1548
+ flex: '0 0 auto', marginTop: 'auto',
1467
1549
  borderTop: '1px solid var(--lc-line)',
1468
- color: t.fg, fontWeight: 700, fontSize: 15,
1550
+ padding: '10px 8px', width: '100%', boxSizing: 'border-box',
1551
+ color: t.fg, fontWeight: 700, fontSize: 13.5,
1552
+ background: t.bg,
1469
1553
  },
1470
1554
  }, '推门进去 →'),
1555
+ // ── 把手:右下角一个小圆点,门的那个意思 ──
1556
+ h('div', {
1557
+ 'aria-hidden': 'true',
1558
+ style: {
1559
+ position: 'absolute', right: 10, bottom: 52,
1560
+ width: 7, height: 7, borderRadius: 999,
1561
+ background: t.fg, opacity: .55,
1562
+ },
1563
+ }),
1471
1564
  )
1472
1565
  }),
1473
1566
  ),
@@ -1475,7 +1568,7 @@ function DoorsView({ onPick, state }) {
1475
1568
  )
1476
1569
  }
1477
1570
 
1478
- function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onProposal, busy, onDoDoctor, showMore, onMore, intro, onIntroDone, onMaterial, onDeleteMaterial, chats, onOpenChat, onForkCard, onDeleteCard }) {
1571
+ 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
1572
  // 这扇门是不是「真实人物记录」。写小说、酒馆那两扇门里不该出现
1480
1573
  // 「贴一份招聘说明」「从记忆里认人」这类动作——那是记录真人才用得上的。
1481
1574
  const isReal = mode !== 'novel' && mode !== 'tavern'
@@ -1493,6 +1586,17 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1493
1586
  // 用户每次回首页都以为自己还有事没做。
1494
1587
  const allProposals = listing?.proposals ?? []
1495
1588
  const proposals = allProposals.filter((p) => (p.adopted ?? 0) < (p.accepted ?? 0))
1589
+ // 「还等着你看的建议」只在**它属于的那扇门**里出现。
1590
+ //
1591
+ // 以前这是个跨屏提醒:在酒馆里写着小说,头顶一直挂着「关于『妈妈』的建议」;
1592
+ // 记录真人的时候又挂着小说角色的建议。建议本身没错,错在它不属于这一屏。
1593
+ // 判据是这条建议说的是谁——那个人有卡就按卡的 kind,没卡(还没建卡)算现实。
1594
+ const pendingProposals = proposals.filter((p) => {
1595
+ const name = String(p.about ?? '').trim()
1596
+ const hit = name === '' ? undefined : cards.find((c) => String(c.id ?? '') === name || String(c.name ?? '').trim() === name)
1597
+ const isFiction = hit !== undefined && hit.kind === 'fictional'
1598
+ return isFiction === (mode !== 'real')
1599
+ })
1496
1600
  const materials = listing?.materials ?? []
1497
1601
  // 搜索也要筛材料:搜一个人的名字,上面卡筛掉了、下面材料还整整齐齐列着,
1498
1602
  // 看着像「搜到了这一份」,其实根本没筛。
@@ -1620,6 +1724,36 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1620
1724
  : null,
1621
1725
  ),
1622
1726
 
1727
+ // 写小说那扇门:先列书架。没有它,用户建了一本书再回来就找不到了。
1728
+ mode === 'novel'
1729
+ ? h('div', null,
1730
+ h(GroupTitle, { tone: 'import', icon: 'book' }, `我的书 · ${(books ?? []).length}`),
1731
+ (books ?? []).length === 0
1732
+ ? h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } },
1733
+ '还没有书。新建一本,让 AI 先想大纲,再一章一章写。')
1734
+ : h('div', { className: 'lc-stack' }, (books ?? []).map((b) => h('button', {
1735
+ key: b.id,
1736
+ className: 'lc-card-item',
1737
+ type: 'button',
1738
+ onClick: () => onOpenBook?.(b),
1739
+ style: { textAlign: 'left', font: 'inherit', color: 'inherit', width: '100%' },
1740
+ },
1741
+ h('div', { className: 'lc-avatar', style: { background: 'var(--lc-import-soft)', color: 'var(--lc-import)' } }, h(Icon, { name: 'book', size: 14 })),
1742
+ h('div', { className: 'lc-grow', style: { minWidth: 0 } },
1743
+ h('div', { className: 'lc-ellipsis', style: { fontWeight: 650 } }, String(b.title ?? b.id)),
1744
+ h('div', { className: 'lc-meta' },
1745
+ [`${b.chapters ?? 0} 章`, `${Math.max(1, Math.round((b.words ?? 0) / 1000))} 千字`]
1746
+ .concat(String(b.genre ?? '').trim() === '' ? [] : [String(b.genre)])
1747
+ .join(' · ')),
1748
+ String(b.idea ?? '').trim() === '' ? null : h('div', { className: 'lc-meta lc-ellipsis' }, String(b.idea)),
1749
+ ),
1750
+ ))),
1751
+ h('div', { className: 'lc-row', style: { marginTop: 8 } },
1752
+ h(Btn, { size: 'sm', tone: 'import', icon: 'plus', onClick: () => onCreateBook?.() }, '新建一本书'),
1753
+ ),
1754
+ )
1755
+ : null,
1756
+
1623
1757
  // 酒馆门里先列「说过的场次」:这是回来的路。没有它,用户退出一场
1624
1758
  // 就再也找不回来——只能重新开一场,前面那些话看着像丢了。
1625
1759
  chatRows.length > 0
@@ -1724,10 +1858,16 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1724
1858
  h('div', { className: 'lc-stack' }, worlds.map((w) => h(WorldRow, { key: w.path, world: w, onOpen: onOpenWorld, photo: avatars[w.id] }))),
1725
1859
  ),
1726
1860
 
1727
- proposals.length > 0
1861
+ // 「还等着你看的建议」只在**它属于的那扇门**里出现。
1862
+ //
1863
+ // 以前这是个跨屏提醒:在酒馆里写着小说,头顶一直挂着「关于『妈妈』的建议」;
1864
+ // 记录真人的时候又挂着小说角色的建议。建议本身没错,错在它不属于这一屏。
1865
+ // 判据是:这条建议说的是谁——那个人有卡就按卡的 kind(虚构/现实),
1866
+ // 没卡就按「还没建卡」算现实(绝大多数情况是给人建卡时读出来的)。
1867
+ pendingProposals.length > 0
1728
1868
  ? h('div', null,
1729
- h(GroupTitle, { tone: 'analyze', icon: 'spark' }, `还等着你看的建议 · ${proposals.length}`),
1730
- h('div', { className: 'lc-stack' }, proposals.slice(0, 6).map((p) => h('button', {
1869
+ h(GroupTitle, { tone: 'analyze', icon: 'spark' }, `还等着你看的建议 · ${pendingProposals.length}`),
1870
+ h('div', { className: 'lc-stack' }, pendingProposals.slice(0, 6).map((p) => h('button', {
1731
1871
  key: p.json, className: 'lc-card-item', type: 'button',
1732
1872
  onClick: () => onProposal(p.json),
1733
1873
  },
@@ -1743,8 +1883,8 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1743
1883
  ))),
1744
1884
  // 一次只摆 6 份。不说明的话,第 7 份起就永远看不见——
1745
1885
  // 用户会以为那些建议丢了。
1746
- proposals.length > 6
1747
- ? h('div', { className: 'lc-meta' }, `还有 ${proposals.length - 6} 份没列出来。处理掉上面任意一份,它就会顶上来。`)
1886
+ pendingProposals.length > 6
1887
+ ? h('div', { className: 'lc-meta' }, `还有 ${pendingProposals.length - 6} 份没列出来。处理掉上面任意一份,它就会顶上来。`)
1748
1888
  : null,
1749
1889
  )
1750
1890
  : null,
@@ -1951,6 +2091,254 @@ function WorldView({ world, listing, onOpenCard }) {
1951
2091
  * 不能因为这个丢掉;
1952
2092
  * 3. 退出就是退出,不弹「确定放弃吗」——每一轮都已经在文件里了。
1953
2093
  */
2094
+ /**
2095
+ * 一本书:大纲 → 一章一章写 → 审。
2096
+ *
2097
+ * 这一屏是「写小说」那扇门真正干活的地方。三段对应三件事:
2098
+ * 1. **先想清楚**:让 AI 把整本书的骨架搭出来,落成一份大纲;人能直接改。
2099
+ * 2. **写这一章**:拿大纲里这一章那一句 + 上一章结尾 + 设定,让它写正文。
2100
+ * 3. **挑毛病**:写完让它只挑问题、不夸,人照着改。
2101
+ *
2102
+ * 刻意不做的事:
2103
+ * - 不自动一章接一章往下写。写完一章就停下来等人看——一次生成十章的书
2104
+ * 没人会读,也没人改得动。
2105
+ * - 不问「要不要继续」。按钮在那儿,要不要点是人自己的事。
2106
+ */
2107
+ function BookView({ bookId, onBack, flash }) {
2108
+ const [book, setBook] = useState(null)
2109
+ const [outline, setOutline] = useState(null)
2110
+ const [chapterIndex, setChapterIndex] = useState(0)
2111
+ const [body, setBody] = useState('')
2112
+ const [draft, setDraft] = useState(null)
2113
+ const [review, setReview] = useState(null)
2114
+ const [busy, setBusy] = useState('')
2115
+ const [err, setErr] = useState(null)
2116
+ const [ask, setAsk] = useState('')
2117
+ const [editingOutline, setEditingOutline] = useState(false)
2118
+ const [outlineText, setOutlineText] = useState('')
2119
+
2120
+ const load = async (want) => {
2121
+ setErr(null)
2122
+ try {
2123
+ const r = await call('novel.get', { id: bookId })
2124
+ if (r?.ok !== true) { setErr(r?.error ?? '这本书打不开'); return }
2125
+ setBook(r)
2126
+ const o = await call('novel.outline.get', { id: bookId })
2127
+ if (o?.ok === true) { setOutline(o); setOutlineText(String(o.text ?? '')) }
2128
+ // 默认打开:有章节就打开最后一章,没有就从第一章开始
2129
+ const target = Number(want) > 0 ? Number(want) : (r.chapters.length === 0 ? 1 : r.chapters[r.chapters.length - 1].index)
2130
+ setChapterIndex(target)
2131
+ await openChapter(target)
2132
+ } catch (e) { setErr(humanizeError(e)) }
2133
+ }
2134
+
2135
+ const openChapter = async (index) => {
2136
+ setErr(null); setReview(null); setDraft(null)
2137
+ try {
2138
+ const r = await call('novel.chapter.get', { id: bookId, index })
2139
+ setBody(r?.ok === true ? String(r.body ?? '') : '')
2140
+ } catch (e) { setErr(humanizeError(e)) }
2141
+ }
2142
+
2143
+ useEffect(() => { load(0) }, [bookId])
2144
+
2145
+ const doPlan = async () => {
2146
+ setBusy('plan'); setErr(null)
2147
+ try {
2148
+ const r = await call('novel.outline.draft', { id: bookId, chapters: 12 })
2149
+ if (r?.ok !== true) { setErr(r?.error ?? '这次没想出大纲'); return }
2150
+ setOutline(r); setOutlineText(String(r.text ?? ''))
2151
+ flash?.('大纲想好了,你可以直接改')
2152
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2153
+ }
2154
+
2155
+ const saveOutline = async () => {
2156
+ setBusy('outline'); setErr(null)
2157
+ try {
2158
+ const r = await call('novel.outline.save', { id: bookId, text: outlineText })
2159
+ if (r?.ok !== true) { setErr(r?.error ?? '大纲没存上'); return }
2160
+ setOutline({ ...(outline ?? {}), ...r, text: outlineText })
2161
+ setEditingOutline(false)
2162
+ flash?.('大纲存好了')
2163
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2164
+ }
2165
+
2166
+ const doWrite = async () => {
2167
+ setBusy('write'); setErr(null); setReview(null)
2168
+ try {
2169
+ const r = await call('novel.chapter.draft', { id: bookId, index: chapterIndex, ask })
2170
+ if (r?.ok !== true) { setErr(r?.error ?? '这次没写出来'); return }
2171
+ setDraft(String(r.draft ?? ''))
2172
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2173
+ }
2174
+
2175
+ const acceptDraft = async () => {
2176
+ if (draft === null) return
2177
+ setBusy('save'); setErr(null)
2178
+ try {
2179
+ const merged = body.trim() === '' ? draft : `${body.trim()}\n\n${draft}`
2180
+ const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body: merged })
2181
+ if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
2182
+ setBody(merged); setDraft(null)
2183
+ flash?.(`第 ${chapterIndex} 章存好了`)
2184
+ await load(chapterIndex)
2185
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2186
+ }
2187
+
2188
+ const saveBody = async () => {
2189
+ setBusy('save'); setErr(null)
2190
+ try {
2191
+ const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body })
2192
+ if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
2193
+ flash?.(`第 ${chapterIndex} 章存好了`)
2194
+ await load(chapterIndex)
2195
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2196
+ }
2197
+
2198
+ const doReview = async () => {
2199
+ setBusy('review'); setErr(null)
2200
+ try {
2201
+ const r = await call('novel.chapter.review', { id: bookId, index: chapterIndex })
2202
+ if (r?.ok !== true) { setErr(r?.error ?? '这次没审成'); return }
2203
+ setReview(String(r.review ?? ''))
2204
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2205
+ }
2206
+
2207
+ if (book === null) {
2208
+ return h('div', { className: 'lc-pad lc-stack' },
2209
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
2210
+ err === null ? h(Spinner) : h(Note, { tone: 'err', icon: 'warning' }, err),
2211
+ )
2212
+ }
2213
+
2214
+ const chapters = Array.isArray(book.chapters) ? book.chapters : []
2215
+ const plan = outline?.ok === true ? (outline.chapters ?? []) : []
2216
+ const planItem = plan.find((c) => c.index === chapterIndex)
2217
+ const title = String(book.data?.title ?? bookId)
2218
+
2219
+ return h('div', { className: 'lc-pad lc-stack', style: { gap: 12 } },
2220
+ h('div', { className: 'lc-row' },
2221
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
2222
+ h('div', { className: 'lc-grow lc-ellipsis', style: { fontWeight: 700, fontSize: 16.5 } }, `《${title}》`),
2223
+ h('span', { className: 'lc-meta' }, `${chapters.length} 章`),
2224
+ ),
2225
+ String(book.idea ?? '').trim() === '' ? null : h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } }, String(book.idea).trim()),
2226
+
2227
+ // ── 第一段:规划 ──
2228
+ h('div', { className: 'lc-sec' },
2229
+ h('div', { className: 'lc-row' },
2230
+ h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, '① 先想清楚这本书怎么走')),
2231
+ h(Btn, { size: 'sm', tone: 'import', icon: 'spark', disabled: busy !== '', onClick: doPlan },
2232
+ busy === 'plan' ? '正在想…' : (plan.length === 0 ? '让 AI 想大纲' : '重新想一版')),
2233
+ plan.length === 0 ? null : h(Btn, { size: 'sm', ghost: true, icon: 'edit', onClick: () => setEditingOutline(!editingOutline) },
2234
+ editingOutline ? '收起' : '自己改'),
2235
+ ),
2236
+ plan.length === 0
2237
+ ? h('div', { className: 'lc-meta', style: { marginTop: 6, lineHeight: 1.7 } },
2238
+ '先让它把整本书的骨架搭出来:主线、每章要发生什么、结局。',
2239
+ h('br'),
2240
+ '搭完你能直接改——改一行,后面写出来的东西就跟着变。')
2241
+ : h('div', { className: 'lc-stack', style: { marginTop: 6 } },
2242
+ outline.theme === '' ? null : h('div', { className: 'lc-meta' }, `主线:${outline.theme}`),
2243
+ h('div', { className: 'lc-stack', style: { gap: 2 } }, plan.map((c) => h('div', {
2244
+ key: c.index,
2245
+ className: 'lc-meta',
2246
+ style: {
2247
+ padding: '3px 6px', borderRadius: 6, cursor: 'pointer', lineHeight: 1.6,
2248
+ background: c.index === chapterIndex ? 'var(--lc-accent-soft)' : 'transparent',
2249
+ color: c.index === chapterIndex ? 'var(--lc-accent)' : undefined,
2250
+ },
2251
+ onClick: () => { setChapterIndex(c.index); openChapter(c.index) },
2252
+ }, `第 ${c.index} 章 · ${c.plan}`))),
2253
+ outline.ending === '' ? null : h('div', { className: 'lc-meta' }, `结局:${outline.ending}`),
2254
+ ),
2255
+ editingOutline
2256
+ ? h('div', { className: 'lc-stack', style: { marginTop: 6 } },
2257
+ h('textarea', {
2258
+ className: 'lc-textarea', value: outlineText, style: { minHeight: 180 },
2259
+ onChange: (e) => setOutlineText(e.target.value), spellCheck: false,
2260
+ }),
2261
+ h('div', { className: 'lc-row' },
2262
+ h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveOutline }, busy === 'outline' ? '正在存…' : '存好'),
2263
+ h('div', { className: 'lc-meta' }, '每行一章,写清这一章要发生什么。'),
2264
+ ),
2265
+ )
2266
+ : null,
2267
+ ),
2268
+
2269
+ // ── 第二段:写这一章 ──
2270
+ h('div', { className: 'lc-sec' },
2271
+ h('div', { className: 'lc-row' },
2272
+ h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, `② 写第 ${chapterIndex} 章`)),
2273
+ h(Btn, { size: 'sm', tone: 'analyze', icon: 'spark', disabled: busy !== '', onClick: doWrite },
2274
+ busy === 'write' ? '正在写…' : '让 AI 写这一章'),
2275
+ ),
2276
+ planItem === undefined
2277
+ ? h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '大纲里还没有这一章。先让 AI 想一版大纲,或者直接写也行。')
2278
+ : h('div', { className: 'lc-meta', style: { marginTop: 4, lineHeight: 1.6 } }, `这一章要写:${planItem.plan}`),
2279
+ h('div', { className: 'lc-row', style: { marginTop: 6 } },
2280
+ h('input', {
2281
+ className: 'lc-input', value: ask, spellCheck: false,
2282
+ placeholder: '(可选)这一章你想强调什么?比如「写他们吵架那场」',
2283
+ onChange: (e) => setAsk(e.target.value),
2284
+ }),
2285
+ ),
2286
+ draft === null
2287
+ ? null
2288
+ : h('div', { className: 'lc-stack', style: { marginTop: 8 } },
2289
+ h(Note, { tone: 'ok', icon: 'spark' }, '写好了。看一眼,满意就收下,不满意可以不收。'),
2290
+ h('div', {
2291
+ style: {
2292
+ whiteSpace: 'pre-wrap', lineHeight: 1.85, fontSize: 14.5,
2293
+ padding: '10px 12px', borderRadius: 10,
2294
+ background: 'var(--lc-analyze-soft)', border: '1px solid var(--lc-analyze)',
2295
+ },
2296
+ }, draft),
2297
+ h('div', { className: 'lc-row' },
2298
+ h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: acceptDraft },
2299
+ busy === 'save' ? '正在存…' : '收下,写进这一章'),
2300
+ h(Btn, { size: 'sm', ghost: true, icon: 'trash', disabled: busy !== '', onClick: () => setDraft(null) }, '不要这段'),
2301
+ ),
2302
+ ),
2303
+ ),
2304
+
2305
+ // ── 正文 ──
2306
+ h('div', { className: 'lc-sec' },
2307
+ h('div', { className: 'lc-row' },
2308
+ h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, '正文')),
2309
+ h('span', { className: 'lc-meta' }, `${body.length} 字`),
2310
+ ),
2311
+ h('textarea', {
2312
+ className: 'lc-textarea',
2313
+ value: body,
2314
+ spellCheck: false,
2315
+ placeholder: '这一章还空着。让 AI 写一段,或者自己动手。',
2316
+ style: { minHeight: 260, marginTop: 6, lineHeight: 1.85 },
2317
+ onChange: (e) => setBody(e.target.value),
2318
+ }),
2319
+ h('div', { className: 'lc-row' },
2320
+ h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveBody }, busy === 'save' ? '正在存…' : '存好这一章'),
2321
+ h(Btn, { size: 'sm', icon: 'doctor', disabled: busy !== '' || body.trim() === '', onClick: doReview },
2322
+ busy === 'review' ? '正在挑毛病…' : '挑挑毛病'),
2323
+ h('div', { className: 'lc-grow' }),
2324
+ h(Btn, { size: 'sm', ghost: true, icon: 'plus', onClick: async () => {
2325
+ const next = chapterIndex + 1
2326
+ setChapterIndex(next)
2327
+ await openChapter(next)
2328
+ } }, `写第 ${chapterIndex + 1} 章`),
2329
+ ),
2330
+ review === null
2331
+ ? null
2332
+ : h('div', { className: 'lc-stack', style: { marginTop: 8 } },
2333
+ h(Note, { tone: 'warn', icon: 'warning' }, '它挑出来的问题(不是夸奖,也不用全听)'),
2334
+ h('div', { style: { whiteSpace: 'pre-wrap', lineHeight: 1.8, fontSize: 14 } }, review),
2335
+ ),
2336
+ ),
2337
+
2338
+ err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
2339
+ )
2340
+ }
2341
+
1954
2342
  function TavernView({ chat, onChat, onBack }) {
1955
2343
  const [draft, setDraft] = useState('')
1956
2344
  const [busy, setBusy] = useState(false)