dsh-line-bot 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -13,11 +13,13 @@ window.__ModuleLoader__.load({
13
13
  // 二級選單:卡片區塊的快速跳轉。
14
14
  const NAV = [
15
15
  { key: 'overview', label: '狀態總覽' },
16
- { key: 'balance', label: 'DeepSeek 餘額' },
17
16
  { key: 'line', label: 'LINE 憑證' },
18
17
  { key: 'tunnel', label: 'Cloudflare' },
18
+ { key: 'model', label: '模型與來源' },
19
19
  { key: 'persona', label: '回覆人設' },
20
20
  { key: 'sync', label: '貼吧同步' },
21
+ { key: 'memory', label: '個人記憶' },
22
+ { key: 'balance', label: 'DeepSeek 餘額' },
21
23
  { key: 'nudge', label: '主動傳訊' },
22
24
  { key: 'users', label: '使用者' },
23
25
  { key: 'advanced', label: '進階/日誌' },
@@ -186,10 +188,10 @@ window.__ModuleLoader__.load({
186
188
  return h('button', { type: 'button', style, onClick: disabled ? undefined : onClick, disabled: !!disabled, title: title || '' }, children)
187
189
  }
188
190
 
189
- function Card({ id, title, desc, right, children }) {
191
+ function Card({ id, title, desc, right, children, order }) {
190
192
  return h(
191
193
  'section',
192
- { id, 'data-section': id || undefined, style: { ...S.card, scrollMarginTop: 104 } },
194
+ { id, 'data-section': id || undefined, style: { ...S.card, scrollMarginTop: 104, ...(order ? { order } : {}) } },
193
195
  h('div', { style: S.cardHead }, h('div', null, h('div', { style: S.cardTitle }, title), desc ? h('div', { style: S.cardDesc }, desc) : null), right || null),
194
196
  h('div', { style: S.cardBody }, children),
195
197
  )
@@ -250,6 +252,28 @@ window.__ModuleLoader__.load({
250
252
  )
251
253
  }
252
254
 
255
+
256
+ function UserChips({ users, value, onChange, emptyText }) {
257
+ const ids = Array.isArray(value) ? value : []
258
+ const list = Array.isArray(users) ? users : []
259
+ if (list.length === 0) {
260
+ return h('div', { style: S.note }, emptyText || '目前還沒有使用者跟 bot 聊過;有人傳訊息後就會出現在這裡。')
261
+ }
262
+ return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
263
+ list.map((user) => {
264
+ const selected = ids.includes(user.userId)
265
+ const label = (user.displayName || '未命名') + '(' + String(user.userId).slice(-6) + ')'
266
+ return h('button', {
267
+ key: user.userId,
268
+ type: 'button',
269
+ onClick: () => onChange(selected ? ids.filter((id) => id !== user.userId) : [...ids, user.userId]),
270
+ title: user.userId,
271
+ style: { ...S.btn, padding: '4px 10px', borderRadius: 999, fontSize: 12, background: selected ? 'var(--dsw-alias-state-business-primary, #4b7bec)' : 'transparent', color: selected ? '#fff' : 'var(--dsw-alias-label-primary)', border: '1px solid ' + (selected ? 'transparent' : 'var(--dsw-alias-border-l2)') },
272
+ }, label)
273
+ }),
274
+ )
275
+ }
276
+
253
277
  function Badge({ ok, children }) {
254
278
  const color = ok === true ? S.ok.color : ok === false ? S.bad.color : S.warn.color
255
279
  const dot = ok === true ? S.ok.color : ok === false ? S.bad.color : S.warn.color
@@ -292,7 +316,12 @@ window.__ModuleLoader__.load({
292
316
  'persona-status': '重新讀取人設',
293
317
  'persona-mode': '切換人設模式',
294
318
  'tieba-preview': '預覽貼吧同步差異',
319
+ 'model-catalog': '讀取模型清單',
295
320
  'balance-refresh': '查詢 DeepSeek 餘額',
321
+ 'memory-list': '讀取個人記憶',
322
+ 'memory-delete': '刪除記憶',
323
+ 'memory-prune': '清理到期記憶',
324
+ 'memory-review': '請角色整理記憶',
296
325
  'balance-ask': '餘額提醒',
297
326
  'tieba-sync': '同步貼吧學習',
298
327
  'local-persona-list': '讀取自訂人設',
@@ -317,6 +346,8 @@ window.__ModuleLoader__.load({
317
346
  const [active, setActive] = useState('overview')
318
347
  const [syncPreview, setSyncPreview] = useState(null)
319
348
  const [balancePreview, setBalancePreview] = useState(null)
349
+ const [memoryData, setMemoryData] = useState(null)
350
+ const [modelCatalog, setModelCatalog] = useState(null)
320
351
  const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
321
352
 
322
353
  const dirty = useMemo(() => {
@@ -436,6 +467,68 @@ window.__ModuleLoader__.load({
436
467
  await act('balance-ask', { dryRun: false, force: true })
437
468
  }
438
469
 
470
+ async function loadMemory({ quiet = false } = {}) {
471
+ if (!quiet) setBusy('memory-list')
472
+ try {
473
+ if (dirty && !quiet) await persist(false)
474
+ const value = await jpost('/action', { action: 'memory-list', payload: {} })
475
+ if (value?.status) {
476
+ setStatus(value.status)
477
+ setDraft(stripSecrets(value.status.config))
478
+ }
479
+ setMemoryData(value?.result || null)
480
+ } catch (err) {
481
+ if (!quiet) setToast({ kind: 'error', text: '讀取記憶失敗:' + (err.message || err) })
482
+ } finally {
483
+ if (!quiet) setBusy('')
484
+ }
485
+ }
486
+
487
+ async function removeMemory(userId, entry) {
488
+ setBusy('memory-delete')
489
+ try {
490
+ const value = await jpost('/action', { action: 'memory-delete', payload: { userId, id: entry.id } })
491
+ if (value?.status) {
492
+ setStatus(value.status)
493
+ setDraft(stripSecrets(value.status.config))
494
+ }
495
+ await loadMemory({ quiet: true })
496
+ setToast({ kind: 'ok', text: '已刪掉一筆記憶。' })
497
+ } catch (err) {
498
+ setToast({ kind: 'error', text: '刪除記憶失敗:' + (err.message || err) })
499
+ } finally {
500
+ setBusy('')
501
+ }
502
+ }
503
+
504
+ async function runMemory(action) {
505
+ await act(action)
506
+ await loadMemory({ quiet: true })
507
+ }
508
+
509
+ async function loadModelCatalog({ quiet = false, force = false } = {}) {
510
+ if (!quiet) setBusy('model-catalog')
511
+ try {
512
+ if (dirty && !quiet) await persist(false)
513
+ const value = await jpost('/action', { action: 'model-catalog', payload: force ? { force: true } : {} })
514
+ if (value?.status) {
515
+ setStatus(value.status)
516
+ setDraft(stripSecrets(value.status.config))
517
+ }
518
+ setModelCatalog(value?.result || null)
519
+ } catch (err) {
520
+ if (!quiet) setToast({ kind: 'error', text: '讀取模型清單失敗:' + (err.message || err) })
521
+ } finally {
522
+ if (!quiet) setBusy('')
523
+ }
524
+ }
525
+
526
+ useEffect(() => {
527
+ loadMemory({ quiet: true })
528
+ loadModelCatalog({ quiet: true })
529
+ // eslint-disable-next-line react-hooks/exhaustive-deps
530
+ }, [])
531
+
439
532
  async function previewSync() {
440
533
  setBusy('tieba-preview')
441
534
  setToast(null)
@@ -566,6 +659,11 @@ window.__ModuleLoader__.load({
566
659
  const balanceStatus = status.balance || {}
567
660
  const balanceSymbol = balanceStatus.cny ? '¥' : balanceStatus.usd ? '$' : '¥'
568
661
  const balanceTotal = balanceStatus.cny ? balanceStatus.cny.total : balanceStatus.usd ? balanceStatus.usd.total : null
662
+ const memoryCfg = cfg.memory || {}
663
+ const memoryStatus = status.memory || {}
664
+ const agentStatus = status.agent || {}
665
+ const modelProviders = Array.isArray(modelCatalog?.providers) ? modelCatalog.providers : []
666
+ const memoryUsers = memoryData?.users || []
569
667
  const syncStats = status.sync || {}
570
668
  const runtime = status
571
669
  const persona = status.persona || {}
@@ -625,7 +723,7 @@ window.__ModuleLoader__.load({
625
723
  // 1. 狀態總覽
626
724
  h(
627
725
  Card,
628
- { id: sectionDomId('overview'), title: '狀態總覽', desc: '綠燈=已就緒。第一次使用建議依序:填入 LINE 憑證 → 啟動 webhook → 啟動隧道。', right: h(Btn, { tone: 'primary', onClick: () => act('setup'), disabled: !!busy }, busy === 'setup' ? '上線中…' : '一鍵上線') },
726
+ { id: sectionDomId('overview'), order: 1, title: '狀態總覽', desc: '綠燈=已就緒。第一次使用建議依序:填入 LINE 憑證 → 啟動 webhook → 啟動隧道。', right: h(Btn, { tone: 'primary', onClick: () => act('setup'), disabled: !!busy }, busy === 'setup' ? '上線中…' : '一鍵上線') },
629
727
  h(
630
728
  'div',
631
729
  { style: S.row },
@@ -652,11 +750,20 @@ window.__ModuleLoader__.load({
652
750
  h(
653
751
  Card,
654
752
  {
655
- id: sectionDomId('balance'),
753
+ id: sectionDomId('balance'), order: 8,
656
754
  title: 'DeepSeek 餘額',
657
- desc: '讓角色知道 API 錢包剩多少;低於門檻時由角色自己想一句話提醒你儲值(會避開主動傳訊的靜音時段)。',
658
- right: h(Btn, { onClick: () => act('balance-refresh'), disabled: !!busy }, busy === 'balance-refresh' ? '查詢中…' : '重新查詢'),
755
+ desc: '讓角色知道 API 錢包剩多少;低於門檻時由角色自己想一句話提醒你儲值(會避開主動傳訊的靜音時段)。只有模型來源是 deepseek-account 時才適用。',
756
+ right: h(Btn, { onClick: () => act('balance-refresh'), disabled: !!busy || balanceStatus.applicable === false }, busy === 'balance-refresh' ? '查詢中…' : '重新查詢'),
659
757
  },
758
+ balanceStatus.applicable === false
759
+ ? h('div', { style: S.warnBox }, `目前模型來源是「${agentStatus.provider || '未設定'}」,不是 DeepSeek 官方帳號,所以餘額查詢與主動提醒已停用。把上面的「模型與接口來源」切回 deepseek-account 就會自動恢復。`)
760
+ : null,
761
+ h(
762
+ 'fieldset',
763
+ {
764
+ disabled: balanceStatus.applicable === false,
765
+ style: { border: 0, margin: 0, padding: 0, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12, ...(balanceStatus.applicable === false ? { opacity: 0.45 } : {}) },
766
+ },
660
767
  h(
661
768
  'div',
662
769
  { style: S.row },
@@ -684,6 +791,7 @@ window.__ModuleLoader__.load({
684
791
  ? ('查詢失敗:' + balanceStatus.error)
685
792
  : '按「重新查詢」讀取目前的 DeepSeek API 餘額。',
686
793
  ),
794
+ h(Field, { label: '餘額提醒對象', hint: '留空=沿用主動傳訊的指定對象;只會提醒這裡選的人' }, h(UserChips, { users: status.users, value: balanceCfg.targetUserIds, onChange: (ids) => update({ balance: { targetUserIds: ids } }) })),
687
795
  h(
688
796
  'div',
689
797
  { style: S.grid },
@@ -703,12 +811,60 @@ window.__ModuleLoader__.load({
703
811
  h(Btn, { tone: 'primary', onClick: askBalance, disabled: !!busy }, '立即提醒一次'),
704
812
  ),
705
813
  balancePreview && balancePreview.text ? h('div', { style: S.panelBox }, h('div', { style: S.fieldTitle }, '角色會傳這則:'), h('pre', { style: S.pre }, balancePreview.text)) : null,
814
+ ),
815
+ ),
816
+
817
+ // 1.6 個人記憶
818
+ h(
819
+ Card,
820
+ {
821
+ id: sectionDomId('memory'), order: 7,
822
+ title: '個人記憶',
823
+ desc: '每位使用者分開。角色覺得重要的事會自己記下來;有期限的到期自動刪,每天也會請角色自己整理一次。',
824
+ right: h(Btn, { onClick: () => loadMemory(), disabled: !!busy }, busy === 'memory-list' ? '讀取中…' : '重新整理'),
825
+ },
826
+ h('div', { style: S.row },
827
+ h(Badge, { ok: memoryStatus.enabled !== false }, '共 ' + (memoryStatus.total || 0) + ' 筆'),
828
+ h(Badge, { ok: memoryCfg.reviewDaily !== false }, '每日整理:' + (memoryCfg.reviewDaily !== false ? (Number(memoryCfg.reviewHour) || 0) + ' 點' : '關閉')),
829
+ h(Badge, { ok: true }, '到期自動刪'),
830
+ ),
831
+ h(KV, { rows: [
832
+ ['上次清理', memoryStatus.lastPruneAt ? fmtTime(memoryStatus.lastPruneAt) : '還沒清過'],
833
+ ['上次角色整理', memoryStatus.lastReviewAt ? fmtTime(memoryStatus.lastReviewAt) : '還沒整理過'],
834
+ ['記憶檔', memoryStatus.file || ''],
835
+ ] }),
836
+ h('div', { style: S.grid },
837
+ h(Field, { label: '每天整理時間(0–23 點)', hint: '由角色自己判斷哪幾筆該忘掉' }, h(Num, { value: memoryCfg.reviewHour, min: 0, max: 23, step: 1, onChange: (value) => update({ memory: { reviewHour: value } }) })),
838
+ h(Field, { label: '每輪最多帶入幾筆', hint: '其餘仍留在檔案裡' }, h(Num, { value: memoryCfg.injectMaxEntries, min: 1, max: 80, step: 1, onChange: (value) => update({ memory: { injectMaxEntries: value } }) })),
839
+ h(Field, { label: '每人上限(筆)', hint: '超過時刪最舊的' }, h(Num, { value: memoryCfg.maxPerUser, min: 10, max: 500, step: 10, onChange: (value) => update({ memory: { maxPerUser: value } }) })),
840
+ h(Field, { label: '帶入字數上限', hint: '避免佔用太多 prompt' }, h(Num, { value: memoryCfg.injectMaxChars, min: 200, max: 8000, step: 200, onChange: (value) => update({ memory: { injectMaxChars: value } }) })),
841
+ ),
842
+ h('div', { style: S.row },
843
+ h(Toggle, { checked: memoryCfg.enabled !== false, onChange: (value) => update({ memory: { enabled: value } }), label: '開啟個人記憶', hint: '關掉後角色不再記新的事情,也不會讀到舊的' }),
844
+ h(Toggle, { checked: memoryCfg.reviewDaily !== false, onChange: (value) => update({ memory: { reviewDaily: value } }), label: '每天請角色整理一次', hint: '清掉過期或不再成立的記憶' }),
845
+ ),
846
+ h('div', { style: S.row },
847
+ h(Btn, { onClick: () => runMemory('memory-prune'), disabled: !!busy }, busy === 'memory-prune' ? '清理中…' : '清理到期記憶'),
848
+ h(Btn, { onClick: () => runMemory('memory-review'), disabled: !!busy }, busy === 'memory-review' ? '整理中…' : '現在請角色整理'),
849
+ ),
850
+ memoryData
851
+ ? (memoryUsers.length === 0
852
+ ? h('div', { style: S.note }, '目前沒有任何記憶。角色在對話中覺得重要時,會自己記下來。')
853
+ : memoryUsers.map((row) => h('div', { key: row.userId, style: S.panelBox },
854
+ h('div', { style: S.fieldTitle }, (row.displayName || row.userId) + '(' + row.entries.length + ' 筆)'),
855
+ row.entries.map((entry) => h('div', { key: entry.id, style: { display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
856
+ h('span', { style: { flex: '0 0 auto', fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', paddingTop: 2 } }, ({ fact: '事實', event: '事件', preference: '喜好', inside: '默契' })[entry.kind] || entry.kind),
857
+ h('span', { style: { flex: 1, fontSize: 13, lineHeight: 1.5 } }, entry.text, entry.expiresText ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, '(到 ' + String(entry.expiresText).slice(0, 10) + ')') : null),
858
+ h(Btn, { tone: 'danger', onClick: () => removeMemory(row.userId, entry), disabled: !!busy }, '刪除'),
859
+ )),
860
+ )))
861
+ : h('div', { style: S.note }, '按「重新整理」讀取目前記住的內容。'),
706
862
  ),
707
863
 
708
864
  // 2. LINE 憑證
709
865
  h(
710
866
  Card,
711
- { id: sectionDomId('line'), title: 'LINE 憑證', desc: '在 LINE Developers Console 的 Messaging API channel 取得。貼上後可按「驗證」確認。' },
867
+ { id: sectionDomId('line'), order: 2, title: 'LINE 憑證', desc: '在 LINE Developers Console 的 Messaging API channel 取得。貼上後可按「驗證」確認。' },
712
868
  h(
713
869
  'div',
714
870
  { style: S.note },
@@ -754,7 +910,7 @@ window.__ModuleLoader__.load({
754
910
  // 3. Cloudflare Tunnel
755
911
  h(
756
912
  Card,
757
- { id: sectionDomId('tunnel'), title: 'Cloudflare Tunnel', desc: '把 127.0.0.1 的 webhook 公開到 https,LINE 才收得到。Quick tunnel 免 token、網址每次不同;API Token 模式可固定網域。' },
913
+ { id: sectionDomId('tunnel'), order: 3, title: 'Cloudflare Tunnel', desc: '把 127.0.0.1 的 webhook 公開到 https,LINE 才收得到。Quick tunnel 免 token、網址每次不同;API Token 模式可固定網域。' },
758
914
  h('div', { style: S.grid },
759
915
  h(Field, { label: '模式' },
760
916
  h('select', {
@@ -792,10 +948,70 @@ window.__ModuleLoader__.load({
792
948
  h('div', { style: S.hint }, 'Quick tunnel 每次重啟網址都會變;若 LINE 的 webhook 沒自動更新,按上面「把隧道網址套到 webhook」。API Token 模式需要先在 Cloudflare 建立 API Token(權限:Account → Cloudflare Tunnel → Edit;Zone → DNS → Edit)。若驗證後面板沒自動帶入帳號/Zone,代表 token 少了 Account Settings:Read/Zone:Read,請手動填 Account ID 與 Zone ID(或幫 token 補上讀取權限)。'),
793
949
  ),
794
950
 
951
+ // 3.5 模型與接口來源
952
+ h(
953
+ Card,
954
+ {
955
+ id: sectionDomId('model'),
956
+ order: 4,
957
+ title: '模型與接口來源',
958
+ desc: 'LINE 回覆要用的 provider/模型。留空=跟隨 DSH 目前的預設;選非 DeepSeek 的來源時,下面的餘額功能會自動停用。',
959
+ right: h(Btn, { onClick: () => loadModelCatalog({ force: true }), disabled: !!busy }, busy === 'model-catalog' ? '讀取中…' : '重新讀取清單'),
960
+ },
961
+ h(KV, {
962
+ rows: [
963
+ ['目前使用', `${agentStatus.provider || '—'} / ${agentStatus.model || '—'}${agentStatus.source === 'custom' ? '(面板指定)' : '(跟隨 DSH 預設)'}`],
964
+ ['DSH 預設', `${agentStatus.defaultProvider || '—'} / ${agentStatus.defaultModel || '—'}`],
965
+ ['餘額功能', agentStatus.provider === 'deepseek-account' ? '可用(DeepSeek 官方帳號)' : '已停用(不是 deepseek-account)'],
966
+ ],
967
+ }),
968
+ h('div', { style: S.grid },
969
+ h(Field, { label: '模型接口來源(provider)', hint: '清單來自這個 DSH profile 已註冊的 provider' },
970
+ h('select', {
971
+ style: S.input,
972
+ value: draft.agent.provider || '',
973
+ onChange: (event) => {
974
+ const provider = event.target.value
975
+ const entry = modelProviders.find((item) => item.id === provider)
976
+ const first = entry?.models?.[0]?.id || ''
977
+ update({ agent: { provider, model: provider ? first : '' } })
978
+ },
979
+ },
980
+ h('option', { value: '' }, `跟隨 DSH 預設${agentStatus.defaultProvider ? `(${agentStatus.defaultProvider} / ${agentStatus.defaultModel})` : ''}`),
981
+ modelProviders.map((item) => h('option', { key: item.id, value: item.id }, `${item.name}(${item.id})${item.models?.length ? '' : ' · 無模型資料'}`)),
982
+ draft.agent.provider && !modelProviders.some((item) => item.id === draft.agent.provider)
983
+ ? h('option', { value: draft.agent.provider }, draft.agent.provider)
984
+ : null,
985
+ )),
986
+ h(Field, { label: '模型', hint: '可直接輸入模型 id,或從清單選' },
987
+ h('input', {
988
+ style: S.input,
989
+ list: 'line-bot-model-options',
990
+ value: draft.agent.model || '',
991
+ placeholder: '留空=跟隨預設;例:deepseek-flash',
992
+ onChange: (event) => update({ agent: { model: event.target.value } }),
993
+ }),
994
+ h('datalist', { id: 'line-bot-model-options' },
995
+ ((modelProviders.find((item) => item.id === (draft.agent.provider || agentStatus.defaultProvider)) || {}).models || []).map((item) => h('option', { key: item.id, value: item.id }, item.name))),
996
+ ),
997
+ h(Field, { label: '推理強度(選填)', hint: '留空=跟隨 provider 預設' },
998
+ h(Text, { value: draft.agent.reasoningEffort || '', onChange: (value) => update({ agent: { reasoningEffort: value } }), placeholder: 'max / high / medium' })),
999
+ ),
1000
+ h('div', { style: S.note }, modelCatalog?.error
1001
+ ? `讀取模型清單失敗:${modelCatalog.error}`
1002
+ : modelCatalog
1003
+ ? `共 ${modelProviders.length} 個來源。清單只列已註冊的 provider;實際能不能用仍取決於該來源的憑證。`
1004
+ : '正在讀取模型清單…'),
1005
+ h('div', { style: S.row },
1006
+ h(Btn, { onClick: () => loadModelCatalog({ force: true }), disabled: !!busy }, busy === 'model-catalog' ? '讀取中…' : '重新讀取模型清單'),
1007
+ h(Btn, { onClick: () => act('agent-test', { text: '請用一句話確認你現在用的模型來源。' }), disabled: !!busy }, busy === 'agent-test' ? '測試中…' : '測試 Agent 回覆'),
1008
+ ),
1009
+ ),
1010
+
795
1011
  // 4. 人設
796
1012
  h(
797
1013
  Card,
798
- { id: sectionDomId('persona'), title: '回覆人設', desc: 'LINE 的回覆會套用這裡選的人設。來源有兩種:大肥魚插件(有安裝時)、插件自己的自訂人設(不依賴任何外掛)。' },
1014
+ { id: sectionDomId('persona'), order: 5, title: '回覆人設', desc: 'LINE 的回覆會套用這裡選的人設。來源有兩種:大肥魚插件(有安裝時)、插件自己的自訂人設(不依賴任何外掛)。' },
799
1015
  persona.warning ? h('div', { style: S.warnBox }, persona.warning) : null,
800
1016
  persona.error ? h('div', { style: S.warnBox }, `讀不到大肥魚插件:${persona.error}`) : null,
801
1017
  h('div', { style: S.grid },
@@ -856,7 +1072,7 @@ window.__ModuleLoader__.load({
856
1072
  // 4b. 貼吧學習同步
857
1073
  h(
858
1074
  Card,
859
- { id: sectionDomId('sync'), title: '貼吧學習同步', desc: '把「刷貼吧」每天演化學到的語氣樣本,寫回上方人設的「## 貼吧學來的口氣」段落,並重載大肥魚插件讓它立即生效。' },
1075
+ { id: sectionDomId('sync'), order: 6, title: '貼吧學習同步', desc: '把「刷貼吧」每天演化學到的語氣樣本,寫回上方人設的「## 貼吧學來的口氣」段落,並重載大肥魚插件讓它立即生效。' },
860
1076
  h(Toggle, { checked: syncCfg.enabled !== false, onChange: (value) => update({ sync: { enabled: value } }), label: '開啟自動同步', hint: '每小時檢查一次;貼吧人設版本有變才寫入(寫入前會備份 personas.json)' }),
861
1077
  h('div', { style: S.grid },
862
1078
  h(Field, { label: '同步到哪個人設' },
@@ -886,7 +1102,7 @@ window.__ModuleLoader__.load({
886
1102
  // 5. 主動傳訊
887
1103
  h(
888
1104
  Card,
889
- { id: sectionDomId('nudge'), title: '主動傳訊(大肥魚自己找人聊天)', desc: '由插件內建排程器執行:每隔幾分鐘擲骰子,符合條件就請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。' },
1105
+ { id: sectionDomId('nudge'), order: 9, title: '主動傳訊(大肥魚自己找人聊天)', desc: '由插件內建排程器執行:每隔幾分鐘擲骰子,符合條件就請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。' },
890
1106
  h(Toggle, { checked: !!nudgeCfg.enabled, onChange: (value) => update({ nudge: { enabled: value } }), label: '開啟主動傳訊' }),
891
1107
  h('div', { style: S.grid },
892
1108
  h(Field, { label: '每次擲骰機率(0~1)', hint: '例:0.18 = 每輪 18% 機率開口' }, h(Num, { value: nudgeCfg.chance, min: 0, max: 1, step: 0.01, onChange: (value) => update({ nudge: { chance: value } }) })),
@@ -898,7 +1114,7 @@ window.__ModuleLoader__.load({
898
1114
  h(Field, { label: '安靜開始(HH:MM)' }, h(Text, { value: nudgeCfg.quietStart, onChange: (value) => update({ nudge: { quietStart: value } }), placeholder: '00:30' })),
899
1115
  h(Field, { label: '安靜結束(HH:MM)' }, h(Text, { value: nudgeCfg.quietEnd, onChange: (value) => update({ nudge: { quietEnd: value } }), placeholder: '09:30' })),
900
1116
  ),
901
- h(Field, { label: '指定對象(LINE userId,一行一個)', hint: '留空=所有已聊過的使用者中挑最久沒互動的' }, h(Area, { value: textList(nudgeCfg.targetUserIds), onChange: (value) => update({ nudge: { targetUserIds: parseList(value) } }), rows: 2, mono: true })),
1117
+ h(Field, { label: '指定對象', hint: '留空=所有已聊過的使用者中挑最久沒互動的;直接點選要傳給誰' }, h(UserChips, { users: status.users, value: nudgeCfg.targetUserIds, onChange: (ids) => update({ nudge: { targetUserIds: ids } }) })),
902
1118
  h(Field, { label: '主動傳訊提示詞(給 Agent 的指示)', hint: '留空=用內建預設(自然、簡短、不提系統、不重複、不適合就 SKIP)' }, h(Area, { value: nudgeCfg.prompt, onChange: (value) => update({ nudge: { prompt: value } }), rows: 4 })),
903
1119
  h('div', { style: S.row },
904
1120
  h(Btn, { onClick: () => act('nudge-test', { force: true }), disabled: !!busy, tone: 'primary' }, busy === 'nudge-test' ? '試跑中…' : '立即試傳一則'),
@@ -919,7 +1135,7 @@ window.__ModuleLoader__.load({
919
1135
  // 6. 使用者
920
1136
  h(
921
1137
  Card,
922
- { id: sectionDomId('users'), title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1138
+ { id: sectionDomId('users'), order: 10, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
923
1139
  (status.users || []).length === 0
924
1140
  ? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
925
1141
  : h('table', { style: S.table },
@@ -955,7 +1171,7 @@ window.__ModuleLoader__.load({
955
1171
  // 7. 進階設定
956
1172
  h(
957
1173
  Card,
958
- { id: sectionDomId('advanced'), title: '進階設定', desc: '通常不用改;出問題時可從這裡調整。' },
1174
+ { id: sectionDomId('advanced'), order: 11, title: '進階設定', desc: '通常不用改;出問題時可從這裡調整。' },
959
1175
  h('div', { style: S.grid },
960
1176
  h(Field, { label: 'Webhook 連接埠', hint: '改完按儲存;伺服器會自動重啟。' }, h(Num, { value: server.port, min: 1024, max: 65535, onChange: (value) => update({ server: { port: value } }) })),
961
1177
  h(Field, { label: '每位使用者獨立工作資料夾', hint: '開啟=每個 LINE 使用者有自己的 cwd(檔案、AGENTS.md、工作區記憶互不相通)' },
@@ -80,7 +80,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
80
80
 
81
81
  // 把「插件自己的人設」與「貼吧動態」掛進這個 agent 的 system prompt。
82
82
  // 自訂人設只有選了才注入;大肥魚的人設走它自己的 session 指定。
83
- function agentSetup({ personaText, contextText }) {
83
+ function agentSetup({ personaText, contextText, tools = [] }) {
84
84
  return (agentCtx) => {
85
85
  try {
86
86
  const prompt = agentCtx?.systemPrompt
@@ -120,6 +120,17 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
120
120
  } catch (error) {
121
121
  logger?.warn?.(`注入人設/貼吧動態失敗:${error?.message || error}`)
122
122
  }
123
+ if (Array.isArray(tools) && tools.length > 0) {
124
+ try {
125
+ const registry = agentCtx.get?.('tools') || agentCtx.tools
126
+ if (!registry?.register) throw new Error('這個 profile 沒有 tools 服務')
127
+ for (const spec of tools) {
128
+ agentCtx.effect(() => registry.register(spec), `line-bot: tool ${spec.name}`)
129
+ }
130
+ } catch (error) {
131
+ logger?.warn?.(`掛載記憶工具失敗:${error?.message || error}`)
132
+ }
133
+ }
123
134
  }
124
135
  }
125
136
 
@@ -161,7 +172,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
161
172
  }
162
173
  }
163
174
 
164
- async function turn({ sessionId, text, persona, personaClient, personaText, contextText, cwd: cwdOverride }) {
175
+ async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], cwd: cwdOverride }) {
165
176
  const cfg = getConfig()
166
177
  const timeoutMs = cfg.agent.timeoutMs || 180000
167
178
  const { options } = agentOptions()
@@ -175,7 +186,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
175
186
  let offEnd = null
176
187
 
177
188
  try {
178
- const setup = personaText || contextText ? agentSetup({ personaText, contextText }) : undefined
189
+ const setup = personaText || contextText || tools.length > 0 ? agentSetup({ personaText, contextText, tools }) : undefined
179
190
  const opened = await openAgent(sessionId, options, cwd, setup)
180
191
  agent = opened.agent
181
192
  handle = opened.handle
package/lib/config.mjs CHANGED
@@ -99,6 +99,17 @@ export const DEFAULT_CONFIG = {
99
99
  threshold: 10,
100
100
  ask: true,
101
101
  cooldownHours: 12,
102
+ targetUserIds: [],
103
+ },
104
+ memory: {
105
+ // 每位使用者各自的個人記憶:agent 覺得重要就自己記,有期限的到期自動刪。
106
+ enabled: true,
107
+ maxPerUser: 120,
108
+ injectMaxEntries: 20,
109
+ injectMaxChars: 2000,
110
+ // 背景每天檢查一次,請角色自己把過期/失效的記憶刪掉。
111
+ reviewDaily: true,
112
+ reviewHour: 4,
102
113
  },
103
114
  log: {
104
115
  maxLines: 400,
@@ -211,6 +222,15 @@ export function sanitizePatch(current, patch) {
211
222
  balance.ask = Boolean(balance.ask)
212
223
  balance.threshold = clampFloat(balance.threshold, 0, 100000, 10)
213
224
  balance.cooldownHours = clampInt(balance.cooldownHours, 1, 168, 12)
225
+ balance.targetUserIds = safeArray(balance.targetUserIds)
226
+
227
+ const memory = next.memory || (next.memory = {})
228
+ memory.enabled = Boolean(memory.enabled)
229
+ memory.reviewDaily = Boolean(memory.reviewDaily)
230
+ memory.maxPerUser = clampInt(memory.maxPerUser, 10, 500, 120)
231
+ memory.injectMaxEntries = clampInt(memory.injectMaxEntries, 1, 80, 20)
232
+ memory.injectMaxChars = clampInt(memory.injectMaxChars, 200, 8000, 2000)
233
+ memory.reviewHour = clampInt(memory.reviewHour, 0, 23, 4)
214
234
 
215
235
  const nudge = next.nudge
216
236
  nudge.enabled = Boolean(nudge.enabled)
@@ -291,6 +311,7 @@ export function normalizeState(raw) {
291
311
  users: isPlainObject(state.users) ? state.users : {},
292
312
  nudge: isPlainObject(state.nudge) ? state.nudge : {},
293
313
  balance: isPlainObject(state.balance) ? state.balance : {},
314
+ memory: isPlainObject(state.memory) ? state.memory : {},
294
315
  sync: isPlainObject(state.sync) ? state.sync : {},
295
316
  webhook: isPlainObject(state.webhook) ? state.webhook : null,
296
317
  lastEventAt: Number(state.lastEventAt) || 0,