{item.text}
+ {children}
; + }, + // 列表 + ul({ children, ...props }) { + return{children}; + }, + // 标题 + h1({ children, ...props }) { return
{code}
+ $1');
+
+ // Blockquote
+ html = html.replace(/^> (.+)$/gm, '$1'); + + // List items + html = html.replace(/^- (.+)$/gm, '
$1
'); + + // Clean up empty paragraphs + html = html.replace(/\s*<\/p>/g, ''); + + return html; +} diff --git a/app/components/MathExtension.js b/app/components/MathExtension.js new file mode 100644 index 0000000..0b18457 --- /dev/null +++ b/app/components/MathExtension.js @@ -0,0 +1,282 @@ +'use client'; + +import { Node, mergeAttributes } from '@tiptap/core'; +import { InputRule } from '@tiptap/core'; +import katex from 'katex'; + +// ==================== 公式编辑弹窗(居中大框) ==================== +export function openMathEditor(currentLatex, onSave) { + // 创建遮罩 + const overlay = document.createElement('div'); + overlay.className = 'math-editor-overlay'; + + // 创建弹窗 + const dialog = document.createElement('div'); + dialog.className = 'math-editor-dialog'; + dialog.innerHTML = ` +
+ {folders.length > 0 && `${folders.length} 个子文件夹 · `} + {items.length} 个设定项 +
+{t('settingsEditor.emptyDesc')}
+{t('settingsEditor.selectDesc')}
++ {t('preferences.intro')} +
+ +{t('apiConfig.intro')}
+ + {/* 供应商选择 */} +{t('bookInfo.intro')}
++ {t('snapshot.createdAt')} {new Date(selectedSnap.timestamp).toLocaleString()} + {selectedSnap.type === 'auto' && ` ${t('snapshot.autoLabel')}`} +
+ ++ {t('snapshot.restoreWarning')} +
+{t('tour.introSubtitle')}
+{t('tour.introHint')}
+{t('welcome.subtitle')}
+ +dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJw b z_^v8bbg` SAn{I*4bH$u(RZ6*x UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p C^ S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk( $?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU ^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c 70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397* _cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU &68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-= A= yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v #ix45EVrcEhr>!NMhprl $InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~ &^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7< 4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}sc Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+ 9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2 `1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M =hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S( O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..5531159 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,4742 @@ +@import "tailwindcss"; + +/* ==================== Author 设计系统 ==================== */ + +:root { + /* 基础动画曲线: 更加丝滑的弹性反馈 */ + --transition-fast: 0.15s cubic-bezier(0.16, 1, 0.3, 1); + --transition-normal: 0.25s cubic-bezier(0.16, 1, 0.3, 1); + --transition-slow: 0.4s cubic-bezier(0.16, 1, 0.3, 1); + + --font-writing: 'Noto Serif SC', 'Source Han Serif SC', 'STSong', Georgia, serif; + --font-ui: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif; + + --editor-font-size: 17px; + --editor-line-height: 1.9; + --editor-max-width: 816px; +} + +/* 默认 / 经典暖纸模式 (Warm Classic) */ +:root, +[data-visual="warm"] { + --bg-primary: #faf8f5; + --bg-secondary: #f5f0ea; + --bg-editor: #fffdf9; + --bg-sidebar: #f7f3ed; + --bg-hover: #efe9e0; + --bg-active: #e8dfd3; + --bg-card: #ffffff; + --bg-canvas: #e8e4df; + --bg-glass: rgba(250, 248, 245, 0.85); + + --text-primary: #2c2417; + --text-secondary: #6b5e4f; + --text-muted: #9a8d7e; + --text-inverse: #faf8f5; + + --border-light: #e5ddd2; + --border-medium: #d4c9ba; + --border-glass: rgba(229, 221, 210, 0.5); + + --accent: #c4956a; + --accent-hover: #b38358; + --accent-light: rgba(196, 149, 106, 0.15); + --accent-glow: rgba(196, 149, 106, 0.3); + + --shadow-sm: 0 1px 3px rgba(44, 36, 23, 0.06); + --shadow-md: 0 4px 12px rgba(44, 36, 23, 0.08); + --shadow-lg: 0 12px 32px rgba(44, 36, 23, 0.12); + --shadow-page: 0 8px 30px rgba(44, 36, 23, 0.08), 0 1px 3px rgba(44, 36, 23, 0.04); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-full: 99px; +} + +/* 暖色纸张 暗色模式 */ +[data-visual="warm"][data-theme="dark"], +:root[data-theme="dark"] { + --bg-primary: #1a1814; + --bg-secondary: #221f1a; + --bg-editor: #1e1b17; + --bg-sidebar: #16140f; + --bg-hover: #2a2620; + --bg-active: #33302a; + --bg-card: #242019; + --bg-canvas: #121010; + --bg-glass: rgba(26, 24, 20, 0.85); + + --text-primary: #e8e0d4; + --text-secondary: #a89a88; + --text-muted: #756a5c; + --text-inverse: #1a1814; + + --border-light: #33302a; + --border-medium: #443f36; + --border-glass: rgba(51, 48, 42, 0.5); + + --accent: #d4a87a; + --accent-hover: #e0b88a; + --accent-light: rgba(212, 168, 122, 0.15); + --accent-glow: rgba(212, 168, 122, 0.25); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); + --shadow-page: 0 8px 30px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* 现代极简透玻璃主题 (Modern Glass) */ +[data-visual="modern"] { + --bg-primary: #fcfcfd; + --bg-secondary: #f2f3f5; + --bg-editor: #ffffff; + --bg-sidebar: #f7f8f9; + --bg-hover: #e8eaed; + --bg-active: #dfe1e5; + --bg-card: #ffffff; + --bg-canvas: #eaebed; + --bg-glass: rgba(255, 255, 255, 0.7); + + --text-primary: #111827; + --text-secondary: #4b5563; + --text-muted: #9ca3af; + --text-inverse: #ffffff; + + --border-light: #e5e7eb; + --border-medium: #d1d5db; + --border-glass: rgba(255, 255, 255, 0.5); + + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-light: rgba(59, 130, 246, 0.1); + --accent-glow: rgba(59, 130, 246, 0.25); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + --shadow-lg: 0 10px 25px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03); + --shadow-page: 0 10px 40px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.02); + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 24px; + --radius-full: 99px; +} + +/* 现代主题 暗色模式 */ +[data-visual="modern"][data-theme="dark"] { + --bg-primary: #111111; + --bg-secondary: #1a1a1a; + --bg-editor: #161616; + --bg-sidebar: #141414; + --bg-hover: #262626; + --bg-active: #333333; + --bg-card: #222222; + --bg-canvas: #0a0a0a; + --bg-glass: rgba(22, 22, 22, 0.7); + + --text-primary: #f3f4f6; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + --text-inverse: #111111; + + --border-light: #2d2d2d; + --border-medium: #404040; + --border-glass: rgba(255, 255, 255, 0.08); + + --accent: #60a5fa; + --accent-hover: #93c5fd; + --accent-light: rgba(96, 165, 250, 0.15); + --accent-glow: rgba(96, 165, 250, 0.25); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 10px 25px -3px rgba(0, 0, 0, 0.6); + --shadow-page: 0 10px 40px rgba(0, 0, 0, 0.7), 0 1px 3px rgba(0, 0, 0, 0.5); +} + +/* ==================== 基础样式 ==================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + + + +body { + font-family: var(--font-ui); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background var(--transition-normal), color var(--transition-normal); +} + +::selection { + background: var(--accent-light); + color: var(--text-primary); +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-medium); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* ==================== 布局 ==================== */ + +.app-layout { + display: flex; + height: 100vh; + overflow: hidden; + position: relative; +} + +.sidebar { + position: absolute !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + width: 280px !important; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-light); + display: flex; + flex-direction: column; + transition: transform var(--transition-normal); + z-index: 30 !important; + transform: translateX(0) !important; +} + +.sidebar.collapsed { + transform: translateX(-100%) !important; + pointer-events: none !important; +} + +.sidebar-header { + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-logo { + font-family: var(--font-writing); + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: 1px; +} + +.sidebar-logo span { + color: var(--accent); +} + +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.sidebar-footer { + padding: 12px; + border-top: 1px solid var(--border-light); + display: flex; + align-items: center; + gap: 8px; +} + +/* ==================== 章节列表 ==================== */ + +.chapter-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.chapter-item { + padding: 10px 14px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--text-secondary); + font-size: 14px; +} + +.chapter-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.chapter-item.active { + background: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +.chapter-item .chapter-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chapter-item .chapter-count { + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +.chapter-item .chapter-actions { + display: none; + gap: 4px; + flex-shrink: 0; +} + +.chapter-item:hover .chapter-actions { + display: flex; +} + +.chapter-item:hover .chapter-count { + display: none; +} + +/* ==================== 主内容区 ==================== */ + +.main-content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; +} + +.editor-toolbar { + padding: 8px 20px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + flex-wrap: wrap; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 2px; +} + +.toolbar-divider { + width: 1px; + height: 20px; + background: var(--border-light); + margin: 0 6px; +} + +.toolbar-btn { + padding: 6px 8px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; +} + +.toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.toolbar-btn.active { + background: var(--accent-light); + color: var(--accent); +} + +/* 工具栏下拉容器 */ +.toolbar-dropdown-wrap { + position: relative; + display: flex; + align-items: center; +} + +.toolbar-dropdown-btn { + font-size: 12px !important; + gap: 4px; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-arrow { + font-size: 9px; + opacity: 0.5; + margin-left: 2px; +} + +/* 工具栏下拉菜单 */ +.toolbar-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 180px; + z-index: 60; + padding: 4px; + animation: fadeIn 0.12s ease; +} + +.toolbar-dropdown-item { + display: block; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + text-align: left; + color: var(--text-primary); + font-size: 13px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.1s; + font-family: var(--font-ui); +} + +.toolbar-dropdown-item:hover { + background: var(--bg-hover); +} + +.toolbar-dropdown-item.active { + background: var(--accent-light); + color: var(--accent); + font-weight: 600; +} + +/* 颜色按钮 */ +.toolbar-color-btn { + gap: 2px !important; + font-weight: 700; +} + +/* 颜色选择器浮层 */ +.color-picker-popover { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 6px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: 12px; + z-index: 60; + min-width: 200px; + animation: fadeIn 0.12s ease; +} + +.color-picker-label { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 8px; + font-weight: 500; +} + +.color-picker-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 4px; + margin-bottom: 8px; +} + +.color-swatch { + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.1s; +} + +.color-swatch:hover { + transform: scale(1.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.color-swatch.active { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + +.color-picker-clear { + width: 100%; + padding: 5px 0; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 11px; + font-family: var(--font-ui); + cursor: pointer; + transition: all 0.15s; +} + +.color-picker-clear:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ==================== 虚拟分页层系统 ==================== */ + +.editor-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 16px 80px; + background: var(--bg-canvas) !important; + transition: background var(--transition-normal); + /* 禁用滚动锚定,防止虚拟分页元素重排时导致原生滚动条跳动 */ + overflow-anchor: none; +} + +/* 统率背景卡片和文本的包裹层 */ +.document-workspace { + position: relative; + width: 100%; + max-width: var(--editor-max-width); + margin: 0 auto; + cursor: text; + /* 禁用滚动锚定 */ + overflow-anchor: none; +} + +/* 纸页背景层(绝对定位在底层)*/ +.pages-bg-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + pointer-events: none; + z-index: 0; + display: flex; + flex-direction: column; +} + +.page-card { + width: 100%; + background: var(--bg-editor); + box-shadow: var(--shadow-page); + border: 1px solid var(--border-light); + border-radius: 2px; + flex-shrink: 0; +} + +/* 文本前景层(透明且连续流淌)*/ +.pages-fg-layer { + position: relative; + z-index: 1; + width: 100%; +} + + +.chapter-title-input { + font-family: var(--font-writing); + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + border: none; + outline: none; + background: transparent; + width: 100%; + margin-bottom: 24px; + padding: 0; + line-height: 1.4; +} + +.chapter-title-input::placeholder { + color: var(--text-muted); +} + +/* Tiptap 编辑器样式 */ +.tiptap { + font-family: var(--font-writing); + font-size: var(--editor-font-size); + line-height: var(--editor-line-height); + color: var(--text-primary); + outline: none; + caret-color: var(--accent); + background: transparent; + box-sizing: border-box; + min-height: 1056px; + padding: var(--page-margin-y, 96px) var(--page-margin-x, 96px); +} + +.tiptap p { + margin-bottom: 0.8em; + text-indent: 2em; +} + +.tiptap p:first-child { + text-indent: 0; +} + +/* 含公式/特殊元素的段落取消首行缩进 */ +.tiptap p:has(.math-inline), +.tiptap p:has(.math-block) { + text-indent: 0; +} + +.tiptap h1 { + font-size: 1.8em; + font-weight: 700; + margin: 1.5em 0 0.8em; + line-height: 1.3; +} + +.tiptap h2 { + font-size: 1.4em; + font-weight: 600; + margin: 1.2em 0 0.6em; + line-height: 1.3; +} + +.tiptap h3 { + font-size: 1.15em; + font-weight: 600; + margin: 1em 0 0.5em; + line-height: 1.3; +} + +.tiptap strong { + font-weight: 700; + color: var(--text-primary); +} + +.tiptap em { + font-style: italic; +} + +.tiptap u { + text-decoration: underline; + text-underline-offset: 3px; +} + +.tiptap mark { + background: var(--accent-light); + border-radius: 2px; + padding: 1px 4px; +} + +.tiptap blockquote { + border-left: 3px solid var(--accent); + padding-left: 16px; + margin: 1em 0; + color: var(--text-secondary); + font-style: italic; +} + +.tiptap hr { + border: none; + border-top: 1px solid var(--border-light); + margin: 2em 0; +} + +.tiptap ul, +.tiptap ol { + padding-left: 1.5em; + margin: 0.8em 0; +} + +.tiptap li { + margin-bottom: 0.3em; + text-indent: 0; +} + +.tiptap li p { + text-indent: 0; +} + +.tiptap .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--text-muted); + pointer-events: none; + height: 0; + font-style: italic; +} + +/* 任务列表 */ +.tiptap ul[data-type="taskList"] { + list-style: none; + padding-left: 0; +} + +.tiptap ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 4px; +} + +.tiptap ul[data-type="taskList"] li>label { + flex-shrink: 0; + margin-top: 4px; +} + +.tiptap ul[data-type="taskList"] li>label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.tiptap ul[data-type="taskList"] li>div { + flex: 1; +} + +.tiptap ul[data-type="taskList"] li[data-checked="true"]>div { + text-decoration: line-through; + color: var(--text-muted); +} + +/* 代码块 */ +.tiptap pre { + background: var(--bg-tertiary, #f4f4f5); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + padding: 12px 16px; + margin: 1em 0; + overflow-x: auto; + font-family: "SF Mono", "Cascadia Code", "Consolas", monospace; + font-size: 0.9em; + line-height: 1.6; +} + +.tiptap pre code { + background: transparent; + padding: 0; + border: none; + font-size: inherit; + color: inherit; +} + +/* 行内代码 */ +.tiptap code { + background: var(--bg-tertiary, #f4f4f5); + padding: 2px 5px; + border-radius: 3px; + font-family: "SF Mono", "Cascadia Code", "Consolas", monospace; + font-size: 0.9em; +} + +/* 删除线 */ +.tiptap s { + text-decoration: line-through; + color: var(--text-muted); +} + +/* 上下标 */ +.tiptap sub { + font-size: 0.75em; + vertical-align: sub; +} + +.tiptap sup { + font-size: 0.75em; + vertical-align: super; +} + +/* 行内公式 — inline-flex 完全无间隙 */ +.math-inline { + display: inline-flex !important; + vertical-align: baseline; + cursor: pointer; + border-radius: 2px; + transition: background 0.15s; + margin: 0 !important; + padding: 0 !important; + border: none; + text-indent: 0; +} + +/* 强制清除 KaTeX 行内公式的所有内部间距 */ +.math-inline .katex { + font-size: 1em; + margin: 0 !important; + padding: 0 !important; + text-indent: 0; +} + +.math-inline .katex-html { + margin: 0 !important; + padding: 0 !important; +} + +.math-inline .katex-mathml { + margin: 0 !important; + padding: 0 !important; +} + +.math-inline .katex .mspace, +.math-inline .katex .mord, +.math-inline .katex .base { + margin: 0 !important; + padding: 0 !important; +} + +.math-inline:hover { + background: var(--accent-light, #e8e0ff); +} + +/* 块级公式 — 居中,无边框,干净 */ +.math-block { + display: block; + text-align: center; + padding: 16px 0; + margin: 0.8em 0; + cursor: pointer; + transition: background 0.15s; + border-radius: var(--radius-sm); +} + +.math-block:hover { + background: var(--bg-hover, #f8f8fa); +} + +.math-block .katex { + font-size: 1.2em; +} + +/* ==================== 公式编辑弹窗 ==================== */ +.math-editor-overlay { + position: fixed; + inset: 0; + z-index: 3000; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.15s ease-out; +} + +.math-editor-overlay.closing { + opacity: 0; + transition: opacity 0.15s; +} + +.math-editor-dialog { + width: 560px; + max-width: 92vw; + background: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.3); + overflow: hidden; + animation: slideUp 0.2s ease-out; +} + +.math-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--border-light); +} + +.math-editor-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.math-editor-close { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 16px; + border-radius: var(--radius-sm); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.math-editor-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* 实时预览区域 */ +.math-editor-preview { + min-height: 80px; + padding: 24px 20px; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid var(--border-light); + background: var(--bg-secondary); + overflow-x: auto; +} + +.math-editor-preview .katex { + font-size: 1.4em; +} + +.math-editor-placeholder { + color: var(--text-muted); + font-style: italic; + font-size: 14px; +} + +.math-editor-error { + color: var(--error, #ef4444); + font-size: 12px; + font-family: monospace; +} + +/* LaTeX 输入框 */ +.math-editor-input { + display: block; + width: 100%; + min-height: 80px; + padding: 14px 20px; + border: none; + outline: none; + font-family: "SF Mono", "Cascadia Code", "Consolas", monospace; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-primary); + resize: vertical; + box-sizing: border-box; +} + +.math-editor-input::placeholder { + color: var(--text-muted); +} + +.math-editor-input:focus { + background: var(--bg-primary); +} + +/* 底部操作栏 */ +.math-editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-top: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.math-editor-hint { + font-size: 11px; + color: var(--text-muted); +} + +.math-editor-actions { + display: flex; + gap: 8px; +} + +.math-editor-cancel { + padding: 6px 16px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + transition: all 0.15s; +} + +.math-editor-cancel:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.math-editor-save { + padding: 6px 20px; + border: none; + background: var(--accent); + color: white; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.15s; +} + +.math-editor-save:hover { + filter: brightness(1.1); +} + +/* ==================== AI 面板 ==================== */ + +.ai-panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + width: 400px; + background: var(--bg-card); + border-left: 1px solid var(--border-light); + box-shadow: var(--shadow-lg); + z-index: 30; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform var(--transition-slow); +} + +.ai-panel.open { + transform: translateX(0); +} + +.ai-panel-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ai-panel-header h3 { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.ai-panel-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.ai-result { + padding: 16px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + font-family: var(--font-writing); + font-size: 15px; + line-height: 1.8; + color: var(--text-primary); + white-space: pre-wrap; + position: relative; +} + +.ai-result-actions { + display: flex; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-light); +} + +.ai-panel-footer { + padding: 16px 20px; + border-top: 1px solid var(--border-light); +} + +.ai-mode-tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; +} + +.ai-mode-tab { + padding: 6px 14px; + border: 1px solid var(--border-light); + background: transparent; + color: var(--text-secondary); + border-radius: 20px; + cursor: pointer; + font-size: 13px; + transition: all var(--transition-fast); +} + +.ai-mode-tab:hover { + border-color: var(--accent); + color: var(--accent); +} + +.ai-mode-tab.active { + background: var(--accent); + border-color: var(--accent); + color: var(--text-inverse); +} + +.ai-input-group { + display: flex; + gap: 8px; +} + +.ai-input { + flex: 1; + padding: 10px 14px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + font-family: var(--font-ui); + resize: none; + outline: none; + transition: border-color var(--transition-fast); +} + +.ai-input:focus { + border-color: var(--accent); +} + +/* ==================== 按钮 (Premium UI) ==================== */ + +.btn { + padding: 8px 16px; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + font-size: 13px; + font-weight: 500; + font-family: var(--font-ui); + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + display: inline-flex; + align-items: center; + gap: 6px; + outline: none; + box-shadow: var(--shadow-sm); +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: white; + box-shadow: 0 4px 12px var(--accent-light); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-2px); + box-shadow: 0 6px 16px var(--accent-glow); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-secondary { + background: var(--bg-hover); + color: var(--text-secondary); + border-color: var(--border-light); +} + +.btn-secondary:hover { + background: var(--bg-active); + color: var(--text-primary); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + box-shadow: none; +} + +.btn-ghost:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: translateY(-1px); +} + +/* ==================== 毛玻璃复用类 ==================== */ +.glass-panel { + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-glass); +} + +.btn-icon { + padding: 6px; + min-width: 32px; + height: 32px; + justify-content: center; +} + +.btn-sm { + padding: 5px 10px; + font-size: 12px; +} + +.btn-stop { + background: var(--error); + color: #fff; + font-weight: 600; +} + +.btn-stop:hover { + background: #a33; + filter: brightness(1.1); +} + +/* 流式输出光标 */ +.streaming-cursor { + display: inline-block; + animation: blink 0.6s steps(2) infinite; + color: var(--accent); + font-weight: 300; + margin-left: 1px; +} + +@keyframes blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +/* ==================== 状态栏 ==================== */ + +.status-bar { + padding: 6px 20px; + border-top: 1px solid var(--border-light); + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); + position: relative; +} + +.status-bar-left, +.status-bar-right { + display: flex; + align-items: center; + gap: 16px; +} + + +/* 排版设置浮层 */ +.typeset-popover { + position: absolute; + bottom: 100%; + right: 16px; + margin-bottom: 6px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: 16px 20px; + min-width: 260px; + z-index: 50; + animation: fadeIn 0.15s ease; +} + +.typeset-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.typeset-row:last-of-type { + margin-bottom: 14px; +} + +.typeset-row label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + min-width: 32px; + font-family: var(--font-ui); +} + +.typeset-row input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border-light); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.typeset-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg-card); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: transform 0.15s; +} + +.typeset-row input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.typeset-value { + font-size: 12px; + font-weight: 600; + color: var(--accent); + min-width: 40px; + text-align: right; + font-family: 'SF Mono', 'Cascadia Code', monospace; +} + +.typeset-reset { + width: 100%; + padding: 6px 0; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + transition: all 0.15s; +} + +.typeset-reset:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-medium); +} + +/* ==================== 浮动 AI 按钮 ==================== */ + +.ai-fab { + position: fixed; + bottom: 48px; + right: 32px; + width: 52px; + height: 52px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: white; + border: none; + cursor: pointer; + box-shadow: 0 4px 16px var(--accent-glow); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + transition: all var(--transition-normal); + z-index: 25; +} + +.ai-fab:hover { + transform: scale(1.08); + box-shadow: 0 6px 24px var(--accent-glow); +} + +.ai-fab.active { + background: var(--text-secondary); + box-shadow: none; +} + +/* ==================== 弹出菜单 ==================== */ + +.dropdown { + position: relative; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + min-width: 160px; + z-index: 50; + padding: 4px; + animation: slideUp 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.dropdown-item { + padding: 8px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + transition: all var(--transition-fast); + display: flex; + align-items: center; + gap: 8px; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.dropdown-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.dropdown-item.danger:hover { + background: rgba(196, 106, 106, 0.1); + color: var(--error); +} + +/* ==================== 模态框 ==================== */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.25); + backdrop-filter: blur(4px); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.modal { + background: var(--bg-glass); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.15); + width: 90%; + max-width: 480px; + padding: 24px; + animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.modal h2 { + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; +} + +.modal-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + font-family: var(--font-ui); + outline: none; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + margin-bottom: 16px; +} + +.modal-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); + background: var(--bg-primary); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ==================== 设置面板 ==================== */ + +.settings-group { + margin-bottom: 20px; +} + +.settings-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.settings-slider { + width: 100%; + accent-color: var(--accent); +} + +/* ==================== 加载动画 ==================== */ + +.loading-dots { + display: inline-flex; + gap: 4px; +} + +.loading-dots span { + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out both; +} + +.loading-dots span:nth-child(1) { + animation-delay: -0.32s; +} + +.loading-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +.loading-dots span:nth-child(3) { + animation-delay: 0s; +} + +/* ==================== Toast 通知 ==================== */ + +.toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 13px; + color: var(--text-primary); + animation: slideIn 0.3s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.toast.success { + border-left: 3px solid var(--success); +} + +.toast.error { + border-left: 3px solid var(--error); +} + +.toast.info { + border-left: 3px solid var(--info); +} + +/* ==================== 动画 ==================== */ + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes bounce { + + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} + +/* ==================== 响应式 ==================== */ + +@media (max-width: 768px) { + .sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 40; + box-shadow: var(--shadow-lg); + } + + .sidebar.collapsed { + transform: translateX(-100%); + } + + .ai-panel { + width: 100%; + } + + .document-workspace { + padding: 0; + } +} + +/* ==================== 设定集分类配色 ==================== */ + +:root { + --cat-work: #d97706; + --cat-work-bg: rgba(217, 119, 6, 0.1); + --cat-character: #3b82f6; + --cat-character-bg: rgba(59, 130, 246, 0.1); + --cat-location: #10b981; + --cat-location-bg: rgba(16, 185, 129, 0.1); + --cat-world: #8b5cf6; + --cat-world-bg: rgba(139, 92, 246, 0.1); + --cat-object: #f59e0b; + --cat-object-bg: rgba(245, 158, 11, 0.1); + --cat-plot: #ef4444; + --cat-plot-bg: rgba(239, 68, 68, 0.1); + --cat-rules: #6b7280; + --cat-rules-bg: rgba(107, 114, 128, 0.1); + --cat-custom: #06b6d4; + --cat-custom-bg: rgba(6, 182, 212, 0.1); + --cat-bookinfo: #c4956a; + --cat-bookinfo-bg: rgba(196, 149, 106, 0.1); +} + +[data-theme="dark"] { + --cat-work: #f59e0b; + --cat-work-bg: rgba(245, 158, 11, 0.15); + --cat-character: #60a5fa; + --cat-character-bg: rgba(96, 165, 250, 0.15); + --cat-location: #34d399; + --cat-location-bg: rgba(52, 211, 153, 0.15); + --cat-world: #a78bfa; + --cat-world-bg: rgba(167, 139, 250, 0.15); + --cat-object: #fbbf24; + --cat-object-bg: rgba(251, 191, 36, 0.15); + --cat-plot: #f87171; + --cat-plot-bg: rgba(248, 113, 113, 0.15); + --cat-rules: #9ca3af; + --cat-rules-bg: rgba(156, 163, 175, 0.15); + --cat-custom: #22d3ee; + --cat-custom-bg: rgba(34, 211, 238, 0.15); + --cat-bookinfo: #d4a574; + --cat-bookinfo-bg: rgba(212, 165, 116, 0.15); +} + +/* ==================== 设定集面板 — 全屏覆盖 ==================== */ + +.settings-panel-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease; +} + +.settings-panel-container { + background: var(--bg-card); + border-radius: 16px; + box-shadow: + 0 24px 80px rgba(0, 0, 0, 0.25), + 0 0 0 1px rgba(255, 255, 255, 0.06); + width: 94%; + max-width: 1120px; + max-height: 88vh; + display: flex; + flex-direction: column; + animation: settingsSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; +} + +@keyframes settingsSlideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* ==================== 设定集头部 ==================== */ + +.settings-header { + padding: 20px 28px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-secondary) 100%); +} + +.settings-header h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); +} + +.settings-header .subtitle { + font-size: 12px; + color: var(--text-muted); + font-weight: 400; +} + +/* ==================== 统计栏 ==================== */ + +.settings-stats { + display: flex; + gap: 8px; + padding: 10px 24px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-secondary); + overflow-x: auto; + scrollbar-width: thin; +} + +.stat-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px 6px 12px; + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + transition: all var(--transition-normal); + cursor: pointer; + border: 1px solid rgba(128, 128, 128, 0.15); + box-shadow: var(--shadow-sm); +} + +.stat-badge:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + filter: brightness(1.05); +} + +.stat-badge:active { + transform: scale(0.96); +} + +.stat-badge .stat-count { + font-weight: 700; + font-size: 14px; +} + +/* ==================== 搜索框 ==================== */ + +.settings-search { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); +} + +.settings-search-input { + width: 100%; + padding: 8px 12px 8px 34px; + border: 1px solid var(--border-light); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-ui); + outline: none; + transition: all 0.2s ease; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%239a8d7e' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 10px center; + background-size: 14px; +} + +.settings-search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light), 0 0 16px rgba(196, 149, 106, 0.1); +} + +.settings-search-input::placeholder { + color: var(--text-muted); +} + +/* ==================== 设定树样式 ==================== */ + +.settings-tree { + padding: 6px 8px; +} + +.tree-node { + user-select: none; +} + +.tree-node-row { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: all var(--transition-normal); + font-size: 13px; + color: var(--text-secondary); + position: relative; + margin-bottom: 2px; +} + +.tree-node-row:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: translateX(2px); +} + +.tree-node-row.selected { + background: var(--bg-card); + color: var(--accent); + font-weight: 600; + border-color: var(--accent-light); + box-shadow: 0 4px 12px var(--accent-light), 0 0 0 1px var(--accent-glow) inset; + transform: translateX(4px); +} + +.tree-node-row.disabled { + opacity: 0.35; +} + +.tree-node-row.disabled .tree-node-name { + text-decoration: line-through; + text-decoration-color: var(--text-muted); +} + +.tree-node-icon { + font-size: 15px; + flex-shrink: 0; + width: 22px; + text-align: center; + line-height: 1; +} + +.tree-node-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} + +.tree-node-badge { + font-size: 10px; + padding: 1px 7px; + border-radius: 10px; + font-weight: 700; + flex-shrink: 0; + min-width: 20px; + text-align: center; +} + +.tree-node-actions { + display: none; + gap: 2px; + flex-shrink: 0; +} + +.tree-node-row:hover .tree-node-actions { + display: flex; +} + +.tree-node-row:hover .tree-node-badge { + display: none; +} + +.tree-action-btn { + padding: 2px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + font-size: 12px; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.tree-action-btn:hover { + background: var(--bg-active); + color: var(--text-primary); +} + +.tree-action-btn.danger:hover { + background: rgba(239, 68, 68, 0.12); + color: var(--error); +} + +/* 启用/禁用开关 */ +.tree-toggle-btn { + padding: 2px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; + font-size: 12px; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + opacity: 0; + color: var(--text-muted); +} + +.tree-node-row:hover .tree-toggle-btn { + opacity: 1; +} + +.tree-toggle-btn.visible { + opacity: 0.5; +} + +.tree-toggle-btn:hover { + opacity: 1 !important; + background: var(--bg-active); +} + +.tree-node-children { + margin-left: 4px; + border-left: 1px solid var(--border-light); + padding-left: 4px; +} + +/* AI 生成按钮 */ +.tree-ai-generate-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + margin: 6px 8px 8px; + border: 1px dashed var(--accent); + border-radius: 8px; + background: linear-gradient(135deg, var(--accent-light) 0%, transparent 100%); + color: var(--accent); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; + font-family: var(--font-ui); +} + +.tree-ai-generate-btn:hover { + background: var(--accent-light); + border-style: solid; + box-shadow: 0 0 16px var(--accent-glow); + transform: translateY(-1px); +} + +/* ==================== 角色卡片预览 ==================== */ + +.character-card-preview { + padding: 22px; + border-radius: 12px; + margin-bottom: 20px; + position: relative; + overflow: hidden; +} + +.character-card-preview::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.08; + background: linear-gradient(135deg, currentColor 0%, transparent 50%); + pointer-events: none; +} + +.character-card-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; + position: relative; + z-index: 1; +} + +.character-card-avatar { + width: 56px; + height: 56px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: white; + flex-shrink: 0; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + letter-spacing: 0; + font-weight: 700; +} + +.character-card-info { + flex: 1; +} + +.character-card-name { + font-size: 20px; + font-weight: 700; + margin-bottom: 6px; + color: var(--text-primary); + letter-spacing: 0.5px; +} + +.character-card-role { + display: inline-flex; + padding: 3px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.character-card-quickinfo { + display: flex; + gap: 20px; + flex-wrap: wrap; + position: relative; + z-index: 1; +} + +.character-card-quickinfo .info-item { + font-size: 12px; + color: var(--text-secondary); + display: flex; + gap: 4px; +} + +.character-card-quickinfo .info-label { + color: var(--text-muted); + font-weight: 500; +} + +/* ==================== 字段分组 ==================== */ + +.field-group { + border: 1px solid var(--border-light); + border-radius: 10px; + margin-bottom: 12px; + overflow: hidden; + transition: box-shadow 0.2s ease; +} + +.field-group:not(.collapsed):hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.field-group.collapsed .field-group-content { + display: none; +} + +.field-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px 16px; + background: var(--bg-secondary); + cursor: pointer; + transition: all 0.15s ease; + user-select: none; +} + +.field-group-header:hover { + background: var(--bg-hover); +} + +.field-group-header h4 { + font-size: 13px; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.field-group-chevron { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.field-group.collapsed .field-group-chevron { + transform: rotate(-90deg); +} + +.field-group-content { + padding: 14px 16px; +} + +/* AI 字段填写按钮 */ +.field-ai-btn { + padding: 3px 8px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + cursor: pointer; + font-size: 11px; + border-radius: 4px; + transition: all 0.15s ease; + opacity: 0.5; + font-weight: 500; +} + +.field-ai-btn:hover { + opacity: 1; + background: var(--accent-light); + box-shadow: 0 0 8px var(--accent-glow); +} + +/* ==================== 上下文预览面板 ==================== */ + +.context-preview { + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-light); + margin: 8px 0; + overflow: hidden; +} + +.context-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + transition: all 0.15s ease; + user-select: none; +} + +.context-preview-header:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.context-preview-body { + padding: 6px 12px 10px; + border-top: 1px solid var(--border-light); +} + +.context-module { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 0; + font-size: 11px; + color: var(--text-muted); +} + +.context-module .module-label { + display: flex; + align-items: center; + gap: 4px; +} + +.context-module .module-tokens { + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; + font-size: 10px; + color: var(--text-muted); + background: var(--bg-secondary); + padding: 1px 5px; + border-radius: 4px; +} + +.context-module.empty { + opacity: 0.35; +} + +/* Token 进度条 */ +.token-bar { + height: 4px; + border-radius: 2px; + background: var(--border-light); + margin: 8px 0 4px; + overflow: hidden; +} + +.token-bar-fill { + height: 100%; + border-radius: 2px; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + transition: width 0.4s ease; +} + +.token-bar-fill.warning { + background: linear-gradient(90deg, #f59e0b, #f97316); +} + +.token-bar-fill.danger { + background: linear-gradient(90deg, #ef4444, #dc2626); +} + +.token-summary { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--text-muted); + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; +} + +/* ==================== 自由对话输入 ==================== */ + +.chat-input-area { + width: 100%; + min-height: 80px; + max-height: 160px; + padding: 12px 14px; + border: 1px solid var(--border-light); + border-radius: 10px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + font-family: var(--font-ui); + resize: vertical; + outline: none; + transition: all 0.2s ease; + margin-bottom: 8px; + line-height: 1.7; +} + +.chat-input-area:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light), 0 0 20px rgba(196, 149, 106, 0.08); +} + +.chat-input-area::placeholder { + color: var(--text-muted); +} + +/* ==================== 写作模式卡片 ==================== */ + +.writing-mode-card { + flex: 1; + padding: 12px 14px; + border: 1px solid var(--border-light); + border-radius: 10px; + background: var(--bg-primary); + cursor: pointer; + text-align: left; + transition: all 0.2s ease; + font-family: var(--font-ui); +} + +.writing-mode-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); +} + +.writing-mode-card.active { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +/* ==================== 设定集空状态 ==================== */ + +.settings-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + text-align: center; + color: var(--text-muted); +} + +.settings-empty-state .empty-icon { + font-size: 56px; + margin-bottom: 20px; + opacity: 0.4; + filter: grayscale(0.3); +} + +.settings-empty-state h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.settings-empty-state p { + font-size: 13px; + line-height: 1.7; + max-width: 300px; + color: var(--text-muted); +} + +/* ==================== Inline AI 内联写作助手 ==================== */ + +.inline-ai-trigger { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + border-radius: 50%; + border: none; + background: var(--accent); + color: #fff; + font-size: 20px; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + z-index: 100; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.inline-ai-trigger:hover { + transform: scale(1.1); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3); +} + +.inline-ai-popover { + position: fixed; + width: 360px; + max-width: calc(100vw - 32px); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.08); + z-index: 200; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + backdrop-filter: blur(12px); + animation: popoverIn 0.15s ease-out; +} + +@keyframes popoverIn { + from { + opacity: 0; + transform: translateY(8px) scale(0.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.inline-ai-modes { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.inline-ai-mode-btn { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + font-family: var(--font-ui); +} + +.inline-ai-mode-btn:hover { + background: var(--bg-tertiary); + border-color: var(--accent); + color: var(--accent); +} + +.inline-ai-mode-btn.active { + background: var(--accent-light); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.inline-ai-mode-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.inline-ai-input-row { + display: flex; + gap: 6px; + align-items: center; +} + +.inline-ai-input { + flex: 1; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-ui); + outline: none; + transition: border-color 0.15s; +} + +.inline-ai-input:focus { + border-color: var(--accent); +} + +.inline-ai-input::placeholder { + color: var(--text-muted); + font-size: 12px; +} + +.inline-ai-go-btn { + padding: 7px 14px; + border: none; + border-radius: var(--radius-sm); + background: var(--accent); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + font-family: var(--font-ui); +} + +.inline-ai-go-btn:hover { + filter: brightness(1.1); +} + +.inline-ai-stop-btn { + padding: 7px 14px; + border: none; + border-radius: var(--radius-sm); + background: var(--error); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + font-family: var(--font-ui); +} + +.inline-ai-stop-btn:hover { + filter: brightness(1.1); +} + +.inline-ai-status { + font-size: 12px; + color: var(--accent); + padding: 2px 4px; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.inline-ai-hint { + font-size: 11px; + color: var(--text-muted); + padding: 2px 4px; +} + +/* ==================== Inline 参考面板 ==================== */ + +.inline-context-panel { + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.inline-context-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 5px 8px; + border: none; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + transition: all 0.15s ease; + text-align: left; +} + +.inline-context-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.inline-context-chevron { + font-size: 9px; + color: var(--text-muted); + width: 12px; + display: inline-flex; + justify-content: center; +} + +.inline-context-count { + color: var(--text-muted); + font-size: 11px; + margin-left: auto; +} + +.inline-context-list { + max-height: 180px; + overflow-y: auto; + border-top: 1px solid var(--border-light); + padding: 4px 0; +} + +.inline-context-group { + margin-bottom: 2px; +} + +.inline-context-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.1s; +} + +.inline-context-group-header:hover { + background: var(--bg-hover); +} + +.inline-context-group-header input[type="checkbox"] { + width: 13px; + height: 13px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.inline-context-group-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-context-group-count { + color: var(--text-muted); + font-size: 10px; + font-weight: 400; + flex-shrink: 0; +} + +.inline-context-item { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px 2px 24px; + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.1s; +} + +.inline-context-item:hover { + background: var(--bg-hover); +} + +.inline-context-item input[type="checkbox"] { + width: 12px; + height: 12px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.inline-context-item-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ==================== 帮助文档面板 ==================== */ + +.help-overlay { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.15s; +} + +.help-panel { + width: 900px; + max-width: 95vw; + height: 80vh; + max-height: 90vh; + background: var(--bg-primary); + border-radius: var(--radius-lg); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideUp 0.2s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0.8; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.help-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.help-header h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.help-close-btn { + width: 32px; + height: 32px; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 18px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.help-close-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.help-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.help-nav { + width: 200px; + min-width: 200px; + border-right: 1px solid var(--border-light); + padding: 12px 8px; + overflow-y: auto; + flex-shrink: 0; +} + +.help-nav-item { + display: block; + width: 100%; + padding: 10px 12px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + text-align: left; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; + margin-bottom: 2px; +} + +.help-nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.help-nav-item.active { + background: var(--accent-light); + color: var(--accent); + font-weight: 600; +} + +.help-content { + flex: 1; + padding: 24px 32px; + overflow-y: auto; +} + +/* 帮助文档内部 Markdown 样式 */ +.help-markdown h2 { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-light); +} + +.help-markdown h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 20px 0 8px; +} + +.help-markdown p { + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); + margin: 6px 0; +} + +.help-markdown strong { + color: var(--text-primary); + font-weight: 600; +} + +.help-markdown code { + background: var(--bg-tertiary, #f4f4f5); + padding: 2px 6px; + border-radius: 3px; + font-family: "SF Mono", "Cascadia Code", monospace; + font-size: 0.9em; + color: var(--accent); +} + +.help-markdown table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 13px; +} + +.help-markdown th { + background: var(--bg-secondary); + padding: 8px 12px; + text-align: left; + font-weight: 600; + color: var(--text-primary); + border-bottom: 2px solid var(--border-light); +} + +.help-markdown td { + padding: 8px 12px; + border-bottom: 1px solid var(--border-light); + color: var(--text-secondary); +} + +.help-markdown tr:hover td { + background: var(--bg-hover); +} + +.help-markdown blockquote { + border-left: 3px solid var(--accent); + padding: 8px 16px; + margin: 12px 0; + background: var(--accent-light); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + color: var(--text-secondary); + font-size: 13px; +} + +.help-markdown s { + text-decoration: line-through; + color: var(--text-muted); +} + +/* ==================== Ghost Text 样式 ==================== */ + +.ghost-text { + background: rgba(46, 160, 67, 0.15); + border-left: 2px solid rgba(46, 160, 67, 0.6); + padding-left: 1px; + color: var(--text-primary); + text-decoration: none; + transition: background 0.3s ease; +} + +.ghost-text+.ghost-text { + border-left: none; +} + +/* 内联操作栏 — Cursor 风格,定位在幽灵文本末尾 */ +.ghost-inline-bar { + position: fixed; + z-index: 1000; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--bg-primary); + border: 1px solid var(--border-medium); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.04); + animation: ghostBarFadeIn 0.15s ease; +} + +@keyframes ghostBarFadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.ghost-inline-bar .ghost-accept-btn, +.ghost-inline-bar .ghost-reject-btn, +.ghost-inline-bar .ghost-regen-btn { + padding: 4px 10px; + font-size: 12px; +} + +.ghost-bar-shortcut { + font-size: 10px; + color: var(--text-muted); + margin-left: 4px; + white-space: nowrap; +} + +/* Ghost 操作栏 */ +.ghost-action-bar { + display: flex; + gap: 6px; + padding: 4px 0; +} + +.ghost-accept-btn, +.ghost-reject-btn, +.ghost-regen-btn { + padding: 6px 14px; + border: none; + border-radius: var(--radius-sm); + font-size: 13px; + font-family: var(--font-ui); + cursor: pointer; + transition: all var(--transition-fast); + font-weight: 500; +} + +.ghost-accept-btn { + background: var(--success); + color: white; +} + +.ghost-accept-btn:hover { + filter: brightness(1.1); +} + +.ghost-reject-btn { + background: transparent; + color: var(--error); + border: 1px solid var(--error); +} + +.ghost-reject-btn:hover { + background: rgba(196, 106, 106, 0.1); +} + +.ghost-regen-btn { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-medium); +} + +.ghost-regen-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ==================== AI 侧栏浮动圆形图标 ==================== */ + +.ai-sidebar-toggle { + position: fixed; + right: 18px; + top: 50%; + transform: translateY(-50%); + z-index: 40; + width: 48px; + height: 48px; + border: none; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: white; + font-size: 20px; + cursor: pointer; + box-shadow: 0 4px 16px rgba(196, 149, 106, 0.35), 0 2px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; + animation: togglePulse 3s ease-in-out infinite; +} + +.ai-sidebar-toggle:hover { + transform: translateY(-50%) scale(1.12); + box-shadow: 0 6px 24px rgba(196, 149, 106, 0.5), 0 3px 10px rgba(0, 0, 0, 0.15); + animation: none; +} + +.ai-sidebar-toggle:active { + transform: translateY(-50%) scale(0.95); +} + +@keyframes togglePulse { + + 0%, + 100% { + box-shadow: 0 4px 16px rgba(196, 149, 106, 0.35), 0 2px 6px rgba(0, 0, 0, 0.1); + } + + 50% { + box-shadow: 0 4px 24px rgba(196, 149, 106, 0.55), 0 2px 8px rgba(0, 0, 0, 0.12); + } +} + +/* ==================== AI 对话侧栏 ==================== */ + +.ai-sidebar { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 380px; + background: var(--bg-primary); + border-left: 1px solid var(--border-light); + display: flex; + flex-direction: column; + z-index: 30; + transform: translateX(0); + transition: transform var(--transition-normal), visibility 0s linear 0s; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.06); + visibility: visible; +} + +.ai-sidebar.collapsed { + transform: translateX(100%); + pointer-events: none; + visibility: hidden; + transition: transform var(--transition-normal), visibility 0s linear 0.3s; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +.ai-sidebar-header { + padding: 16px 20px; + background: linear-gradient(135deg, var(--accent-light), transparent); + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ai-sidebar-title { + font-weight: 700; + font-size: 16px; + color: var(--accent); + letter-spacing: 0.5px; +} + +.ai-sidebar-tabs { + display: flex; + border-bottom: 1px solid var(--border-light); + padding: 0; + background: var(--bg-secondary); +} + +.ai-sidebar-tab { + flex: 1; + padding: 12px 8px; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 13px; + font-family: var(--font-ui); + cursor: pointer; + border-bottom: 2.5px solid transparent; + transition: all var(--transition-fast); + text-align: center; + font-weight: 500; +} + +.ai-sidebar-tab:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.ai-sidebar-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 600; + background: var(--bg-primary); +} + +.ai-sidebar-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +/* ===== 对话控制栏 ===== */ + +.chat-controls { + padding: 8px 12px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-shrink: 0; +} + +.chat-control-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; +} + +.chat-control-item input[type="checkbox"] { + accent-color: var(--accent); + width: 14px; + height: 14px; +} + +.chat-window-size-input { + width: 40px; + padding: 2px 4px; + border: 1px solid var(--border-light); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-ui); + text-align: center; +} + +.chat-control-actions { + display: flex; + gap: 4px; +} + +/* 小按钮 */ +.btn-mini { + padding: 4px 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-ui); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-mini:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.btn-mini.primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-mini.primary:hover { + filter: brightness(1.1); +} + +.btn-mini.danger { + color: var(--error); + border-color: transparent; +} + +.btn-mini.danger:hover { + background: rgba(196, 106, 106, 0.1); +} + +.btn-mini:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ===== 总结编辑器 ===== */ + +.summary-editor { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + background: var(--accent-light); + flex-shrink: 0; +} + +.summary-editor-label { + font-size: 12px; + color: var(--accent); + font-weight: 600; + margin-bottom: 6px; +} + +.summary-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border-medium); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-ui); + line-height: 1.5; + resize: vertical; +} + +.summary-actions { + display: flex; + gap: 6px; + margin-top: 6px; + justify-content: flex-end; +} + +/* ===== 聊天消息区 ===== */ + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-empty { + text-align: center; + color: var(--text-muted); + padding: 40px 20px; + font-size: 14px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.chat-empty div:first-child { + font-size: 28px; + color: var(--accent); +} + +.chat-empty-hint { + font-size: 12px; +} + +.chat-message { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-message-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); +} + +.chat-check { + width: 13px; + height: 13px; + accent-color: var(--accent); + cursor: pointer; +} + +.chat-role { + font-weight: 600; + font-size: 12px; +} + +.chat-message.user .chat-role { + color: var(--text-secondary); +} + +.chat-message.assistant .chat-role { + color: var(--accent); +} + +.chat-message.system .chat-role { + color: var(--info); +} + +.chat-time { + margin-left: auto; +} + +/* 消息操作按钮 — 默认隐藏,hover 滑入 */ +.chat-msg-actions { + display: flex; + gap: 1px; + margin-left: auto; + opacity: 0; + transform: translateX(8px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} + +.chat-message:hover .chat-msg-actions { + opacity: 1; + transform: translateX(0); + pointer-events: auto; +} + +.btn-mini-icon { + background: none; + border: 1px solid transparent; + cursor: pointer; + font-size: 11px; + padding: 3px 5px; + border-radius: 6px; + opacity: 0.7; + transition: all 0.18s ease; + line-height: 1; +} + +.btn-mini-icon:hover { + opacity: 1; + background: var(--bg-hover); + border-color: var(--border-light); + transform: scale(1.1); +} + +.btn-mini-icon.danger:hover { + background: rgba(220, 60, 60, 0.12); + border-color: rgba(220, 60, 60, 0.25); +} + +.btn-mini-icon:disabled { + opacity: 0.25; + cursor: not-allowed; + transform: none; +} + +/* 已编辑标记 */ +.chat-edited-badge { + font-size: 10px; + color: var(--text-muted); + font-style: italic; + padding: 1px 6px; + background: rgba(128, 128, 128, 0.1); + border-radius: 8px; +} + +/* 消息编辑模式 */ +.chat-message-editing { + display: flex; + flex-direction: column; + gap: 8px; + animation: fadeSlideIn 0.2s ease; +} + +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-edit-textarea { + width: 100%; + padding: 10px 12px; + border: 2px solid var(--accent); + border-radius: 10px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + line-height: 1.6; + resize: vertical; + font-family: inherit; + box-shadow: 0 0 0 3px rgba(var(--accent-rgb, 100, 130, 200), 0.12); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.chat-edit-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb, 100, 130, 200), 0.18); +} + +.chat-edit-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.chat-edit-actions .btn-mini { + border-radius: 8px; + padding: 5px 14px; + font-size: 12px; + transition: all 0.15s ease; +} + +/* 流式输出光标 */ +.chat-message.assistant .chat-bubble-content.streaming::after { + content: '▊'; + animation: blink 0.8s steps(2) infinite; + color: var(--accent); + margin-left: 2px; +} + +/* ===== 聊天 Markdown 渲染 ===== */ + +.chat-bubble-content { + font-size: 13px; + line-height: 1.7; + word-break: break-word; + overflow-wrap: break-word; +} + +.chat-md-p { + margin: 0 0 8px; +} + +.chat-md-p:last-child { + margin-bottom: 0; +} + +.chat-md-heading { + font-weight: 700; + margin: 12px 0 6px; + color: var(--text-primary); +} + +h3.chat-md-heading { + font-size: 15px; +} + +h4.chat-md-heading { + font-size: 14px; +} + +h5.chat-md-heading { + font-size: 13px; +} + +.chat-md-inline-code { + background: var(--bg-tertiary, rgba(0, 0, 0, 0.06)); + padding: 1px 5px; + border-radius: 4px; + font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; + font-size: 0.88em; + color: var(--accent); + word-break: break-all; +} + +.chat-md-code-block { + margin: 8px 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.chat-md-code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + background: var(--bg-tertiary, rgba(0, 0, 0, 0.04)); + border-bottom: 1px solid var(--border-light); +} + +.chat-md-code-lang { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.chat-md-copy-btn { + padding: 2px 8px; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 10px; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; + font-family: var(--font-ui); +} + +.chat-md-copy-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.chat-md-pre { + margin: 0; + padding: 10px 12px; + overflow-x: auto; + font-size: 12px; + line-height: 1.5; + font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; + color: var(--text-primary); +} + +.chat-md-pre code { + font-family: inherit; +} + +.chat-md-table-wrap { + overflow-x: auto; + margin: 8px 0; + border-radius: 6px; + border: 1px solid var(--border-light); +} + +.chat-md-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.chat-md-table th { + background: var(--bg-secondary); + padding: 6px 10px; + text-align: left; + font-weight: 600; + color: var(--text-primary); + border-bottom: 2px solid var(--border-light); + white-space: nowrap; +} + +.chat-md-table td { + padding: 5px 10px; + border-bottom: 1px solid var(--border-light); + color: var(--text-secondary); +} + +.chat-md-table tr:last-child td { + border-bottom: none; +} + +.chat-md-table tr:hover td { + background: var(--bg-hover); +} + +.chat-md-blockquote { + margin: 8px 0; + padding: 6px 12px; + border-left: 3px solid var(--accent); + background: var(--accent-light); + border-radius: 0 6px 6px 0; + color: var(--text-secondary); + font-size: 12px; +} + +.chat-md-blockquote p { + margin: 0; +} + +.chat-md-ul, +.chat-md-ol { + margin: 4px 0; + padding-left: 20px; +} + +.chat-md-ul li, +.chat-md-ol li { + margin-bottom: 2px; +} + +.chat-md-link { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dotted var(--accent); + transition: opacity 0.15s; +} + +.chat-md-link:hover { + opacity: 0.8; +} + + +/* ===== 设定集操作卡片 ===== */ + +.settings-action-card { + margin: 8px 0; + padding: 10px 12px; + border: 1px solid var(--accent-glow, rgba(100, 130, 200, 0.25)); + border-radius: 10px; + background: linear-gradient(135deg, var(--accent-light, rgba(100, 130, 200, 0.06)), transparent); +} + +.settings-action-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.settings-action-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + background: var(--accent); + color: white; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.settings-action-cat { + font-size: 11px; + color: var(--text-muted); +} + +.settings-action-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.settings-action-preview { + margin: 6px 0 8px; + padding: 6px 8px; + background: var(--bg-secondary); + border-radius: 6px; + font-size: 11px; + line-height: 1.5; +} + +.settings-action-field { + display: flex; + gap: 4px; + margin-bottom: 4px; +} + +.settings-action-field-key { + color: var(--text-muted); + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; +} + +.settings-action-field-val { + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} + +.settings-action-apply { + width: 100%; + text-align: center; + border-radius: 8px; + padding: 5px 0; + font-size: 12px; +} + +.settings-action-apply:disabled { + background: var(--bg-secondary); + color: var(--text-muted); + border-color: var(--border-light); + cursor: default; + opacity: 0.8; +} + +/* ===== 会话列表面板 ===== */ +.session-list-panel { + border-bottom: 1px solid var(--border-light); + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); + max-height: 300px; + display: flex; + flex-direction: column; + animation: slideDown 0.2s ease; +} + +@keyframes slideDown { + from { + max-height: 0; + opacity: 0; + } + + to { + max-height: 300px; + opacity: 1; + } +} + +.session-list-header { + padding: 10px 14px 6px; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.session-list { + overflow-y: auto; + flex: 1; + padding: 2px 8px 8px; +} + +.session-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 10px; + cursor: pointer; + transition: all 0.18s ease; + font-size: 13px; + margin-bottom: 2px; + border: 1px solid transparent; + position: relative; +} + +.session-item:hover { + background: var(--bg-hover); + border-color: var(--border-light); +} + +.session-item.active { + background: linear-gradient(135deg, var(--accent-light), rgba(var(--accent-rgb, 100, 130, 200), 0.08)); + border-color: var(--accent-glow); + box-shadow: 0 1px 4px rgba(var(--accent-rgb, 100, 130, 200), 0.1); +} + +.session-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: var(--accent); + border-radius: 0 3px 3px 0; +} + +.session-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.session-item-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-item-meta { + font-size: 11px; + color: var(--text-muted); + display: flex; + gap: 6px; + align-items: center; +} + +.session-item-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.session-item:hover .session-item-actions { + opacity: 1; +} + +.session-rename-input { + flex: 1; + padding: 6px 10px; + border: 2px solid var(--accent); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + box-shadow: 0 0 0 3px rgba(var(--accent-rgb, 100, 130, 200), 0.12); +} + +/* ===== 思维链折叠显示 ===== */ +.chat-thinking-block { + margin: 4px 0; + border-radius: 8px; + border: 1px solid rgba(255, 200, 60, 0.2); + background: rgba(255, 200, 60, 0.05); + overflow: hidden; +} + +.chat-thinking-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 10px; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + color: var(--text-secondary); + text-align: left; + transition: background 0.15s; +} + +.chat-thinking-toggle:hover { + background: rgba(255, 200, 60, 0.08); +} + +.thinking-chevron { + font-size: 9px; + transition: transform 0.2s ease; + display: inline-block; +} + +.thinking-chevron.open { + transform: rotate(90deg); +} + +.thinking-preview { + color: var(--text-muted); + font-size: 11px; + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.chat-thinking-content { + padding: 8px 12px; + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; + border-top: 1px solid rgba(255, 200, 60, 0.15); + animation: fadeSlideIn 0.15s ease; +} + +/* ===== 变体导航 < 1/3 > ===== */ +.chat-variant-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 3px 0; + margin-top: 2px; +} + +.variant-indicator { + font-size: 11px; + color: var(--text-muted); + font-weight: 600; + min-width: 36px; + text-align: center; + user-select: none; +} + +.chat-variant-nav .btn-mini-icon { + font-size: 9px; + padding: 2px 6px; + border-radius: 50%; +} + +.chat-bubble-content { + padding: 12px 16px; + border-radius: var(--radius-lg); + font-size: 13px; + line-height: 1.6; + word-break: break-word; + box-shadow: var(--shadow-sm); + transition: box-shadow var(--transition-normal); +} + +.chat-bubble-content:hover { + box-shadow: var(--shadow-md); +} + +.chat-message.user .chat-bubble-content { + background: linear-gradient(135deg, var(--accent-light) 0%, rgba(var(--accent-rgb, 200, 150, 100), 0.05) 100%); + color: var(--text-primary); + border: 1px solid var(--accent-glow); + border-bottom-right-radius: 4px; +} + +.chat-message.assistant .chat-bubble-content { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom-left-radius: 4px; +} + +.chat-message.system .chat-bubble-content { + background: rgba(106, 150, 196, 0.06); + color: var(--text-secondary); + border: 1px solid rgba(106, 150, 196, 0.15); + font-size: 12px; + border-radius: var(--radius-sm); +} + +.streaming-cursor { + animation: blink 0.8s infinite; + color: var(--accent); +} + +@keyframes blink { + + 0%, + 50% { + opacity: 1; + } + + 51%, + 100% { + opacity: 0; + } +} + +/* ===== 输入区 ===== */ + +.chat-input-area { + padding: 16px; + border-top: 1px solid var(--border-glass); + display: flex; + gap: 12px; + align-items: flex-end; + flex-shrink: 0; + background: var(--bg-glass); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); +} + +.chat-input { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--border-glass); + border-radius: 20px; + background: var(--bg-card); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-ui); + line-height: 1.5; + resize: none; + outline: none; + transition: all var(--transition-normal); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02); +} + +.chat-input:focus { + border-color: var(--accent); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02), 0 0 0 3px var(--accent-light); +} + +.chat-input::placeholder { + color: var(--text-muted); +} + +.chat-send-btn { + width: 44px; + height: 44px; + border: none; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: white; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + flex-shrink: 0; + box-shadow: 0 4px 12px var(--accent-glow); +} + +.chat-send-btn:hover:not(:disabled) { + transform: translateY(-2px) scale(1.05); + box-shadow: 0 8px 20px var(--accent-glow); +} + +.chat-send-btn:active:not(:disabled) { + transform: scale(0.95); +} + +.chat-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + box-shadow: none; + background: var(--border-medium); +} + +/* ===== 存档 Tab ===== */ + +.archive-search-bar { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.archive-search-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-ui); + outline: none; + transition: border-color var(--transition-fast); +} + +.archive-search-input:focus { + border-color: var(--accent); +} + +.archive-list { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.archive-item { + padding: 10px 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-card); + cursor: pointer; + transition: all var(--transition-fast); +} + +.archive-item:hover { + border-color: var(--accent-glow); + box-shadow: var(--shadow-sm); +} + +.archive-item-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.archive-status { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; +} + +.archive-status.accepted { + background: rgba(107, 158, 107, 0.15); + color: var(--success); +} + +.archive-status.rejected { + background: rgba(196, 106, 106, 0.15); + color: var(--error); +} + +.archive-status.pending { + background: var(--accent-light); + color: var(--accent); +} + +.archive-mode { + font-size: 12px; + color: var(--text-secondary); +} + +.archive-time { + font-size: 11px; + color: var(--text-muted); + margin-left: auto; +} + +.archive-preview { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} + +.archive-actions { + margin-top: 8px; + display: flex; + gap: 6px; +} + +/* ===== 上下文勾选面板(📚 参考 Tab) ===== */ + +.context-budget-bar { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.context-budget-label { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 5px; + font-weight: 500; +} + +.context-over-budget { + color: var(--error) !important; + font-weight: 700; +} + +.context-budget-track { + width: 100%; + height: 6px; + background: var(--bg-tertiary, #e5e5e5); + border-radius: 3px; + overflow: hidden; +} + +.context-budget-fill { + height: 100%; + border-radius: 3px; + background: linear-gradient(90deg, var(--success), var(--accent)); + transition: width 0.3s ease; +} + +.context-budget-fill.over { + background: linear-gradient(90deg, var(--error), #d9534f); +} + +.context-search-bar { + padding: 8px 12px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.context-search-input { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-ui); + outline: none; + transition: border-color var(--transition-fast); +} + +.context-search-input:focus { + border-color: var(--accent); +} + +.context-groups { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.context-group { + border-bottom: 1px solid var(--border-light); +} + +.context-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + transition: background var(--transition-fast); + user-select: none; +} + +.context-group-header:hover { + background: var(--bg-hover); +} + +.context-collapse-icon { + font-size: 10px; + color: var(--text-muted); + width: 14px; + text-align: center; + flex-shrink: 0; +} + +.context-group-check { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.context-group-name { + flex: 1; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-group-tokens { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono, monospace); + flex-shrink: 0; + min-width: 40px; + text-align: right; +} + +.context-group-items { + padding: 0 12px 6px 20px; +} + +.context-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); +} + +.context-item:hover { + background: var(--bg-hover); +} + +.context-item-check { + width: 13px; + height: 13px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.context-item-name { + flex: 1; + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-item-tokens { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono, monospace); + flex-shrink: 0; + min-width: 36px; + text-align: right; +} + +.context-actions { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--border-light); + flex-shrink: 0; + justify-content: center; + flex-wrap: wrap; +} + +/* ===== 响应式 ===== */ +@media (max-width: 900px) { + .ai-sidebar { + position: fixed; + right: 0; + top: 0; + bottom: 0; + z-index: 50; + box-shadow: var(--shadow-lg); + width: 320px; + } +} + +/* ==================== 引导向导 (Tour Overlay) ==================== */ + +.tour-portal { + position: fixed; + inset: 0; + z-index: 10000; + pointer-events: none; + /* Let clicks pass through the SVG mask */ +} + +/* 阻止背后的互动但允许高亮区域互动 */ +.tour-portal>* { + pointer-events: auto; +} + +.tour-svg-mask { + position: absolute; + inset: 0; + pointer-events: all; + /* block clicks on dark area */ + cursor: default; +} + +.tour-overlay-bg { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +.tour-intro-modal { + background: var(--bg-primary); + border: 1px solid var(--border-light); + border-radius: 20px; + padding: 48px; + max-width: 460px; + width: 90%; + text-align: center; + box-shadow: 0 32px 64px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05) inset; + animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +.tour-intro-icon { + font-size: 64px; + margin-bottom: 24px; + line-height: 1; + text-shadow: 0 12px 24px rgba(0, 0, 0, 0.1); + animation: floatIcon 3s ease-in-out infinite; +} + +@keyframes floatIcon { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-10px); + } +} + +.tour-intro-modal h2 { + font-size: 26px; + font-weight: 800; + color: var(--text-primary); + margin-bottom: 12px; + letter-spacing: -0.5px; +} + +.tour-intro-modal p { + font-size: 16px; + color: var(--text-secondary); + line-height: 1.6; +} + +.tour-intro-modal .tour-intro-sub { + font-size: 14px; + color: var(--text-muted); + margin-top: 24px; + margin-bottom: 32px; +} + +.tour-intro-actions { + display: flex; + justify-content: center; + gap: 16px; +} + +/* Tooltip floating box */ +.tour-tooltip { + position: absolute; + background: var(--bg-primary); + border: 1px solid var(--border-medium); + border-radius: 16px; + padding: 24px; + width: 340px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05) inset; + z-index: 10001; + pointer-events: auto; + animation: scaleIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes scaleIn { + from { + transform: scale(0.95) translateY(10px); + opacity: 0; + } + + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +.tour-tooltip-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tour-tooltip-header h3 { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.tour-step-counter { + font-size: 12px; + font-weight: 600; + color: var(--accent); + background: var(--bg-hover); + padding: 4px 10px; + border-radius: 20px; +} + +.tour-tooltip-content { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.tour-tooltip-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tour-skip-text { + background: none; + border: none; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + padding: 0; + transition: color 0.2s; +} + +.tour-skip-text:hover { + color: var(--text-primary); + text-decoration: underline; +} + +.tour-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.tour-btn.primary { + background: var(--accent); + color: white; + box-shadow: 0 4px 12px rgba(var(--accent-rgb, 107, 76, 222), 0.3); +} + +.tour-btn.primary:hover { + background: var(--accent-hover); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(var(--accent-rgb, 107, 76, 222), 0.4); +} + +.tour-btn.ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-medium); +} + +.tour-btn.ghost:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ==================== Welcome Modal (Onboarding) ==================== */ + +.welcome-modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +.welcome-modal-container { + max-width: 600px; + width: 90%; + background: var(--bg-card); + border: 1px solid var(--border-glass); + border-radius: 24px; + padding: 48px; + box-shadow: var(--shadow-page); + text-align: center; + position: relative; + overflow: hidden; + transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), max-width 0.5s; +} + +.welcome-modal-container.step-2 { + max-width: 800px; +} + +.welcome-step { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.welcome-title { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 32px; + letter-spacing: -0.5px; +} + +.welcome-subtitle { + font-size: 16px; + color: var(--text-secondary); + margin-bottom: 40px; +} + +.welcome-section-title { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + font-weight: 600; + margin-bottom: 20px; +} + +.welcome-lang-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + width: 100%; +} + +.welcome-card { + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: 16px; + padding: 24px 16px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + transition: all var(--transition-normal); +} + +.welcome-card:hover { + transform: translateY(-4px); + background: var(--bg-hover); + box-shadow: var(--shadow-md); + border-color: var(--border-medium); +} + +.welcome-card:active { + transform: scale(0.96); +} + +.welcome-card.active { + border-color: var(--accent); + background: var(--accent-light); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.welcome-icon { + font-size: 40px; +} + +.welcome-label { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.welcome-theme-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; + width: 100%; + margin-bottom: 40px; +} + +.theme-card { + padding: 0; + overflow: hidden; + text-align: left; +} + +.theme-preview { + width: 100%; + height: 140px; + background-size: cover; + background-position: center; + border-bottom: 1px solid var(--border-light); +} + +.warm-preview { + background: linear-gradient(135deg, #faf8f5 0%, #efe9e0 100%); + position: relative; +} + +.warm-preview::after { + content: ''; + position: absolute; + top: 20px; + left: 20px; + right: 20px; + bottom: 20px; + background: #fffdf9; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(44, 36, 23, 0.05); + border: 1px solid #e5ddd2; +} + +.modern-preview { + background: linear-gradient(135deg, #f2f3f5 0%, #e8eaed 100%); + position: relative; +} + +.modern-preview::after { + content: ''; + position: absolute; + top: 20px; + left: 20px; + right: 30%; + bottom: 20px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(8px); + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + border: 1px solid rgba(255, 255, 255, 0.5); +} + +.theme-info { + padding: 20px; +} + +.theme-info h4 { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.theme-info p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +.welcome-start-btn { + padding: 16px 48px; + font-size: 18px; + border-radius: 99px; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-inverse); + font-weight: 600; + border: none; + cursor: pointer; + transition: all var(--transition-normal); + box-shadow: 0 8px 24px var(--accent-glow); +} + +.welcome-start-btn:hover { + transform: translateY(-2px); + box-shadow: 0 12px 32px var(--accent-glow); +} + +.welcome-start-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} \ No newline at end of file diff --git a/app/layout.js b/app/layout.js new file mode 100644 index 0000000..99ff272 --- /dev/null +++ b/app/layout.js @@ -0,0 +1,42 @@ +'use client'; + +import "./globals.css"; +import { useEffect, useState } from "react"; + +// 内联脚本:在 HTML 解析阶段同步读取 theme,避免 hydration 不匹配和闪烁 +const themeInitScript = ` +(function() { + try { + var t = localStorage.getItem('author-theme') || 'light'; + document.documentElement.setAttribute('data-theme', t); + } catch(e) {} +})(); +`; + +export default function RootLayout({ children }) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + + + Author - AI辅助创作平台 + + + + + + {children} + + ); +} diff --git a/app/lib/chat-sessions.js b/app/lib/chat-sessions.js new file mode 100644 index 0000000..894bd4f --- /dev/null +++ b/app/lib/chat-sessions.js @@ -0,0 +1,306 @@ +'use client'; + +// ==================== 多会话管理模块 ==================== + +import { get, set } from 'idb-keyval'; + +const STORAGE_KEY = 'author-chat-sessions'; + +function generateId(prefix = 'session') { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; +} + +/** + * 从 IndexedDB 读取全部会话数据 (Async) + */ +export async function loadSessionStore() { + if (typeof window === 'undefined') return { activeSessionId: null, sessions: [] }; + try { + let store = await get(STORAGE_KEY); + if (!store) { + // Fallback: migrate from localStorage + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + store = JSON.parse(raw); + await set(STORAGE_KEY, store); + } + } + if (store) return store; + } catch { } + return { activeSessionId: null, sessions: [] }; +} + +/** + * 保存全部会话数据到 IndexedDB (Async) + */ +export async function saveSessionStore(store) { + if (typeof window === 'undefined') return; + try { + await set(STORAGE_KEY, store); + } catch { } +} + +/** + * 兼容迁移:将旧 chatHistory 数组迁移为多会话结构 + */ +export function migrateFromLegacy(legacyChatHistory) { + if (!legacyChatHistory || legacyChatHistory.length === 0) return null; + const session = { + id: generateId(), + title: autoTitle(legacyChatHistory), + createdAt: legacyChatHistory[0]?.timestamp || Date.now(), + updatedAt: Date.now(), + messages: legacyChatHistory, + }; + return session; +} + +/** + * 从首条用户消息自动生成标题 + */ +function autoTitle(messages) { + const first = messages.find(m => m.role === 'user'); + if (!first) return '新对话'; + const text = first.content.slice(0, 30); + return text.length < first.content.length ? text + '…' : text; +} + +// ==================== 会话级操作 ==================== + +/** + * 创建新的空白会话 + */ +export function createSession(store) { + const session = { + id: generateId(), title: '新对话', createdAt: Date.now(), + updatedAt: Date.now(), messages: [], + }; + const newStore = { + activeSessionId: session.id, + sessions: [...store.sessions, session], + }; + saveSessionStore(newStore); // Run in background + return newStore; +} + +/** + * 删除会话 + */ +export function deleteSession(store, sessionId) { + const remaining = store.sessions.filter(s => s.id !== sessionId); + const newActive = store.activeSessionId === sessionId + ? (remaining[remaining.length - 1]?.id || null) + : store.activeSessionId; + const newStore = { activeSessionId: newActive, sessions: remaining }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 重命名会话 + */ +export function renameSession(store, sessionId, newTitle) { + const newStore = { + ...store, + sessions: store.sessions.map(s => + s.id === sessionId ? { ...s, title: newTitle, updatedAt: Date.now() } : s + ), + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 切换活动会话 + */ +export function switchSession(store, sessionId) { + const newStore = { ...store, activeSessionId: sessionId }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 获取当前活动会话 + */ +export function getActiveSession(store) { + if (!store.activeSessionId) return null; + return store.sessions.find(s => s.id === store.activeSessionId) || null; +} + +// ==================== 消息级操作 ==================== + +/** + * 向活动会话添加消息 + */ +export function addMessage(store, msg) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + const messages = [...s.messages, msg]; + const title = s.messages.length === 0 && msg.role === 'user' + ? (msg.content.slice(0, 30) + (msg.content.length > 30 ? '…' : '')) + : s.title; + return { ...s, messages, title, updatedAt: Date.now() }; + }), + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 更新活动会话的最后一条消息(用于流式输出拼接) + */ +export function updateLastMessage(store, updater) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + if (s.messages.length === 0) return s; + const messages = [...s.messages]; + messages[messages.length - 1] = updater(messages[messages.length - 1]); + return { ...s, messages, updatedAt: Date.now() }; + }), + }; + return newStore; +} + +/** + * 编辑指定消息的内容 + */ +export function editMessage(store, msgId, newContent) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + return { + ...s, + messages: s.messages.map(m => + m.id === msgId ? { ...m, content: newContent, editedAt: Date.now() } : m + ), + updatedAt: Date.now(), + }; + }), + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 删除指定消息 + */ +export function deleteMessage(store, msgId) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + return { + ...s, + messages: s.messages.filter(m => m.id !== msgId), + updatedAt: Date.now(), + }; + }), + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 从指定消息创建分支 — 复制该消息及之前的所有消息到新会话 + */ +export function createBranch(store, msgId) { + const activeSession = getActiveSession(store); + if (!activeSession) return store; + + const msgIndex = activeSession.messages.findIndex(m => m.id === msgId); + if (msgIndex < 0) return store; + + const branchedMessages = activeSession.messages.slice(0, msgIndex + 1).map(m => ({ + ...m, id: generateId('msg'), + })); + + const branchSession = { + id: generateId(), title: `${activeSession.title} (分支)`, + createdAt: Date.now(), updatedAt: Date.now(), messages: branchedMessages, + }; + + const newStore = { + activeSessionId: branchSession.id, + sessions: [...store.sessions, branchSession], + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 替换活动会话的消息列表(用于总结等批量操作) + */ +export function replaceMessages(store, newMessages) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + return { ...s, messages: newMessages, updatedAt: Date.now() }; + }), + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 为指定消息添加一个变体(多次重新生成的结果) + */ +export function addVariant(store, msgId, variantData) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + return { + ...s, + messages: s.messages.map(m => { + if (m.id !== msgId) return m; + const existingVariants = m.variants || [{ + content: m.content, + thinking: m.thinking || '', + timestamp: m.timestamp, + }]; + const newVariants = [...existingVariants, variantData]; + return { + ...m, + variants: newVariants, + activeVariant: newVariants.length - 1, + content: variantData.content, + thinking: variantData.thinking || '', + }; + }), + updatedAt: Date.now(), + }; + }), + }; + saveSessionStore(newStore); + return newStore; +} + +/** + * 切换指定消息的当前显示变体 + */ +export function switchVariant(store, msgId, variantIndex) { + const newStore = { + ...store, + sessions: store.sessions.map(s => { + if (s.id !== store.activeSessionId) return s; + return { + ...s, + messages: s.messages.map(m => { + if (m.id !== msgId || !m.variants) return m; + const idx = Math.max(0, Math.min(variantIndex, m.variants.length - 1)); + const variant = m.variants[idx]; + return { ...m, activeVariant: idx, content: variant.content, thinking: variant.thinking || '' }; + }), + updatedAt: Date.now(), + }; + }), + }; + saveSessionStore(newStore); + return newStore; +} diff --git a/app/lib/context-engine.js b/app/lib/context-engine.js new file mode 100644 index 0000000..cb941a9 --- /dev/null +++ b/app/lib/context-engine.js @@ -0,0 +1,827 @@ +// 上下文收集引擎 — 像 Cursor 一样,在每次 AI 调用前自动汇聚所有相关信息 +// 让 AI 完整理解:作品风格、人物设定、世界观、前文脉络、当前位置 + +import { getChapters } from './storage'; +import { getProjectSettings, getSettingsNodes, getWritingMode, getActiveWorkId } from './settings'; +import { getEmbedding, cosineSimilarity } from './embeddings'; + +// ==================== Token 预算管理 ==================== + +export const INPUT_TOKEN_BUDGET = 200000; // 输入预算(发送给AI的上下文) +export const OUTPUT_TOKEN_BUDGET = 6000; // 输出预算(AI生成的回复长度,约4500字) + +// 中文 token 估算:约 1.5 个中文字符 = 1 个 token +export function estimateTokens(text) { + if (!text) return 0; + const chineseChars = (text.match(/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/g) || []).length; + const otherChars = text.length - chineseChars; + return Math.ceil(chineseChars / 1.5 + otherChars / 4); +} + +// 上下文模块优先级(数字越小越优先) +const PRIORITY = { + writingRules: 1, // 写作规则——必须严格遵守 + currentChapter: 2, // 当前章节元信息 + characters: 3, // 人物设定 + plotOutline: 4, // 大纲 + bookInfo: 5, // 作品信息 + worldbuilding: 6, // 世界观 + locations: 7, // 地点 + objects: 8, // 物品 + customSettings: 9, // 自定义设定 + previousChapters: 10, // 前文回顾(最容易截断) +}; + +/** + * 获取上下文可勾选条目列表(供「📚 参考」Tab 使用) (Async) + * 返回扁平化数组,每个条目包含 id, group, name, tokens, category + */ +export async function getContextItems(activeChapterId) { + const settings = getProjectSettings(); + const chapters = await getChapters(); + const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId); + + const allNodes = await getSettingsNodes(); + const activeWorkId = getActiveWorkId(); + + // 按当前作品过滤 + let nodes; + if (activeWorkId) { + const workDesc = new Set(); + const coll = (pid) => { allNodes.filter(n => n.parentId === pid).forEach(n => { workDesc.add(n.id); coll(n.id); }); }; + workDesc.add(activeWorkId); + coll(activeWorkId); + nodes = allNodes.filter(n => workDesc.has(n.id)); + } else { + nodes = allNodes; + } + const itemNodes = nodes.filter(n => n.type === 'item'); + + const items = []; + + // 分类映射 + const categoryMap = { + rules: { group: '📐 写作规则', builder: (ns) => buildRulesContext(ns) }, + character: { group: '👤 人物设定', builder: (ns) => buildCharactersContext(ns) }, + location: { group: '🗺️ 空间/地点', builder: (ns) => buildLocationsContext(ns, nodes) }, + world: { group: '🌍 世界观', builder: (ns) => buildWorldContext(ns, nodes) }, + object: { group: '🔮 物品/道具', builder: (ns) => buildObjectsContext(ns, nodes) }, + plot: { group: '📋 大纲', builder: (ns) => buildPlotContext(ns, nodes) }, + custom: { group: '⚙️ 自定义', builder: (ns) => buildCustomContext(ns, nodes) }, + }; + + // 设定条目 + for (const [cat, config] of Object.entries(categoryMap)) { + const catNodes = itemNodes.filter(n => n.category === cat); + if (catNodes.length === 0) { + // 检查是否有该分类的文件夹 — 如果有,显示空提示 + const hasFolder = nodes.some(n => n.type === 'folder' && n.category === cat); + if (hasFolder) { + items.push({ + id: `empty-${cat}`, + group: config.group, + name: '(暂无条目)', + tokens: 0, + category: cat, + enabled: false, + _empty: true, + }); + } + continue; + } + for (const n of catNodes) { + const text = config.builder([n]); + items.push({ + id: `setting-${n.id}`, + group: config.group, + name: n.name, + tokens: estimateTokens(text), + category: cat, + enabled: n.enabled !== false, + _nodeId: n.id, + }); + } + } + + // 作品信息 + const bookInfo = settings.bookInfo; + if (bookInfo && (bookInfo.title || bookInfo.author || bookInfo.genre || bookInfo.synopsis)) { + items.push({ + id: 'bookinfo', + group: '📖 作品信息', + name: bookInfo.title || '作品信息', + tokens: estimateTokens(buildBookInfoContext(bookInfo)), + category: 'bookInfo', + enabled: true, + }); + } + + // 章节条目 — 显示全部章节 + chapters.forEach((ch, i) => { + const text = stripHtml(ch.content || ''); + if (i === currentIndex) { + // 当前章节 + items.push({ + id: `chapter-current`, + group: '📑 章节', + name: `第${i + 1}章 ${ch.title}(当前)`, + tokens: estimateTokens(buildCurrentContext(ch, i, chapters.length)), + category: 'currentChapter', + enabled: true, + }); + } else { + items.push({ + id: `chapter-${ch.id}`, + group: '📑 章节', + name: `第${i + 1}章 ${ch.title}${i > currentIndex ? '(后续)' : ''}`, + tokens: estimateTokens(text ? text.slice(0, 1500) : ''), + category: 'chapter', + enabled: i < currentIndex, // 前文章节默认启用,后续章节默认不启用 + }); + } + }); + + return items; +} + +/** + * 构建完整的 AI 上下文 (Async) + * @param {string} activeChapterId + * @param {string} selectedText + * @param {Set|null} selectedIds - 如果提供,只包含 id 在此 Set 中的条目 + */ +export async function buildContext(activeChapterId, selectedText, selectedIds = null) { + const settings = getProjectSettings(); + const chapters = await getChapters(); + const currentChapter = chapters.find(ch => ch.id === activeChapterId); + const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId); + + // 从树形节点读取设定(过滤掉禁用项,并按当前作品过滤) + const allNodes = await getSettingsNodes(); + const activeWorkId = getActiveWorkId(); + + // 收集当前作品的所有后代节点 + let nodes; + if (activeWorkId) { + const workDescendants = new Set(); + const collectDesc = (pid) => { + allNodes.filter(n => n.parentId === pid).forEach(n => { + workDescendants.add(n.id); + collectDesc(n.id); + }); + }; + workDescendants.add(activeWorkId); + collectDesc(activeWorkId); + nodes = allNodes.filter(n => workDescendants.has(n.id)); + } else { + nodes = allNodes; + } + + // 获取所有有效的设定条目 + const allValidItemNodes = nodes.filter(n => n.type === 'item' && n.enabled !== false); + + // 如果有 selectedIds,提取手动勾选的设定 + const manualItemNodes = []; + const unselectedItemNodes = []; + + for (const n of allValidItemNodes) { + // 规则类总是作为手动(强制生效)如果 enabled + if (n.category === 'rules') { + manualItemNodes.push(n); + continue; + } + + if (selectedIds && selectedIds.has(`setting-${n.id}`)) { + manualItemNodes.push(n); + } else if (!selectedIds) { + // 如果 selectedIds 为 null,默认所有启用的项都算作手动(兼容旧逻辑,或视情况全转RAG) + // 为了发挥 RAG 威力,如果 selectedIds 为 null,我们可以假设除了规则外,全部走向 RAG, + // 或者全部走 Manual。这里我们设定期望:全部走 RAG 自动检索。 + unselectedItemNodes.push(n); + } else { + unselectedItemNodes.push(n); + } + } + + // --- RAG 自动检索 --- + let autoRetrievedNodes = []; + const queryText = (selectedText || '').trim(); + if (queryText && unselectedItemNodes.length > 0) { + try { + // 获取 Query 的 Embedding + // 此处的文本可以是光标前的一小段文字,为了简便使用 selectedText + const { apiConfig } = getProjectSettings(); + // TODO: 这里如果是空文本,可能需要补当前章节最近的内容 + let ragSourceText = queryText; + if (ragSourceText.length < 50 && currentChapter) { + // 文本太短,用当前章节末尾补充 + const stripChapText = stripHtml(currentChapter.content || '').slice(-200); + ragSourceText = ragSourceText + '\n' + stripChapText; + } + + const queryVector = await getEmbedding(ragSourceText, apiConfig); + if (queryVector) { + // 计算相似度 + const scoredNodes = unselectedItemNodes.map(n => { + if (!n.embedding) return { node: n, score: -1 }; + return { node: n, score: cosineSimilarity(queryVector, n.embedding) }; + }).filter(x => x.score > 0.3); // 设置一个基础阈值 0.3 + + // 按分数降序 + scoredNodes.sort((a, b) => b.score - a.score); + + // 取 Top-K (比如最多取 5 个) + autoRetrievedNodes = scoredNodes.slice(0, 5).map(x => x.node); + } + } catch (e) { + console.error('RAG Retrieval failed:', e); + } + } + + // 合并手动与自动检索的节点 + const itemNodes = [...manualItemNodes, ...autoRetrievedNodes]; + // 去重 + const finalItemNodes = Array.from(new Set(itemNodes)); + + const writingMode = getWritingMode(); + + // 先构建各模块的原始文本 + const rawModules = { + bookInfo: (!selectedIds || selectedIds.has('bookinfo')) ? buildBookInfoContext(settings.bookInfo) : '', + characters: buildCharactersContext(finalItemNodes.filter(n => n.category === 'character')), + locations: buildLocationsContext(finalItemNodes.filter(n => n.category === 'location'), nodes), + worldbuilding: buildWorldContext(finalItemNodes.filter(n => n.category === 'world'), nodes), + objects: buildObjectsContext(finalItemNodes.filter(n => n.category === 'object'), nodes), + plotOutline: buildPlotContext(finalItemNodes.filter(n => n.category === 'plot'), nodes), + writingRules: buildRulesContext(finalItemNodes.filter(n => n.category === 'rules')), + customSettings: buildCustomContext(finalItemNodes.filter(n => n.category === 'custom'), nodes), + previousChapters: selectedIds + ? buildPreviousContextFiltered(chapters, currentIndex, selectedIds) + : buildPreviousContext(chapters, currentIndex), + currentChapter: (!selectedIds || selectedIds.has('chapter-current')) + ? buildCurrentContext(currentChapter, currentIndex, chapters.length) + : '', + }; + + // 按优先级分配 token 预算 + const budgetedModules = applyTokenBudget(rawModules); + + const context = { + writingMode, + ...budgetedModules, + focusText: selectedText || '', + }; + + return context; +} + +/** + * 按优先级分配 token 预算,超出时截断低优先级内容 + */ +function applyTokenBudget(modules) { + // 计算每个模块的 token + const entries = Object.entries(modules).map(([key, text]) => ({ + key, + text: text || '', + tokens: estimateTokens(text), + priority: PRIORITY[key] || 99, + })); + + // 按优先级排序 + entries.sort((a, b) => a.priority - b.priority); + + let remaining = INPUT_TOKEN_BUDGET; + const result = {}; + + for (const entry of entries) { + if (entry.tokens <= remaining) { + result[entry.key] = entry.text; + remaining -= entry.tokens; + } else if (remaining > 100) { + // 截断:按比例保留 + const ratio = remaining / entry.tokens; + const keepChars = Math.floor(entry.text.length * ratio * 0.9); // 留10%余量 + result[entry.key] = entry.text.slice(0, keepChars) + '\n…(因 token 限制,部分内容已省略)'; + remaining = 0; + } else { + result[entry.key] = ''; // 预算耗尽 + } + } + + return result; +} + +/** + * 获取上下文预览(各模块状态和 token 估算) (Async) + */ +export async function getContextPreview(activeChapterId, selectedText) { + const settings = getProjectSettings(); + const chapters = await getChapters(); + const currentChapter = chapters.find(ch => ch.id === activeChapterId); + const currentIndex = chapters.findIndex(ch => ch.id === activeChapterId); + + const allNodes = await getSettingsNodes(); + const activeWorkId = getActiveWorkId(); + + // 按当前作品过滤 + let nodes; + if (activeWorkId) { + const workDesc = new Set(); + const coll = (pid) => { allNodes.filter(n => n.parentId === pid).forEach(n => { workDesc.add(n.id); coll(n.id); }); }; + workDesc.add(activeWorkId); + coll(activeWorkId); + nodes = allNodes.filter(n => workDesc.has(n.id)); + } else { + nodes = allNodes; + } + const allItemNodes = nodes.filter(n => n.type === 'item'); + const enabledItemNodes = allItemNodes.filter(n => n.enabled !== false); + + const modules = [ + { + key: 'writingRules', + label: '📐 写作规则', + count: enabledItemNodes.filter(n => n.category === 'rules').length, + totalCount: allItemNodes.filter(n => n.category === 'rules').length, + tokens: estimateTokens(buildRulesContext(enabledItemNodes.filter(n => n.category === 'rules'))), + priority: PRIORITY.writingRules, + }, + { + key: 'characters', + label: '👤 人物设定', + count: enabledItemNodes.filter(n => n.category === 'character').length, + totalCount: allItemNodes.filter(n => n.category === 'character').length, + tokens: estimateTokens(buildCharactersContext(enabledItemNodes.filter(n => n.category === 'character'))), + priority: PRIORITY.characters, + }, + { + key: 'locations', + label: '🗺️ 空间/地点', + count: enabledItemNodes.filter(n => n.category === 'location').length, + totalCount: allItemNodes.filter(n => n.category === 'location').length, + tokens: estimateTokens(buildLocationsContext(enabledItemNodes.filter(n => n.category === 'location'), nodes)), + priority: PRIORITY.locations, + }, + { + key: 'worldbuilding', + label: '🌍 世界观', + count: enabledItemNodes.filter(n => n.category === 'world').length, + totalCount: allItemNodes.filter(n => n.category === 'world').length, + tokens: estimateTokens(buildWorldContext(enabledItemNodes.filter(n => n.category === 'world'), nodes)), + priority: PRIORITY.worldbuilding, + }, + { + key: 'objects', + label: '🔮 物品/道具', + count: enabledItemNodes.filter(n => n.category === 'object').length, + totalCount: allItemNodes.filter(n => n.category === 'object').length, + tokens: estimateTokens(buildObjectsContext(enabledItemNodes.filter(n => n.category === 'object'), nodes)), + priority: PRIORITY.objects, + }, + { + key: 'plotOutline', + label: '📋 大纲', + count: enabledItemNodes.filter(n => n.category === 'plot').length, + totalCount: allItemNodes.filter(n => n.category === 'plot').length, + tokens: estimateTokens(buildPlotContext(enabledItemNodes.filter(n => n.category === 'plot'), nodes)), + priority: PRIORITY.plotOutline, + }, + { + key: 'bookInfo', + label: '📖 作品信息', + count: settings.bookInfo?.title ? 1 : 0, + totalCount: 1, + tokens: estimateTokens(buildBookInfoContext(settings.bookInfo)), + priority: PRIORITY.bookInfo, + }, + { + key: 'previousChapters', + label: '📑 前文回顾', + count: Math.max(0, currentIndex), + totalCount: Math.max(0, currentIndex), + tokens: estimateTokens(buildPreviousContext(chapters, currentIndex)), + priority: PRIORITY.previousChapters, + }, + { + key: 'currentChapter', + label: '✏️ 当前章节', + count: currentChapter ? 1 : 0, + totalCount: 1, + tokens: estimateTokens(buildCurrentContext(currentChapter, currentIndex, chapters.length)), + priority: PRIORITY.currentChapter, + }, + ]; + + const totalTokens = modules.reduce((sum, m) => sum + m.tokens, 0); + + return { + modules, + totalTokens, + inputBudget: INPUT_TOKEN_BUDGET, + outputBudget: OUTPUT_TOKEN_BUDGET, + budget: INPUT_TOKEN_BUDGET, // 兼容旧字段 + overBudget: totalTokens > INPUT_TOKEN_BUDGET, + }; +} + +/** + * 将上下文编译成系统提示词 + */ +export function compileSystemPrompt(context, mode) { + const sections = []; + + sections.push(getModeRolePrompt(context.writingMode)); + + if (context.bookInfo) { + sections.push(`【作品信息】\n${context.bookInfo}`); + } + if (context.characters) { + sections.push(`【人物档案】\n以下是本作品中的重要角色,写作时必须严格遵循他们的设定:\n${context.characters}`); + } + if (context.locations) { + sections.push(`【空间/地点】\n以下是本作品中的重要场所:\n${context.locations}`); + } + if (context.worldbuilding) { + sections.push(`【世界观设定】\n以下是本作品的世界观,所有内容必须在这个框架内:\n${context.worldbuilding}`); + } + if (context.objects) { + sections.push(`【物品/道具】\n以下是本作品中的重要物品:\n${context.objects}`); + } + if (context.plotOutline) { + sections.push(`【剧情大纲】\n${context.plotOutline}`); + } + if (context.writingRules) { + sections.push(`【写作规则——必须严格遵守】\n${context.writingRules}`); + } + if (context.customSettings) { + sections.push(`【补充设定】\n${context.customSettings}`); + } + if (context.previousChapters) { + sections.push(`【前文回顾】\n以下是之前章节的主要内容,续写时必须保持连贯:\n${context.previousChapters}`); + } + if (context.currentChapter) { + sections.push(`【当前写作位置】\n${context.currentChapter}`); + } + + const modeInstruction = getModeInstruction(mode); + sections.push(`【你的任务】\n${modeInstruction}`); + + return sections.join('\n\n---\n\n'); +} + +/** + * 构建用户提示词 + */ +export function compileUserPrompt(mode, text, instruction) { + switch (mode) { + case 'continue': + if (!text || !text.trim()) { + return instruction + ? `请根据以下要求开始创作新内容(约300-500字):\n要求:${instruction}` + : '请根据设定集信息,开始撰写新的章节内容(约300-500字)。'; + } + return instruction + ? `请续写以下内容(约300-500字),保持风格和情节的连贯性:\n要求:${instruction}\n\n「${text}」` + : `请续写以下内容(约300-500字),保持风格和情节的连贯性:\n\n「${text}」`; + case 'rewrite': + return instruction + ? `按照以下要求改写文本:\n要求:${instruction}\n\n原文:\n「${text}」` + : `请润色改写以下文本,提升文学质量:\n\n「${text}」`; + case 'expand': + return `请扩写以下文本,加入更丰富的细节和描写(约为原文1.5-2倍):\n\n「${text}」`; + case 'condense': + return `请精简以下文本,保留核心信息,删除冗余:\n\n「${text}」`; + case 'dialogue': + return `请为以下场景优化或续写人物对话,对话须符合各角色的说话风格:\n\n「${text}」`; + case 'chat': + // 自由对话模式:instruction 是用户的问题,text 可能是选中文本 + if (text && instruction) { + return `${instruction}\n\n参考文本:\n「${text}」`; + } + return instruction || text || '请根据设定集信息回答我的问题。'; + default: + return instruction ? `${instruction}\n\n「${text}」` : text; + } +} + +// ==================== 内部构建函数 ==================== + +function buildBookInfoContext(bookInfo) { + if (!bookInfo) return ''; + const parts = []; + if (bookInfo.title) parts.push(`书名:${bookInfo.title}`); + if (bookInfo.genre) parts.push(`题材:${bookInfo.genre}`); + if (bookInfo.synopsis) parts.push(`故事简介:${bookInfo.synopsis}`); + if (bookInfo.style) parts.push(`写作风格:${bookInfo.style}`); + if (bookInfo.tone) parts.push(`整体基调:${bookInfo.tone}`); + if (bookInfo.pov) parts.push(`叙事视角:${bookInfo.pov}`); + if (bookInfo.targetAudience) parts.push(`目标读者:${bookInfo.targetAudience}`); + return parts.length > 0 ? parts.join('\n') : ''; +} + +// 从树节点构建人物上下文 +function buildCharactersContext(charNodes) { + if (!charNodes || charNodes.length === 0) return ''; + return charNodes.map(n => { + const c = n.content || {}; + const parts = [`【${n.name}】(${c.role || '角色'})(id: ${n.id})`]; + if (c.age) parts.push(` 年龄:${c.age}`); + if (c.gender) parts.push(` 性别:${c.gender}`); + if (c.appearance) parts.push(` 外貌:${c.appearance}`); + if (c.personality) parts.push(` 性格:${c.personality}`); + if (c.background) parts.push(` 背景:${c.background}`); + if (c.motivation) parts.push(` 动机/目标:${c.motivation}`); + if (c.skills) parts.push(` 能力:${c.skills}`); + if (c.speechStyle) parts.push(` 说话风格:${c.speechStyle}`); + if (c.relationships) parts.push(` 人物关系:${c.relationships}`); + if (c.arc) parts.push(` 成长弧线:${c.arc}`); + if (c.notes) parts.push(` 备注:${c.notes}`); + return parts.join('\n'); + }).join('\n\n'); +} + +// 从树节点构建世界观上下文(带层级路径) +function buildWorldContext(worldNodes, allNodes) { + if (!worldNodes || worldNodes.length === 0) return ''; + return worldNodes.map(n => { + const path = getNodePathStr(n, allNodes); + const desc = n.content?.description || ''; + return `[${path}] (id: ${n.id}) +${desc}`; + }).join('\n\n'); +} + +// 从树节点构建大纲上下文 +function buildPlotContext(plotNodes, allNodes) { + if (!plotNodes || plotNodes.length === 0) return ''; + return plotNodes.map(n => { + const path = getNodePathStr(n, allNodes); + const status = n.content?.status ? `(${n.content.status})` : ''; + const desc = n.content?.description || ''; + return `[${path}]${status} (id: ${n.id}) +${desc}`; + }).join('\n\n'); +} + +// 从树节点构建写作规则 +function buildRulesContext(rulesNodes) { + if (!rulesNodes || rulesNodes.length === 0) return ''; + return rulesNodes.map(n => { + const desc = n.content?.description || ''; + return `${n.name} (id: ${n.id}): +${desc}`; + }).join('\n\n'); +} + +// 从树节点构建自定义设定 +function buildCustomContext(customNodes, allNodes) { + if (!customNodes || customNodes.length === 0) return ''; + return customNodes.map(n => { + const path = getNodePathStr(n, allNodes); + const desc = n.content?.description || ''; + return `[${path}] (id: ${n.id}) +${desc}`; + }).join('\n\n'); +} + +// 获取节点路径字符串(不含根分类) +function getNodePathStr(node, allNodes) { + const path = []; + let current = node; + while (current) { + if (current.parentId !== null) { + path.unshift(current.name); + } + current = current.parentId ? allNodes.find(n => n.id === current.parentId) : null; + } + return path.join(' / '); +} + +function buildPreviousContext(chapters, currentIndex) { + if (currentIndex <= 0) return ''; + + const prevChapters = chapters.slice(0, currentIndex); + + return prevChapters.map((ch, i) => { + const text = stripHtml(ch.content || ''); + if (!text) return `第${i + 1}章「${ch.title}」:(空)`; + + if (i === currentIndex - 1) { + // 前一章:保留更多内容 + const excerpt = text.length > 1500 ? '...' + text.slice(-1500) : text; + return `第${i + 1}章「${ch.title}」(前一章完整回顾):\n${excerpt}`; + } + + // 远距章节:只保留简要摘要 + if (text.length > 300) { + return `第${i + 1}章「${ch.title}」:\n${text.slice(0, 150)}…(中间省略)…${text.slice(-100)}`; + } + return `第${i + 1}章「${ch.title}」:\n${text}`; + }).join('\n\n'); +} + +// 按 selectedIds 过滤的前文上下文 +function buildPreviousContextFiltered(chapters, currentIndex, selectedIds) { + if (currentIndex <= 0) return ''; + + const prevChapters = chapters.slice(0, currentIndex); + const selected = prevChapters.filter(ch => selectedIds.has(`chapter-${ch.id}`)); + if (selected.length === 0) return ''; + + return selected.map((ch) => { + const i = chapters.indexOf(ch); + const text = stripHtml(ch.content || ''); + if (!text) return `第${i + 1}章「${ch.title}」:(空)`; + + if (i === currentIndex - 1) { + const excerpt = text.length > 1500 ? '...' + text.slice(-1500) : text; + return `第${i + 1}章「${ch.title}」(前一章完整回顾):\n${excerpt}`; + } + + if (text.length > 300) { + return `第${i + 1}章「${ch.title}」:\n${text.slice(0, 150)}…(中间省略)…${text.slice(-100)}`; + } + return `第${i + 1}章「${ch.title}」:\n${text}`; + }).join('\n\n'); +} + +function buildCurrentContext(chapter, index, totalChapters) { + if (!chapter) return ''; + const text = stripHtml(chapter.content || ''); + const parts = [ + `当前章节:第${index + 1}章 / 共${totalChapters}章`, + `章节标题:「${chapter.title}」`, + ]; + if (text) { + parts.push(`本章已有字数:${text.replace(/\s/g, '').length}字`); + parts.push(`\n--- 本章正文 ---\n${text}`); + } + return parts.join('\n'); +} + +function getModeInstruction(mode) { + switch (mode) { + case 'continue': + return `根据前文的情节走向和当前章节的内容,自然地续写故事。 +要求: +- 续写内容必须与前文情节逻辑严格连贯,不能出现矛盾 +- 如果涉及已有角色,必须符合其性格设定和说话风格 +- 场景描写要符合世界观设定 +- 情节推进要符合大纲规划的方向 +- 约300-500字`; + + case 'rewrite': + return `润色和改写指定文本,提升文学质量。 +要求: +- 保持原文的核心含义和情节不变 +- 提升感官描写和文学表现力 +- 确保改写后的内容仍然符合人物设定和世界观 +- 保持叙事视角一致`; + + case 'expand': + return `扩写指定文本,丰富细节。 +要求: +- 增加环境描写、感官细节、心理活动 +- 深化人物的情感表达 +- 扩写后必须与前后文衔接自然 +- 不改变原有情节走向`; + + case 'condense': + return `精简指定文本,提升节奏感。 +要求: +- 删除冗余修饰和重复表达 +- 保留核心信息和关键描写 +- 保持情节完整性`; + + case 'dialogue': + return `优化或续写人物对话。 +要求: +- 每个角色的对话必须符合其性格设定和说话风格 +- 通过对话推动情节或展现人物关系 +- 加入适当的动作描写和表情细节 +- 对话节奏要自然,长短句交替`; + + case 'chat': + return `作为创作顾问,根据上述设定集的全部信息回答作者的问题。 +要求: +- 回答必须基于已有的设定信息,不能凭空捏造 +- 如果问题涉及设定中未覆盖的内容,可以给出基于现有设定的合理建议 +- 语言简洁明了,尽量具体 +- 回答以中文呈现 + +【设定集管理能力】 +当用户要求你创建、修改或删除设定集条目时,你可以在回复中嵌入操作指令块。格式如下: + +\`\`\` +[SETTINGS_ACTION] +{"action":"add","category":"character","name":"角色姓名","content":{"role":"主角","personality":"...","background":"..."}} +[/SETTINGS_ACTION] +\`\`\` + +可用的 action: "add"(新增)、"update"(更新,需提供 nodeId)、"delete"(删除,需提供 nodeId) + +可用的 category 和对应 content 字段: +- "character":角色。content 可含:role, age, gender, appearance, personality, background, motivation, skills, speechStyle, relationships, arc, notes +- "world":世界观。content 可含:description +- "location":地点。content 可含:description, sensoryVisual, sensoryAudio, sensorySmell, mood, dangerLevel +- "object":物品。content 可含:description, objectType, currentHolder, rank, numericStats, symbolism +- "plot":大纲。content 可含:description, status +- "rules":写作规则。content 可含:description +- "custom":自定义。content 可含:description + +使用规则: +- 每个操作块只包含一个 JSON 对象 +- 如果需要多个操作,使用多个 [SETTINGS_ACTION] 块 +- 操作块前后必须有正常的文字说明 +- update 示例:{"action":"update","nodeId":"具体id","name":"新名称","content":{...}} +- delete 示例:{"action":"delete","nodeId":"具体id"} +- 在正文中已有角色/设定出现时,如果用户要求,可以从正文分析内容并自动创建设定`; + + default: + return '按照作者的指示完成写作任务,确保内容与已有设定一致。'; + } +} + +// ==================== 写作模式角色提示词 ==================== + +function getModeRolePrompt(writingMode) { + const base = `你的核心原则: +- 深度理解作品的世界观和人物,绝不写出与设定矛盾的内容 +- 保持作者已建立的写作风格和语气,你是协作者,不是替代者 +- 对话要体现角色独特的说话方式和性格 +- 避免"然而"、"不禁"、"竟然"、"仿佛"等AI味词汇 +- 情节推进要自然,符合大纲规划和人物动机`; + + switch (writingMode) { + case 'webnovel': + return `你是一位资深网络小说写手兼编辑,擅长连载节奏把控和爽点设计。你正在协助作者创作一部网络小说。 +${base} +- 注重节奏感和爽点密度,每个章节要有"钩子"吸引读者 +- 严格维护数值体系(等级、属性、技能冷却等)的一致性 +- 对话简洁有力,推动剧情和人物关系发展`; + + case 'traditional': + return `你是一位经验丰富的文学编辑兼小说家,深谙散文美学和主题构建。你正在协助作者创作一部文学作品。 +${base} +- 使用丰富的感官描写(视觉、听觉、触觉、嗅觉、味觉) +- 注重主题的编织和意象的呼应,象征具有层次感 +- 深入挖掘人物心理,内心独白要体现角色独特的思维方式 +- 散文质量优先,追求文字的精确性和美感`; + + case 'screenplay': + return `你是一位专业的影视编剧兼剧本顾问,精通视觉叙事和对白技巧。你正在协助作者创作一部剧本/脚本。 +${base} +- 以视觉化思维写作,描述"镜头能看到的"而非抽象概念 +- 对白必须口语化、角色化,每个角色有独特的"声音" +- 注意场景的物理逻辑:谁在场、在哪里、什么时间 +- 保持光照连续性(日/夜)和空间连续性`; + + default: + return `你是一位经验丰富的中文作家兼编辑。你正在协助作者创作一部作品。 +${base} +- 使用丰富的感官描写(视觉、听觉、触觉、嗅觉、味觉)`; + } +} + +// ==================== 新分类上下文构建 ==================== + +// 空间/地点上下文 +function buildLocationsContext(locationNodes, allNodes) { + if (!locationNodes || locationNodes.length === 0) return ''; + return locationNodes.map(n => { + const path = getNodePathStr(n, allNodes); + const c = n.content || {}; + const parts = [`[${path}]`]; + if (c.description) parts.push(c.description); + if (c.slugline) parts.push(`场景标题:${c.slugline}`); + if (c.sensoryVisual) parts.push(`视觉:${c.sensoryVisual}`); + if (c.sensoryAudio) parts.push(`听觉:${c.sensoryAudio}`); + if (c.sensorySmell) parts.push(`嗅觉/触觉:${c.sensorySmell}`); + if (c.mood) parts.push(`氛围:${c.mood}`); + if (c.dangerLevel) parts.push(`危险等级:${c.dangerLevel}`); + return parts.join('\n'); + }).join('\n\n'); +} + +// 物品/道具上下文 +function buildObjectsContext(objectNodes, allNodes) { + if (!objectNodes || objectNodes.length === 0) return ''; + return objectNodes.map(n => { + const path = getNodePathStr(n, allNodes); + const c = n.content || {}; + const parts = [`[${path}]`]; + if (c.description) parts.push(c.description); + if (c.objectType) parts.push(`类型:${c.objectType}`); + if (c.currentHolder) parts.push(`当前持有者:${c.currentHolder}`); + if (c.rank) parts.push(`品阶:${c.rank}`); + if (c.numericStats) parts.push(`属性:${c.numericStats}`); + if (c.symbolism) parts.push(`象征意义:${c.symbolism}`); + return parts.join('\n'); + }).join('\n\n'); +} + +// 去除HTML标签 +function stripHtml(html) { + if (!html) return ''; + return html.replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim(); +} diff --git a/app/lib/embeddings.js b/app/lib/embeddings.js new file mode 100644 index 0000000..8ed551c --- /dev/null +++ b/app/lib/embeddings.js @@ -0,0 +1,58 @@ +// 文本向量化与余弦相似度计算库 + +/** + * 获取文本的向量化表示 (Embeddings) + * @param {string} text 要向量化的文本 + * @param {object} apiConfig 从 getProjectSettings().apiConfig 传入的配置 + * @returns {Promise} 浮点数数组形式的向量 + */ +export async function getEmbedding(text, apiConfig) { + if (!text || text.trim() === '') return null; + + try { + const res = await fetch('/api/embed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, apiConfig }) + }); + + if (!res.ok) { + console.error('getEmbedding HTTP error:', await res.text()); + return null; + } + + const data = await res.json(); + if (data.error) { + console.error('getEmbedding API error:', data.error); + return null; + } + + return data.embedding; + } catch (err) { + console.error('getEmbedding fetch error:', err); + return null; + } +} + +/** + * 计算两个向量之间的余弦相似度 + * @param {number[]} vecA 向量 A + * @param {number[]} vecB 向量 B + * @returns {number} 相似度得分 (-1.0 到 1.0) + */ +export function cosineSimilarity(vecA, vecB) { + if (!vecA || !vecB || vecA.length !== vecB.length) return 0; + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < vecA.length; i++) { + dotProduct += vecA[i] * vecB[i]; + normA += vecA[i] * vecA[i]; + normB += vecB[i] * vecB[i]; + } + + if (normA === 0 || normB === 0) return 0; + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); +} diff --git a/app/lib/project-io.js b/app/lib/project-io.js new file mode 100644 index 0000000..7e78ca1 --- /dev/null +++ b/app/lib/project-io.js @@ -0,0 +1,134 @@ +/** + * 项目导出/导入 — 将所有 localStorage 数据打包为 JSON 文件 + * 支持:章节、设定集、API 配置、聊天会话 + */ + +const PROJECT_FILE_VERSION = 1; + +// 需要导出的所有 localStorage keys +const STORAGE_KEYS = { + chapters: 'author-chapters', + settings: 'author-project-settings', + settingsNodes: 'author-settings-nodes', + activeWork: 'author-active-work', + chatSessions: 'author-chat-sessions', +}; + +// 章节摘要前缀 +const SUMMARY_PREFIX = 'author-chapter-summary-'; + +/** + * 导出整个项目为 JSON 文件并下载 + */ +export function exportProject() { + if (typeof window === 'undefined') return; + + const data = { + _version: PROJECT_FILE_VERSION, + _exportedAt: new Date().toISOString(), + _app: 'Author', + }; + + // 收集所有主要数据 + for (const [key, storageKey] of Object.entries(STORAGE_KEYS)) { + try { + const raw = localStorage.getItem(storageKey); + data[key] = raw ? JSON.parse(raw) : null; + } catch { + data[key] = null; + } + } + + // 收集章节摘要 + const summaries = {}; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(SUMMARY_PREFIX)) { + const chapterId = k.slice(SUMMARY_PREFIX.length); + summaries[chapterId] = localStorage.getItem(k); + } + } + data.chapterSummaries = summaries; + + // 生成文件名 + const now = new Date(); + const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; + const fileName = `Author_存档_${dateStr}.json`; + + // 下载 + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + return fileName; +} + +/** + * 从 JSON 文件导入项目数据 + * @param {File} file - 用户选择的 JSON 文件 + * @returns {Promise<{ success: boolean, message: string }>} + */ +export async function importProject(file) { + if (typeof window === 'undefined') return { success: false, message: '环境不支持' }; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + // 基本校验 + if (!data._app || data._app !== 'Author') { + return { success: false, message: '文件格式不正确,不是 Author 存档文件' }; + } + + // 恢复主要数据 + for (const [key, storageKey] of Object.entries(STORAGE_KEYS)) { + if (data[key] !== undefined && data[key] !== null) { + localStorage.setItem(storageKey, JSON.stringify(data[key])); + } + } + + // 恢复章节摘要 + if (data.chapterSummaries && typeof data.chapterSummaries === 'object') { + for (const [chapterId, summary] of Object.entries(data.chapterSummaries)) { + if (summary) { + localStorage.setItem(SUMMARY_PREFIX + chapterId, summary); + } + } + } + + return { success: true, message: `成功导入存档(导出时间:${data._exportedAt || '未知'})` }; + } catch (err) { + return { success: false, message: `导入失败:${err.message}` }; + } +} + +/** + * 获取当前项目数据的概要信息(用于显示) + */ +export function getProjectSummary() { + if (typeof window === 'undefined') return null; + + try { + const chaptersRaw = localStorage.getItem(STORAGE_KEYS.chapters); + const chapters = chaptersRaw ? JSON.parse(chaptersRaw) : []; + const nodesRaw = localStorage.getItem(STORAGE_KEYS.settingsNodes); + const nodes = nodesRaw ? JSON.parse(nodesRaw) : []; + const sessionsRaw = localStorage.getItem(STORAGE_KEYS.chatSessions); + const sessions = sessionsRaw ? JSON.parse(sessionsRaw) : {}; + + return { + chapterCount: chapters.length, + settingsNodeCount: nodes.length, + sessionCount: Object.keys(sessions.sessions || {}).length, + totalChars: chapters.reduce((sum, ch) => sum + (ch.content?.length || 0), 0), + }; + } catch { + return null; + } +} diff --git a/app/lib/settings.js b/app/lib/settings.js new file mode 100644 index 0000000..3bbbb0e --- /dev/null +++ b/app/lib/settings.js @@ -0,0 +1,717 @@ +// 设定集管理 - 存储人物、世界观、大纲等全局创作信息 +// 这些信息会在每次AI调用时作为上下文传入,让AI像Cursor一样了解整个项目 +// 基于「叙事引擎」架构 — 支持网络小说、传统文学、剧本/脚本三种创作模式 + +import { get, set } from 'idb-keyval'; +import { getEmbedding } from './embeddings'; + +const SETTINGS_KEY = 'author-project-settings'; + +/** + * 递归提取节点的所有文本内容,用于向量化 + */ +function extractTextForEmbedding(node) { + if (!node) return ''; + let text = `Name: ${node.name || ''}\n`; + + const extract = (obj) => { + if (typeof obj === 'string') return obj; + if (Array.isArray(obj)) return obj.map(extract).join(' '); + if (typeof obj === 'object' && obj !== null) { + return Object.values(obj).filter(v => v).map(extract).join(' '); + } + return ''; + }; + + if (node.content) { + text += extract(node.content); + } + return text.trim(); +} + +// ==================== 写作模式定义 ==================== + +export const WRITING_MODES = { + webnovel: { + key: 'webnovel', + label: '网络小说', + icon: '📱', + color: '#3b82f6', + desc: '适合日更连载、修仙玄幻、系统流等网文创作', + painPoint: '数值膨胀与连载一致性', + extraCharacterFields: [ + { key: 'level', label: '等级/境界', placeholder: '例:筑基期三层 / Lv.45', multiline: false }, + { key: 'stats', label: '属性面板', placeholder: '力量:85\n敏捷:72\n智力:90\n体质:68', multiline: true, rows: 4 }, + { key: 'skillList', label: '技能列表', placeholder: '技能名称、效果、冷却时间...', multiline: true, rows: 3 }, + { key: 'equipment', label: '装备/法宝', placeholder: '当前装备和持有的重要物品', multiline: true, rows: 2 }, + ], + extraLocationFields: [ + { key: 'dangerLevel', label: '危险等级', placeholder: '例:S级禁区 / 安全区', multiline: false }, + { key: 'resources', label: '资源产出', placeholder: '灵石矿脉、药草分布...', multiline: true, rows: 2 }, + ], + extraObjectFields: [ + { key: 'rank', label: '品阶/等级', placeholder: '例:天级上品 / SSR', multiline: false }, + { key: 'numericStats', label: '数值属性', placeholder: '攻击力+500\n暴击率+15%', multiline: true, rows: 3 }, + ], + }, + traditional: { + key: 'traditional', + label: '传统文学', + icon: '📚', + color: '#8b5cf6', + desc: '适合严肃小说、纯文学、短篇、出版向作品', + painPoint: '主题编织与草稿迭代', + extraCharacterFields: [ + { key: 'coreTrauma', label: '核心创伤', placeholder: '角色内心深处的伤痕、驱动行为的心理根源', multiline: true, rows: 2 }, + { key: 'innerMonologue', label: '内心独白关键词', placeholder: '角色内心世界的典型词汇和思维方式', multiline: true, rows: 2 }, + { key: 'voice', label: '人物声音/对话标签', placeholder: '独特的措辞习惯、语法特点、方言痕迹...', multiline: true, rows: 2 }, + { key: 'motifs', label: '反复意象/母题', placeholder: '与角色绑定的象征符号,如“绿光”、“断桥”', multiline: true, rows: 2 }, + ], + extraLocationFields: [ + { key: 'sensoryVisual', label: '视觉描写', placeholder: '色调、光线、空间感...', multiline: true, rows: 2 }, + { key: 'sensoryAudio', label: '听觉描写', placeholder: '环境音、远处声响...', multiline: true, rows: 2 }, + { key: 'sensorySmell', label: '嗅觉/触觉', placeholder: '气味、温度、湿度、质感...', multiline: true, rows: 2 }, + { key: 'mood', label: '氛围/情绪基调', placeholder: '压抑、温馨、荒凉、神秘...', multiline: false }, + ], + extraObjectFields: [ + { key: 'symbolism', label: '象征意义', placeholder: '这个物品在主题上代表什么?', multiline: true, rows: 2 }, + ], + }, + screenplay: { + key: 'screenplay', + label: '剧本/脚本', + icon: '🎬', + color: '#f59e0b', + desc: '适合影视剧本、舞台剧、广播剧等脚本创作', + painPoint: '连续性与制作可行性', + extraCharacterFields: [ + { key: 'castType', label: '角色类型', placeholder: '主演 / 配角 / 客串 / 群演', multiline: false }, + { key: 'sceneCount', label: '出场场次', placeholder: '出现在哪些场次(如 4, 12, 55)', multiline: false }, + { key: 'dialogueStyle', label: '对白风格笔记', placeholder: '说话节奏、用语习惯、语气特点...', multiline: true, rows: 3 }, + ], + extraLocationFields: [ + { key: 'slugline', label: '场景标题', placeholder: '如:INT. 厨房 - DAY / EXT. 街道 - NIGHT', multiline: false }, + { key: 'shootingNotes', label: '拍摄备注', placeholder: '布景需求、特殊灯光、道具需求...', multiline: true, rows: 2 }, + { key: 'usedInScenes', label: '使用场次', placeholder: '此场景在哪些场次中被使用', multiline: false }, + ], + extraObjectFields: [ + { key: 'propCategory', label: '道具分类', placeholder: '手持道具 / 场景道具 / 特效道具', multiline: false }, + { key: 'requiredScenes', label: '所需场次', placeholder: '需要此道具的场次编号', multiline: false }, + ], + }, +}; + +// 默认项目设定结构 +const DEFAULT_SETTINGS = { + // 写作模式 + writingMode: 'webnovel', + + // API 配置 — 用户自己填入 API Key + apiConfig: { + provider: 'zhipu', // 预设供应商标识 + apiKey: '', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + model: 'glm-4-flash', + useCustomEmbed: false, // 是否使用独立的 Embedding API + embedProvider: 'zhipu', + embedApiKey: '', + embedBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', + embedModel: 'embedding-3', + }, + + // 作品基本信息 + bookInfo: { + title: '', + genre: '', // 题材类型:玄幻/都市/悬疑/言情/科幻... + synopsis: '', // 故事简介/梗概 + style: '', // 写作风格:如"轻松幽默"、"严肃沉重"、"诗意抒情" + tone: '', // 整体基调 + targetAudience: '', // 目标读者 + pov: '', // 叙事视角:第一人称/第三人称/全知视角 + }, + + // 人物设定 + characters: [ + // 每个人物的数据结构: + // { + // id: string, + // name: string, // 姓名 + // role: string, // 角色类型:主角/反派/配角/路人 + // age: string, // 年龄 + // gender: string, // 性别 + // appearance: string, // 外貌描写 + // personality: string, // 性格特征 + // background: string, // 背景故事 + // motivation: string, // 动机/目标 + // skills: string, // 能力/技能 + // speechStyle: string, // 说话风格/口头禅 + // relationships: string, // 与其他角色的关系 + // arc: string, // 角色成长弧线 + // notes: string, // 其他备注 + // } + ], + + // 世界观设定 + worldbuilding: { + era: '', // 时代背景 + geography: '', // 地理环境 + society: '', // 社会制度 + culture: '', // 文化习俗 + powerSystem: '', // 力量体系/魔法体系 + technology: '', // 科技水平 + rules: '', // 世界特殊规则 + history: '', // 历史大事件 + factions: '', // 势力/组织 + notes: '', // 其他设定 + }, + + // 大纲/剧情规划 + plotOutline: { + mainConflict: '', // 核心矛盾 + plotPoints: '', // 关键剧情节点(按顺序) + subplots: '', // 支线剧情 + ending: '', // 结局方向 + currentArc: '', // 当前所处的故事弧 + foreshadowing: '', // 已埋伏笔 + notes: '', // 其他备注 + }, + + // 写作规则/禁忌 + writingRules: { + mustDo: '', // 必须遵守的规则 + mustNotDo: '', // 禁止出现的内容/词汇 + styleGuide: '', // 风格指南 + notes: '', // 其他备注 + }, +}; + +// 获取项目设定 +export function getProjectSettings() { + if (typeof window === 'undefined') return DEFAULT_SETTINGS; + try { + const data = localStorage.getItem(SETTINGS_KEY); + if (!data) return DEFAULT_SETTINGS; + return { ...DEFAULT_SETTINGS, ...JSON.parse(data) }; + } catch { + return DEFAULT_SETTINGS; + } +} + +// 保存项目设定 +export function saveProjectSettings(settings) { + if (typeof window === 'undefined') return; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +// 添加角色 +export function addCharacter(character) { + const settings = getProjectSettings(); + const newChar = { + id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5), + name: '', + role: '配角', + age: '', + gender: '', + appearance: '', + personality: '', + background: '', + motivation: '', + skills: '', + speechStyle: '', + relationships: '', + arc: '', + notes: '', + ...character, + }; + settings.characters.push(newChar); + saveProjectSettings(settings); + return newChar; +} + +// 更新角色 +export function updateCharacter(id, updates) { + const settings = getProjectSettings(); + const idx = settings.characters.findIndex(c => c.id === id); + if (idx === -1) return null; + settings.characters[idx] = { ...settings.characters[idx], ...updates }; + saveProjectSettings(settings); + return settings.characters[idx]; +} + +// 删除角色 +export function deleteCharacter(id) { + const settings = getProjectSettings(); + settings.characters = settings.characters.filter(c => c.id !== id); + saveProjectSettings(settings); +} + +// ==================== 写作模式读写 ==================== + +export function getWritingMode() { + const settings = getProjectSettings(); + return settings.writingMode || 'webnovel'; +} + +export function setWritingMode(mode) { + if (!WRITING_MODES[mode]) return; + const settings = getProjectSettings(); + settings.writingMode = mode; + saveProjectSettings(settings); +} + +// ==================== 树形设定集节点系统 ==================== + +const NODES_KEY = 'author-settings-nodes'; +const ACTIVE_WORK_KEY = 'author-active-work'; + +function generateNodeId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 6); +} + +// ==================== 作品级节点系统 ==================== + +// 每个作品下自动创建的子分类模板 +const WORK_SUB_CATEGORIES = [ + { suffix: 'bookinfo', name: '作品信息', icon: '📖', category: 'bookInfo', type: 'special' }, + { suffix: 'characters', name: '人物设定', icon: '👤', category: 'character', type: 'folder' }, + { suffix: 'locations', name: '空间/地点', icon: '🗺️', category: 'location', type: 'folder' }, + { suffix: 'world', name: '世界观/设定', icon: '🌍', category: 'world', type: 'folder' }, + { suffix: 'objects', name: '物品/道具', icon: '🔮', category: 'object', type: 'folder' }, + { suffix: 'plot', name: '大纲', icon: '📋', category: 'plot', type: 'folder' }, + { suffix: 'rules', name: '写作规则', icon: '📐', category: 'rules', type: 'folder' }, +]; + +// 全局根分类(不属于任何作品)— 已废弃,所有规则均归属各作品 +const GLOBAL_ROOT_CATEGORIES = []; + +// 旧版 ROOT_CATEGORIES 的 id(用于迁移检测) +const LEGACY_ROOT_IDS = [ + 'root-bookinfo', 'root-characters', 'root-locations', + 'root-world', 'root-objects', 'root-plot', 'root-rules', +]; + +/** + * 创建一个作品节点及其下的完整子分类树 + * @returns {{ workNode, subNodes }} 创建的作品节点和子分类节点数组 + */ +export function createWorkNode(name, workId) { + const id = workId || ('work-' + generateNodeId()); + const now = new Date().toISOString(); + const workNode = { + id, + name: name || '新作品', + type: 'work', + category: 'work', + parentId: null, + order: 0, + icon: '📕', + content: {}, + collapsed: false, + enabled: true, + createdAt: now, + updatedAt: now, + }; + const subNodes = WORK_SUB_CATEGORIES.map((cat, i) => ({ + id: `${id}-${cat.suffix}`, + name: cat.name, + type: cat.type, + category: cat.category, + parentId: id, + order: i, + icon: cat.icon, + content: {}, + collapsed: false, + createdAt: now, + updatedAt: now, + })); + return { workNode, subNodes }; +} + +// ==================== 激活作品管理 ==================== + +export function getActiveWorkId() { + if (typeof window === 'undefined') return null; + return localStorage.getItem(ACTIVE_WORK_KEY) || null; +} + +export function setActiveWorkId(workId) { + if (typeof window === 'undefined') return; + if (workId) { + localStorage.setItem(ACTIVE_WORK_KEY, workId); + } else { + localStorage.removeItem(ACTIVE_WORK_KEY); + } +} + +export function getAllWorks(nodes) { + const allNodes = nodes || getSettingsNodes(); + return allNodes.filter(n => n.type === 'work'); +} + +// ==================== 节点初始化与迁移 ==================== + +// 获取默认节点树(包含一个默认作品 + 全局规则) +function getDefaultNodes() { + const { workNode, subNodes } = createWorkNode('默认作品', 'work-default'); + return [workNode, ...subNodes]; +} + +// 获取所有设定节点 (Async) +export async function getSettingsNodes() { + if (typeof window === 'undefined') return getDefaultNodes(); + try { + let nodes = await get(NODES_KEY); + if (!nodes) { + // 首次使用:尝试从 localStorage fallback 或迁移旧数据 + const legacyData = localStorage.getItem(NODES_KEY); + if (legacyData) { + nodes = JSON.parse(legacyData); + await set(NODES_KEY, nodes); // 写入 IndexedDB + } else { + const migrated = await migrateOldSettings(); + if (migrated) { + nodes = await migrateToWorkStructure(migrated); + return nodes; + } + const defaults = getDefaultNodes(); + await saveSettingsNodes(defaults); + if (!getActiveWorkId()) setActiveWorkId('work-default'); + return defaults; + } + } + + nodes = await migrateToWorkStructure(nodes); + nodes = await migrateGlobalRulesToWork(nodes); + nodes = await ensureWorkExists(nodes); + return nodes; + } catch { + return getDefaultNodes(); + } +} + +// 保存设定集节点 (Async) +export async function saveSettingsNodes(nodes) { + if (typeof window === 'undefined') return; + await set(NODES_KEY, nodes); +} + +/** + * 将旧的扁平根分类结构迁移到作品结构 (Async) + */ +async function migrateToWorkStructure(nodes) { + if (nodes.some(n => n.type === 'work')) return nodes; + + const legacyRoots = nodes.filter(n => n.parentId === null && LEGACY_ROOT_IDS.includes(n.id)); + if (legacyRoots.length === 0) return nodes; + + const { workNode } = createWorkNode('默认作品', 'work-default'); + const newNodes = [workNode]; + for (const node of nodes) { + if (LEGACY_ROOT_IDS.includes(node.id) && node.parentId === null) { + const suffix = node.id.replace('root-', ''); + const newId = `work-default-${suffix}`; + nodes.forEach(child => { + if (child.parentId === node.id) child.parentId = newId; + }); + newNodes.push({ ...node, id: newId, parentId: 'work-default' }); + } else if (!LEGACY_ROOT_IDS.includes(node.id)) { + newNodes.push(node); + } + } + + for (const cat of WORK_SUB_CATEGORIES) { + const expectedId = `work-default-${cat.suffix}`; + if (!newNodes.find(n => n.id === expectedId)) { + newNodes.push({ + id: expectedId, name: cat.name, type: cat.type, category: cat.category, + parentId: 'work-default', order: WORK_SUB_CATEGORIES.indexOf(cat), icon: cat.icon, + content: {}, collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + } + } + + await saveSettingsNodes(newNodes); + if (!getActiveWorkId()) setActiveWorkId('work-default'); + return newNodes; +} + +/** + * 迁移旧的全局写作规则到默认作品 (Async) + */ +async function migrateGlobalRulesToWork(nodes) { + const globalRules = nodes.find(n => n.id === 'root-rules' && n.parentId === null); + if (!globalRules) return nodes; + const activeWorkId = getActiveWorkId() || 'work-default'; + let targetRulesId = nodes.find(n => n.parentId === activeWorkId && n.category === 'rules')?.id; + if (!targetRulesId) { + const anyWork = nodes.find(n => n.type === 'work'); + if (anyWork) targetRulesId = nodes.find(n => n.parentId === anyWork.id && n.category === 'rules')?.id; + } + if (targetRulesId) { + nodes.forEach(n => { + if (n.parentId === 'root-rules') n.parentId = targetRulesId; + }); + } + nodes = nodes.filter(n => n.id !== 'root-rules'); + await saveSettingsNodes(nodes); + return nodes; +} + +// 确保至少有一个作品存在 (Async) +async function ensureWorkExists(nodes) { + if (!nodes.some(n => n.type === 'work')) { + const { workNode, subNodes } = createWorkNode('默认作品', 'work-default'); + nodes.push(workNode, ...subNodes); + await saveSettingsNodes(nodes); + } + if (!getActiveWorkId()) { + const firstWork = nodes.find(n => n.type === 'work'); + if (firstWork) setActiveWorkId(firstWork.id); + } + return nodes; +} + +// 添加节点 (Async) +export async function addSettingsNode({ name, type, category, parentId, icon, content }) { + const nodes = await getSettingsNodes(); + const siblings = nodes.filter(n => n.parentId === parentId); + const node = { + id: generateNodeId(), + name: name || (type === 'folder' ? '新分类' : '新条目'), + type: type || 'item', + category: category || 'custom', + parentId: parentId || null, + order: siblings.length, + icon: icon || (type === 'folder' ? '📁' : '📄'), + content: content || {}, + collapsed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + if (node.type === 'item') { + const textToEmbed = extractTextForEmbedding(node); + const { apiConfig } = getProjectSettings(); + node.embedding = await getEmbedding(textToEmbed, apiConfig); + } + + nodes.push(node); + await saveSettingsNodes(nodes); + return node; +} + +// 更新节点 (Async) +export async function updateSettingsNode(id, updates) { + const nodes = await getSettingsNodes(); + const idx = nodes.findIndex(n => n.id === id); + if (idx === -1) return null; + const isProtected = GLOBAL_ROOT_CATEGORIES.some(c => c.id === id) || + nodes[idx].type === 'work' || + (nodes[idx].parentId && nodes.some(p => p.id === nodes[idx].parentId && p.type === 'work') && WORK_SUB_CATEGORIES.some(c => id.endsWith('-' + c.suffix))); + if (isProtected) { + delete updates.type; + delete updates.category; + delete updates.parentId; + } + + // 如果名称或内容发生改变,且是条目,重新计算 embedding + const nodeType = updates.type || nodes[idx].type; + if (nodeType === 'item' && (updates.name !== undefined || updates.content !== undefined)) { + const tempNode = { ...nodes[idx], ...updates }; + const textToEmbed = extractTextForEmbedding(tempNode); + const { apiConfig } = getProjectSettings(); + updates.embedding = await getEmbedding(textToEmbed, apiConfig); + } + + nodes[idx] = { ...nodes[idx], ...updates, updatedAt: new Date().toISOString() }; + await saveSettingsNodes(nodes); + return nodes[idx]; +} + +// 删除节点(及所有子节点) (Async) +export async function deleteSettingsNode(id) { + let nodes = await getSettingsNodes(); + const node = nodes.find(n => n.id === id); + if (node && node.parentId) { + const parent = nodes.find(p => p.id === node.parentId); + if (parent && parent.type === 'work' && WORK_SUB_CATEGORIES.some(c => id.endsWith('-' + c.suffix))) return false; + } + const toDelete = new Set(); + const collect = (parentId) => { + toDelete.add(parentId); + nodes.filter(n => n.parentId === parentId).forEach(n => collect(n.id)); + }; + collect(id); + nodes = nodes.filter(n => !toDelete.has(n.id)); + await saveSettingsNodes(nodes); + return true; +} + +// 移动节点 (Async) +export async function moveSettingsNode(id, newParentId) { + const nodes = await getSettingsNodes(); + const idx = nodes.findIndex(n => n.id === id); + if (idx === -1) return null; + const siblings = nodes.filter(n => n.parentId === newParentId && n.id !== id); + nodes[idx] = { + ...nodes[idx], + parentId: newParentId, + order: siblings.length, + updatedAt: new Date().toISOString(), + }; + await saveSettingsNodes(nodes); + return nodes[idx]; +} + +// 获取指定分类下的所有 item 节点(递归) (Async) +export async function getItemsByCategory(category) { + const nodes = await getSettingsNodes(); + return nodes.filter(n => n.type === 'item' && n.category === category); +} + +// 获取某节点的所有子节点(直接子节点) (Async) +export async function getChildren(parentId) { + const nodes = await getSettingsNodes(); + return nodes + .filter(n => n.parentId === parentId) + .sort((a, b) => { + if (a.type === 'folder' && b.type !== 'folder') return -1; + if (a.type !== 'folder' && b.type === 'folder') return 1; + return a.order - b.order; + }); +} + +// 获取节点的路径(从根到当前节点的名称链) (Async) +export async function getNodePath(id) { + const nodes = await getSettingsNodes(); + const path = []; + let current = nodes.find(n => n.id === id); + while (current) { + path.unshift(current.name); + current = current.parentId ? nodes.find(n => n.id === current.parentId) : null; + } + return path; +} + +// ==================== 旧数据迁移 ==================== + +// ==================== 旧数据迁移 ==================== + +async function migrateOldSettings() { + if (typeof window === 'undefined') return null; + try { + const oldData = localStorage.getItem(SETTINGS_KEY); + if (!oldData) return null; + + const old = JSON.parse(oldData); + const nodes = getDefaultNodes(); + let hasContent = false; + + // 迁移人物设定 + if (old.characters && old.characters.length > 0) { + old.characters.forEach((char, i) => { + nodes.push({ + id: char.id || generateNodeId(), + name: char.name || '未命名角色', + type: 'item', + category: 'character', + parentId: 'root-characters', + order: i, + icon: '📄', + content: { + role: char.role || '', + age: char.age || '', + gender: char.gender || '', + appearance: char.appearance || '', + personality: char.personality || '', + background: char.background || '', + motivation: char.motivation || '', + skills: char.skills || '', + speechStyle: char.speechStyle || '', + relationships: char.relationships || '', + arc: char.arc || '', + notes: char.notes || '', + }, + collapsed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }); + hasContent = true; + } + + // 迁移世界观 + if (old.worldbuilding) { + const fieldMap = { + era: '时代背景', geography: '地理环境', society: '社会制度', + culture: '文化习俗', powerSystem: '力量体系', technology: '科技水平', + rules: '特殊规则', history: '历史大事件', factions: '势力/组织', + notes: '其他设定', + }; + let order = 0; + for (const [key, label] of Object.entries(fieldMap)) { + if (old.worldbuilding[key]) { + nodes.push({ + id: generateNodeId(), + name: label, type: 'item', category: 'world', + parentId: 'root-world', order: order++, icon: '📄', + content: { description: old.worldbuilding[key] }, + collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + hasContent = true; + } + } + } + + // 迁移大纲 + if (old.plotOutline) { + const fieldMap = { + mainConflict: '核心矛盾', plotPoints: '关键剧情节点', subplots: '支线剧情', + currentArc: '当前故事弧', foreshadowing: '已埋伏笔', ending: '结局方向', + notes: '备注', + }; + let order = 0; + for (const [key, label] of Object.entries(fieldMap)) { + if (old.plotOutline[key]) { + nodes.push({ + id: generateNodeId(), + name: label, type: 'item', category: 'plot', + parentId: 'root-plot', order: order++, icon: '📄', + content: { description: old.plotOutline[key] }, + collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + hasContent = true; + } + } + } + + // 迁移写作规则 + if (old.writingRules) { + const fieldMap = { + mustDo: '✅ 必须遵守', mustNotDo: '❌ 禁止内容', + styleGuide: '📝 风格指南', notes: '备注', + }; + let order = 0; + for (const [key, label] of Object.entries(fieldMap)) { + if (old.writingRules[key]) { + nodes.push({ + id: generateNodeId(), + name: label, type: 'item', category: 'rules', + parentId: 'root-rules', order: order++, icon: '📄', + content: { description: old.writingRules[key] }, + collapsed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + hasContent = true; + } + } + } + + if (hasContent) { + await saveSettingsNodes(nodes); + return nodes; + } + return null; + } catch { + return null; + } +} diff --git a/app/lib/snapshots.js b/app/lib/snapshots.js new file mode 100644 index 0000000..0e78e57 --- /dev/null +++ b/app/lib/snapshots.js @@ -0,0 +1,101 @@ +import { get, set } from 'idb-keyval'; +import { getChapters, saveChapters } from './storage'; +import { getSettingsNodes, saveSettingsNodes } from './settings'; + +const SNAPSHOTS_KEY = 'author-snapshots'; + +/** + * 获取所有快照 + * @returns {Promise } 快照列表(按时间倒序) + */ +export async function getSnapshots() { + try { + const snapshots = await get(SNAPSHOTS_KEY); + return Array.isArray(snapshots) ? snapshots : []; + } catch (e) { + console.error('Failed to get snapshots:', e); + return []; + } +} + +/** + * 创建新快照 + * @param {string} label - 快照标签描述 + * @param {string} type - 'auto' | 'manual' + * @returns {Promise