// data.jsx — Popor 示例笔记数据(中英混排) // 字段:id, title, en, date(制作), imported(导入), year, month, city, scene, // gps, notebook, cat(主分类·紫), notes(内容·绿), locs(地点·黄), // preview, body(markdown), hasPhoto // ============================================================ // 数据来源:从后端 API 实时获取(http://localhost:8002) // 如需改地址,在页面里设 window.POPOR_API_BASE 即可。 // 后端不可达时自动回落到下方的本地示例数据(FALLBACK_NOTES)。 // ============================================================ const POPOR_API_BASE = (typeof window !== 'undefined' && window.POPOR_API_BASE) || 'http://localhost:8002'; // —— 本子样式:按主分类(category)给深色书脊 + 图标 + 描述 —— // 本子通过 category 关联笔记:note.notebook === note.cat(即 category) const CATEGORY_STYLE = { '读书': { bg: '#2C1A0E', fg: '#F5E6CC', icon: 'catBook', desc: '把读过的句子收进来' }, '学习': { bg: '#0E2A1E', fg: '#C8F0D8', icon: 'book', desc: '学到的、想通的' }, '音乐': { bg: '#1E0A2E', fg: '#E8C8FF', icon: 'catMusic', desc: '一首歌停下的瞬间' }, '旅行': { bg: '#15263F', fg: '#C8DDF0', icon: 'catTravel',desc: '走过的地方与遇见' }, '美食': { bg: '#2E1A0A', fg: '#F0D8B0', icon: 'coffee', desc: '吃过的好东西' }, '创意': { bg: '#2A0E22', fg: '#F5C8E0', icon: 'spark', desc: '一闪而过的念头' }, '生活': { bg: '#2E1212', fg: '#F5D0C0', icon: 'catLife', desc: '日子里的细节' }, '工作': { bg: '#14162E', fg: '#C8C8F0', icon: 'layers', desc: '手头在做的事' }, // —— 示例数据可能用到的额外分类 —— '电影': { bg: '#0D1B2A', fg: '#C8DDF0', icon: 'catFilm', desc: '黑暗里亮起的光' }, '运动': { bg: '#0E2A1E', fg: '#C8F0D8', icon: 'catRun', desc: '身体丈量过的路' }, '健康': { bg: '#2E0A0A', fg: '#F5D0C0', icon: 'catHealth',desc: '身体写下的日记' }, '账本': { bg: '#2a2a0a', fg: '#EEE8C0', icon: 'catMoney', desc: '钱去了哪里' }, }; // 始终展示的基础本子(后端主分类枚举) const NOTEBOOK_BASE = ['读书', '学习', '音乐', '旅行', '美食', '创意', '生活', '工作']; function notebookStyle(cat) { return CATEGORY_STYLE[cat] || { bg: '#222428', fg: '#E6E1D6', icon: 'book', desc: '' }; } // 由笔记动态生成本子列表:基础本子 + 数据里出现的其它分类 function buildNotebooks(notes) { const cats = NOTEBOOK_BASE.slice(); (notes || []).forEach(n => { if (n.cat && cats.indexOf(n.cat) < 0) cats.push(n.cat); }); return cats.map(c => { const s = notebookStyle(c); return { id: c, name: c, desc: s.desc, bg: s.bg, fg: s.fg, lineIcon: s.icon }; }); } // —— 字段格式化(API → 原型字段)—— function dotDate(s) { return s ? String(s).slice(0, 10).replace(/-/g, '.') : ''; } // 'YYYY-MM-DD' → 'YYYY.MM.DD' function yearOf(s) { const d = String(s || '').slice(0, 4); return /^\d{4}$/.test(d) ? d : ''; } function monthOf(s) { const n = parseInt(String(s || '').slice(5, 7), 10); return n ? n + '月' : ''; } function timeOf(ts) { const m = String(ts || '').match(/T(\d{2}:\d{2})/); return m ? m[1] : ''; } // ISO → 'HH:MM' function stripMd(t) { return String(t || '') .replace(/```[\s\S]*?```/g, ' ') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/^[ \t]*\|?[\s:|-]*-{2,}[\s:|-]*\|?[ \t]*$/gm, ' ') // 表格分隔行 / 水平线 .replace(/^[ \t]*[-*+]\s+/gm, '') // 列表符号 .replace(/[#>*`|_~]/g, ' ') .replace(/\s+/g, ' ') .trim(); } function makePreview(t) { const s = stripMd(t); return s.length > 72 ? s.slice(0, 72) + '…' : s; } // 把后端笔记对象转成原型组件需要的形状(字段映射见对接文档) function transformNote(n) { const time = n.time || {}; const media = n.media || {}; const cdate = time.content_date || n.date || time.import_date || ''; const cat = n.category || '未分类'; const tags = Array.isArray(n.tags) ? n.tags : []; const loc = (time.content_location || '').trim(); return { id: n.id, title: n.title || '未命名', en: '', // 真实数据无英文副名 date: dotDate(cdate), time: timeOf(time.import_ts || n.ts) || '12:00', imported: dotDate(time.import_date || n.date), year: yearOf(cdate), month: monthOf(cdate), city: loc || '未标注', scene: (time.content_scene || '').trim() || '未记录', gps: '', // 后续从 time.geo 取 notebook: cat, // 本子 = 主分类 cat: cat, notes: tags, // 内容标签(绿) locs: loc ? [loc] : [], // 地点标签(黄) preview: makePreview(n.text), body: n.text || '', // 完整 Markdown hasPhoto: !!media.original_image, img: media.original_image ? (POPOR_API_BASE + media.original_image) : '', // 原始字段,留给墨吞后续接入 mood: n.mood || '', mokeyQuestion: n.mokey_question || '', mokeyDialogue: Array.isArray(n.mokey_dialogue) ? n.mokey_dialogue : [], }; } // —— 本地示例数据:后端不可达时的回落(保证离线/预览仍可渲染)—— const FALLBACK_NOTES = [ { id: 'n1', title: '京都的雨', en: 'Rain over Kyoto', date: '2021.10.14', time: '16:00', imported: '2021.10.16', year: '2021', month: '10月', city: '京都', scene: '旅途中', gps: '35.00°N 135.77°E', notebook: 'read', cat: '旅行', notes: ['寺院', '侘寂'], locs: ['京都'], hasPhoto: true, preview: '在龙安寺的廊下坐了一个下午。雨落在石庭上,十五块石头,从任何角度都只能看见十四块。', body: `# 龙安寺 · 石庭 在廊下坐了一个下午。雨一直落,落在白砂耙出的水波纹上。 > 十五块石头,从任何一个角度,都只能看见十四块。 > 缺的那一块,留给观者自己补。 这就是 *wabi-sabi* 吧——不是完整,而是**承认不完整**。 旅途读到 Leonard Koren 的话: \`\`\` Wabi-sabi is the beauty of things imperfect, impermanent, and incomplete. \`\`\` 雨停的时候,砂上的纹路已经被打乱了。没有人去重新耙。`, }, { id: 'n2', title: '深夜的爵士', en: 'Midnight Jazz', date: '2026.05.22', time: '01:10', imported: '2026.05.22', year: '2026', month: '5月', city: '上海', scene: '家', gps: '31.23°N 121.47°E', notebook: 'music', cat: '音乐', notes: ['爵士', '深夜'], locs: ['上海'], hasPhoto: false, preview: '反复听 Bill Evans 的 Peace Piece。一个和弦按住不放,右手在上面慢慢游走,像在试探黑暗的形状。', body: `# Peace Piece — Bill Evans 凌晨一点,反复听这一首。 左手一个和弦按住不放,像一片不动的水面。右手在上面慢慢游走,*试探黑暗的形状*。 | 时间 | 在做的事 | |---|---| | 00:00 | 主题,几乎不动 | | 02:30 | 开始有不安 | | 06:00 | 又回到最初 | > 最好的音乐,是让你愿意一直待在不解决的和弦里。`, }, { id: 'n3', title: '三月账单', en: 'March Ledger', date: '2026.03.31', time: '22:00', imported: '2026.03.31', year: '2026', month: '3月', city: '上海', scene: '家', gps: '31.23°N 121.47°E', notebook: 'ledger', cat: '生活', notes: ['复盘', '克制'], locs: ['上海'], hasPhoto: false, preview: '这个月花在书上的钱,第一次超过了外卖。记下来不是为了省,是想看见自己把时间和钱给了什么。', body: `# 三月 · 复盘 记下来不是为了省,是想**看见自己把钱给了什么**。 - 书 ¥486 - 外卖 ¥412 - 唱片 ¥238 - 一束花 ¥68 > 这个月花在书上的钱,第一次超过了外卖。 > 一个微小的、自己跟自己的胜利。 剩下的不写了。账本最怕的是变成审判。`, }, { id: 'n4', title: '晨跑七公里', en: 'Seven Kilometres at Dawn', date: '2026.05.30', time: '05:30', imported: '2026.05.30', year: '2026', month: '5月', city: '上海', scene: '户外', gps: '31.24°N 121.50°E', notebook: 'sport', cat: '运动', notes: ['晨跑', '身体'], locs: ['上海', '滨江'], hasPhoto: true, preview: '五点半出门,江面还没醒。跑到第五公里的时候,那个一直在脑子里吵的念头,忽然就安静了。', body: `# 滨江 · 晨跑 五点半出门,江面还没醒,雾贴着水。 跑到第五公里,那个一直在脑子里吵的念头,*忽然就安静了*。 \`\`\` 距离 7.2 km 配速 5'48" 心率 avg 152 \`\`\` > 身体先于头脑想明白了一件事: > 有些问题不是用想的,是用跑的。`, }, { id: 'n5', title: '小津的留白', en: 'Ozu’s Empty Frames', date: '2024.11.08', time: '15:20', imported: '2024.11.09', year: '2024', month: '11月', city: '杭州', scene: '咖啡馆', gps: '30.27°N 120.16°E', notebook: 'film', cat: '电影', notes: ['小津', '留白'], locs: ['杭州'], hasPhoto: false, preview: '重看《东京物语》。最打动我的不是对话,是那些没有人的空镜——走廊、晾着的衣服、一列开过的火车。', body: `# 东京物语 · 空镜 最打动我的不是对话,是那些**没有人的空镜**。 走廊、晾着的衣服、一列开过的火车。 > 小津把摄影机放得很低,像一个跪坐在榻榻米上的人。 > 镜头不追人,人走出画框,画框还在那里多停三秒。 那三秒,是留给观众呼吸的。也是留给逝去的时间的。`, }, { id: 'n6', title: '一杯手冲', en: 'A Cup, Pour-over', date: '2024.07.19', time: '10:00', imported: '2024.07.19', year: '2024', month: '7月', city: '杭州', scene: '图书馆', gps: '30.26°N 120.13°E', notebook: 'health', cat: '生活', notes: ['咖啡', '专注'], locs: ['杭州'], hasPhoto: false, preview: '学着自己冲。水温、粉量、时间,三个变量反复调。失败很多次后明白,所谓手艺就是和不确定性长期相处。', body: `# 手冲 · 笔记 水温、粉量、时间,三个变量反复调。 | 参数 | 这次 | |---|---| | 水温 | 92℃ | | 粉水比 | 1:15 | | 总时长 | 2'10" | > 失败很多次后明白,所谓手艺, > 就是和不确定性长期相处。`, }, { id: 'n7', title: '奈良的鹿', en: 'The Deer of Nara', date: '2021.04.03', time: '11:30', imported: '2021.04.05', year: '2021', month: '4月', city: '奈良', scene: '旅途中', gps: '34.68°N 135.84°E', notebook: 'read', cat: '旅行', notes: ['奈良', '相遇'], locs: ['奈良'], hasPhoto: true, preview: '鹿不怕人,也不亲人。它们只是恰好和你在同一片草地上,各自低头吃草。这种距离,刚刚好。', body: `# 奈良 · 鹿 鹿不怕人,也不亲人。 它们只是恰好和你在同一片草地上,各自低头吃草。 > 这种距离,刚刚好。 > 不索取,也不躲闪。 回来后一直在想,人和人之间,能不能也这样。`, }, { id: 'n8', title: '坂本龙一的最后', en: 'Sakamoto, the Last', date: '2026.05.11', time: '23:40', imported: '2026.05.11', year: '2026', month: '5月', city: '上海', scene: '家', gps: '31.23°N 121.47°E', notebook: 'music', cat: '音乐', notes: ['坂本龙一', '告别'], locs: ['上海'], hasPhoto: false, preview: '听 12。每一个音都像是用尽了力气才按下去的。呼吸声留在录音里,没有剪掉。那也是音乐的一部分。', body: `# 12 — 坂本龙一 每一个音,都像用尽了力气才按下去。 呼吸声留在录音里,没有剪掉。 > 那也是音乐的一部分。 > 一个人快走到尽头时,连喘息都成了旋律。 *Ars longa, vita brevis.*`, }, { id: 'n9', title: '出差的清晨', en: 'A Working Dawn', date: '2026.05.26', time: '17:00', imported: '2026.05.26', year: '2026', month: '5月', city: '深圳', scene: '旅途中', gps: '22.54°N 114.06°E', notebook: 'sport', cat: '生活', notes: ['出差', '城市'], locs: ['深圳', '福田'], hasPhoto: false, preview: '福田的傍晚,写字楼一格一格亮起来。出差的人没有黄昏,只有从一个会议室到下一个会议室的间隙。', body: `# 深圳 · 福田 傍晚五点,写字楼一格一格亮起来。 出差的人没有黄昏,只有从一个会议室,到下一个会议室的*间隙*。 > 在别人的城市里,时间是借来的。 > 借来的时间,要还得格外用力。`, }, { id: 'n10', title: '静安寺的银杏', en: 'Ginkgo at Jing’an', date: '2026.05.27', time: '11:30', imported: '2026.05.27', year: '2026', month: '5月', city: '上海', scene: '旅途中', gps: '31.22°N 121.45°E', notebook: 'read', cat: '旅行', notes: ['银杏', '城市'], locs: ['上海', '静安'], hasPhoto: true, preview: '从深圳飞回,落地就去了静安寺。五月的银杏还是绿的,但风里已经有了要黄的预感。', body: `# 上海 · 静安 从深圳飞回,落地就去了静安寺。 五月的银杏还是绿的,但风里已经有了*要黄的预感*。 > 金顶在阴天里也亮着。 > 城市再快,总要有一处地方,是慢的。`, }, ]; // —— 城市 = 地铁线路色 —— // const CITY_LINE = { '奈良': '#7A8C4A', // 橄榄 · 奈良线 '京都': '#B8503A', // 朱砂 · 京都线 '杭州': '#4A6B8A', // 青灰 · 杭州线 '上海': '#2C6E63', // 深青 · 上海线(海水) '深圳': '#9A5B9C', // 紫 · 深圳线 }; // 城市经纬度(数字地图用) const CITY_GEO = { '奈良': { lat: 34.68, lon: 135.83 }, '京都': { lat: 35.01, lon: 135.77 }, '上海': { lat: 31.23, lon: 121.47 }, '杭州': { lat: 30.27, lon: 120.16 }, '深圳': { lat: 22.54, lon: 114.06 }, }; // —— 把笔记按时间排成时空站点(地铁式行程)—— // function buildStations() { const list = (typeof window !== 'undefined' && window.NOTES) ? window.NOTES : []; const toNum = n => parseInt(String(n.date || '').replace(/\./g, '') + String(n.time || '').replace(':', ''), 10) || 0; return [...list].sort((a, b) => toNum(a) - toNum(b)).map((n, i, arr) => ({ note: n, line: CITY_LINE[n.city] || '#666', transfer: i > 0 && arr[i - 1].city !== n.city, // 换乘(跨城) })); } // —— 确定性哈希(演示用·非密码学)—— // function poporHash(seed) { let h1 = 0x811c9dc5, h2 = 0x1000193; const s = String(seed); for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0; h2 = Math.imul(h2 + c + i, 0x85ebca6b) >>> 0; } const hex = (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')); return (hex + hex.split('').reverse().join('')).slice(0, 40); } // —— 现在:时空轴的主轴锚点 —— // const NOW = { date: '2026.06.01', time: '11:15', city: '上海', scene: '家', label: '现在' }; // ============================================================ // 墨吞 · 书写时的对话式助手(可提问、可找资料,资料带来源 index) // 演示用:确定性脚本,不接真实 API // ============================================================ // 每则笔记的可被引用资料库(论文式来源) const MOTUN_SOURCES = { n1: [ { title: 'Wabi-Sabi: for Artists, Designers, Poets & Philosophers', author: 'Leonard Koren', year: 1994, kind: '专著', snippet: 'Wabi-sabi is the beauty of things imperfect, impermanent, and incomplete.' }, { title: '龙安寺石庭の十五石配置に関する考察', author: 'van Tonder, G. J. 等', year: 2002, kind: '论文 · Nature', snippet: '十五块石的布局,使任一视点恰好被遮去一块——缺,被设计成可见的。' }, { title: '阴翳礼赞', author: '谷崎润一郎', year: 1933, kind: '随笔', snippet: '美,不在物之本身,而在物与物投下的阴翳的浓淡之间。' }, ], n5: [ { title: '小津安二郎の映画美学:ローポジションと空ショット', author: 'David Bordwell', year: 1988, kind: '论文', snippet: '空镜并非过场,而是让叙事呼吸的"枕镜"(pillow shot)。' }, { title: '东京物语 · 分镜笔记', author: '松竹株式会社', year: 1953, kind: '资料', snippet: '人物走出画框后,镜头停留约三秒,容纳逝去的时间。' }, ], n2: [ { title: 'Peace Piece 和声分析', author: 'Jazz Theory Archive', year: 2011, kind: '乐谱注', snippet: '左手 C–G 持续音上,右手在不解决的属功能里游移。' }, { title: 'Bill Evans: How My Heart Sings', author: 'Peter Pettinger', year: 1998, kind: '传记', snippet: '埃文斯把钢琴当作"会呼吸的乐器",让和弦悬而不落。' }, ], n8: [ { title: '坂本龙一《12》创作访谈', author: 'NHK', year: 2023, kind: '访谈', snippet: '把每天的身体状态录成声音日记,呼吸也是乐句的一部分。' }, { title: 'Ars longa, vita brevis 词源考', author: 'Hippocrates / Seneca', year: '—', kind: '辞条', snippet: '艺术长存,人生短暂——原指医术须穷尽一生方得。' }, ], }; const MOTUN_SOURCES_FALLBACK = [ { title: '与本页主题相关的公开资料', author: '网络检索', year: 2025, kind: '资料', snippet: '墨吞为你检索到的一段可引用内容,点开可查看完整出处。' }, { title: '同主题的延伸阅读', author: '编辑整理', year: 2024, kind: '资料', snippet: '若需要,我可以继续按时间或作者帮你筛。' }, ]; function motunSources(note) { return (note && MOTUN_SOURCES[note.id]) || MOTUN_SOURCES_FALLBACK; } // ============================================================ // 墨吞 · 牵线(共创):在他人的笔记里,用向量检索找能补这一页的人 // 多人 + 多 AI —— 墨吞这里只做"建立联系",引用时带对方 ID 与来源 // 演示用:确定性匹配,不接真实 faiss // ============================================================ // 他人笔记的语境库(每个人 = 一种职业 / 地点 / 经历 / 爱好的语境) const PEOPLE = [ { id: '@lakeside_zhou', name: '周屿', prof: '风光摄影师', av: '#2C6E63', now: '杭州', been: ['西湖', '京都', '奈良'], hobby: ['慢门', '黑白胶片'], note: '雾里的西湖', offer: '可现拍 · 有旧照', snippet: '等了四个清晨,才等到苏堤完全没有人的那一分钟。雾把对岸的雷峰塔擦掉了一半。', kw: ['西湖', '杭州', '京都', '奈良', '摄影', '照片', '旅行', '雾', '留白', '侘寂', '相遇'] }, { id: '@wabi_keng', name: '耿默', prof: '古建与庭园研究者', av: '#7A5A2A', now: '奈良', been: ['京都', '西湖', '苏州'], hobby: ['枯山水', '拓印'], note: '十四块石头', offer: '语境补全', snippet: '龙安寺缺的那一块,不是失误,是工匠请你走进来,用自己补满它。', kw: ['侘寺', '侘寂', '寺院', '京都', '奈良', '西湖', '留白', '庭园', '旅行', '不完整'] }, { id: '@evans_fan', name: '苏黎', prof: '爵士钢琴手', av: '#5B3A6E', now: '上海', been: ['纽约', '东京'], hobby: ['即兴', '黑胶'], note: '不解决的和弦', offer: '语境补全', snippet: 'Peace Piece 的左手,是一个人决定不往前走——把不安按住,听它自己慢慢化开。', kw: ['爵士', '音乐', '深夜', '钢琴', 'Bill Evans', '坂本龙一', '和弦', '告别', '即兴'] }, { id: '@ozu_low', name: '川岛', prof: '纪录片导演', av: '#3A4A6B', now: '京都', been: ['东京', '杭州'], hobby: ['空镜', '低机位'], note: '低机位的耐心', offer: '可现拍 · 语境', snippet: '把摄影机放到一个跪坐者的高度,人走出画框之后,那三秒就有了重量。', kw: ['小津', '电影', '留白', '空镜', '东京', '杭州', '镜头', '耐心', '城市'] }, { id: '@dr_lin', name: '林述', prof: '内科医生', av: '#8B2020', now: '上海', been: ['滨江'], hobby: ['晨跑', '古典乐'], note: '值夜班的呼吸', offer: '专业视角', snippet: '心率掉到 152 以下那一刻,身体其实比仪器先一步知道——它在替你做决定。', kw: ['身体', '晨跑', '健康', '心率', '呼吸', '滨江', '上海', '运动', '坂本龙一', '克制'] }, { id: '@pourover_an', name: '安和', prof: '咖啡师', av: '#6B4A2A', now: '杭州', been: ['图书馆'], hobby: ['手冲', '烘焙'], note: '三个变量', offer: '语境补全', snippet: '水温、粉量、时间——失败到第七次才学会:手艺就是和不确定性长期相处。', kw: ['咖啡', '手冲', '专注', '杭州', '图书馆', '手艺', '不确定', '生活', '变量'] }, { id: '@ledger_he', name: '何拾', prof: '独立财务顾问', av: '#5A5A2A', now: '上海', been: [], hobby: ['复盘', '极简'], note: '月底的诚实', offer: '专业视角', snippet: '账本最怕变成审判。我把它写成一封给自己的信——只记,不判。', kw: ['账本', '复盘', '克制', '钱', '生活', '上海', '极简', '诚实'] }, { id: '@ginkgo_x', name: '夏行', prof: '城市行走作家', av: '#7A8C4A', now: '上海', been: ['静安', '福田', '深圳', '杭州'], hobby: ['city walk', '银杏'], note: '借来的黄昏', offer: '可现拍 · 旧照', snippet: '出差的人没有黄昏,只有从一个会议室到下一个会议室的缝隙。城市是借来的。', kw: ['城市', '出差', '银杏', '静安', '深圳', '福田', '上海', '黄昏', '旅行', '相遇'] }, ]; // 一个人能提供哪种帮助:现成的(写过 · 过去)/ 实时的(能现在去做)/ 专业 function coIsLive(p) { return /拍|现拍|到场/.test(p.offer); } function coIsPro(p) { return /专业/.test(p.offer) || ['内科医生','独立财务顾问','古建与庭园研究者','纪录片导演','风光摄影师'].includes(p.prof); } // 墨吞 · 判断这一页缺什么,并把"找人帮助"结构成两条路 // 这是墨吞常用的问题结构:① 现成的(有人写过 · 过去)② 实时的(需要现在有人去做) function motunCoJudge(note) { const place = (note && note.city) || '某地'; const topic = (note && note.notes && note.notes[0]) || '这一页'; const hay = note ? [note.title, note.cat, ...(note.notes || []), ...(note.locs || []), note.preview || '', note.body || ''].join(' ') : ''; let missing, proRole = null, liveLine; if (/照片|拍|影像|实景|风光|风景|画面|看见|一幕/.test(hay) || ['旅行', '电影'].includes(note && note.cat)) { missing = `一张「${place}」的实景画面`; proRole = '摄影师'; liveLine = `这一页缺的是现场——也许该请一位此刻在「${place}」、能去拍的人。`; } else if (/身体|健康|心率|呼吸|晨跑|运动|值夜/.test(hay)) { missing = '一个专业的身体判断'; proRole = '医生'; liveLine = '这里牵涉身体与健康——建议让一位医生来把关,而不是只凭感觉。'; } else if (/账|钱|财务|复盘|预算|开销/.test(hay)) { missing = '一份更冷静的财务视角'; proRole = '财务顾问'; liveLine = '钱的事容易当局者迷——可以请一位财务顾问替你看一眼。'; } else if (/爵士|音乐|和弦|谱|乐/.test(hay)) { missing = `「${topic}」更深的那层语境`; proRole = null; liveLine = `这层意思也许有人正经历着——可以请此刻懂「${topic}」的人来补。`; } else { missing = `「${topic}」缺的那一块`; proRole = null; liveLine = `如果需要现在有人去做点什么,也可以从这里约。`; } return { missing, proRole, pastLine: `这类内容,很可能已经有人写过了——我帮你找几则带出处的,引用时会自动署上来源。`, liveLine, place, topic, }; } // 检索维度:按模式(past 现成 / live 实时)给"选择要找什么" function coDimensions(note, mode) { const place = (note && note.city) || '某地'; const topic = (note && note.notes && note.notes[0]) || '这一页'; if (mode === 'live') { return [ { id: 'prof', icon: 'users', label: '按职业找', hint: '摄影师 / 医生 / 乐手…', q: 'pro:' }, { id: 'hobby', icon: 'heart', label: '按爱好找', hint: '同好 · 懂这件事的人', q: topic }, { id: 'exp', icon: 'quote', label: '相似经历的人', hint: '走过同一段路的人', q: note ? note.cat : '' }, { id: 'here', icon: 'camera', label: `在「${place}」拍一张`, hint: '请现场的人替你拍照', q: 'now:' + place }, ]; } return [ { id: 'prof', icon: 'users', label: '按职业找人', hint: '医生 / 摄影师 / 乐手…', q: '' }, { id: 'topic', icon: 'spark', label: `懂「${topic}」`, hint: '写过这层语境的人', q: topic }, { id: 'exp', icon: 'quote', label: '相似经历的人', hint: '走过同一段路的人', q: note ? note.cat : '' }, ]; } // faiss 风格匹配:把笔记 + 查询拼成向量,按 kw 命中算相关度 // rawQuery 前缀:now:城市(此刻在)/ been:城市(去过)/ pro:(找专业人士)/ live(仅能现场的人) function faissMatch(note, rawQuery, onlyLive) { const q = String(rawQuery || ''); let placeFilter = null, mode = null; let qm = q.match(/^(now|been):(.+)$/); if (qm) { mode = qm[1]; placeFilter = qm[2]; } const proMode = /^pro:/.test(q); // 话题/职业检索:只看主题 + 内容标签(不掺城市/地点,免得同城的人被无脑抬高) // 地点检索:由 placeFilter 单独加权 const free = (mode || proMode) ? [] : q.split(/[\s,,、]+/).filter(Boolean); const ctx = (note && !mode && !proMode) ? [note.cat, ...(note.notes || [])] : []; const tokens = free.concat(ctx); const scored = PEOPLE.map(p => { if (note && p.note === note.title) return null; // 不匹配自己这页 if (mode === 'now' && p.now !== placeFilter) return null; // 此刻在某地 if (mode === 'been' && p.now !== placeFilter && !p.been.includes(placeFilter)) return null; if (proMode && !coIsPro(p)) return null; // 仅专业人士 if (onlyLive && !(coIsLive(p) || coIsPro(p))) return null; // 实时模式:能现场 / 专业 const hay = [p.prof, p.now, ...p.been, ...p.hobby, ...p.kw]; let hits = 0; tokens.forEach(t => { if (hay.some(h => h.includes(t) || t.includes(h))) hits++; }); if (placeFilter && (p.now === placeFilter || p.been.includes(placeFilter))) hits += 3; if (proMode && coIsPro(p)) hits += 3; const jit = (parseInt(poporHash(p.id + (note ? note.id : '') + q).slice(0, 3), 16) % 60) / 10; const score = Math.min(97, 70 + hits * 6 + jit); const seenNow = mode === 'now' || p.now === placeFilter; return { p, score: Math.round(score * 10) / 10, hits, pro: coIsPro(p), live: coIsLive(p), whenPlace: placeFilter ? (seenNow ? '此刻在' + placeFilter : '曾去过' + placeFilter) : null }; }).filter(Boolean); scored.sort((a, b) => b.score - a.score); const keep = s => s.hits > 0 || placeFilter || proMode; return scored.filter(keep).slice(0, 3).length ? scored.filter(keep).slice(0, 3) : scored.slice(0, 2); } // 墨吞开场:先抛一个与本页相关的问题,并给出可点的快捷追问 function motunOpening(note) { const tag = note && note.notes && note.notes[0] ? note.notes[0] : '这一页'; return { text: `在写《${note ? note.title : '新的一页'}》。我读到你提到「${tag}」——要不要我帮你把它说清楚一点?我也可以替你找些有出处的资料。`, suggestions: [ `「${tag}」到底指什么?`, '帮我找几则可引用的资料', '这一段还能怎么展开?', ], }; } // 墨吞回应:返回一段话 + 引用到的资料(带 index) function motunReply(note, prompt, usedCount) { const pool = motunSources(note); const wantsSource = /资料|出处|引用|来源|找/.test(prompt); const cite = wantsSource ? pool.slice(0, 2) : [pool[usedCount % pool.length]]; let text; if (/指什么|是什么|意思|清楚/.test(prompt)) { text = `我的理解是这样——${pool[0].snippet} 你这一页其实已经写到了这层意思,要不要我把它接到你"${note ? note.city : ''}"那一段后面?`; } else if (wantsSource) { text = `找到几则有出处的资料,按相关度排了序。下面标了序号,正文里需要的话可以直接当脚注引。`; } else { text = `可以顺着这个方向写:先写你看见的具体一幕,再让它自己说出道理,别替它总结。我把一段相关的话放在下面,供你参考。`; } return { text, cite }; } // ============================================================ // 墨吞 · 找灵感(第四面)—— 帮你写下一句 // 把"下一句"做成检索 + 提问:跨域 / 跨时 / 跨地 / 接续 / 借声 五个方向。 // 每条灵感都带来源(你的标签 / 你的旧页 / 你的心情 / 通讯录的人),不是黑箱补全。 // 演示用:确定性地在 window.NOTES 上做真实关联;接入引擎后把"生成"那步换成 LLM。 // ============================================================ const INSPIRE_MODES = [ { id: 'cross', icon: 'spark', label: '跨域', hint: '换一个面,看同一个词' }, { id: 'time', icon: 'clock', label: '跨时', hint: '另一段时间里的同一件事' }, { id: 'place', icon: 'mapPin', label: '跨地', hint: '另一座城里的同一个主题' }, { id: 'thread', icon: 'route', label: '接续', hint: '接一条还没收口的旧线' }, { id: 'voice', icon: 'users', label: '借声', hint: '换一个人的笔法来写' }, ]; // 已知标签的"切面"——跨域提问的种子(演示策展;未知标签回落到通用切面) const TAG_FACETS = { '寺院': ['空间是怎么替人安静下来的', '为什么"慢"的地方让人想停留'], '侘寂': ['缺的那一块,是留给谁补的', '不完整怎么成了一种邀请'], '深夜': ['夜里听见的,白天为什么听不见', '一个人的深夜,和很多人的深夜'], '爵士': ['不解决的和弦,像生活里哪件事', '即兴是自由,还是更深的纪律'], '复盘': ['记录是为了看见,还是为了审判', '数字背后藏着的那个决定'], '克制': ['没买的那束花,和买了的那本书', '克制是匮乏,还是另一种丰盛'], '晨跑': ['身体先于头脑想明白的那件事', '第五公里之后安静下来的念头'], '身体': ['仪器之前,身体先知道了什么', '疼痛在替你写的那种日记'], '小津': ['镜头不追人,人走出去画框还在', '低机位看到的是谁的世界'], '留白': ['没有人的画面,到底在说什么', '多停那三秒的重量'], '咖啡': ['三个变量,和不确定性长期相处', '一杯手冲里的手艺与失败'], '专注': ['专注是排除,还是沉入', '把一件小事做到底,尽头是什么'], '奈良': ['不索取也不躲闪的那种距离', '人和人,能不能像人和鹿'], '相遇': ['恰好在同一片草地上', '擦肩而过里没说出口的那句'], '坂本龙一': ['连喘息都成了旋律', '走到尽头的人,留下的声音'], '告别': ['没剪掉的那段呼吸声', '告别是结束,还是另一种保存'], '出差': ['借来的时间,要还得格外用力', '别人的城市里,没有黄昏'], '城市': ['再快的城市,也要留一处慢的', '写字楼一格一格亮起来的傍晚'], '银杏': ['绿里已经有了要黄的预感', '一棵树,是怎么记住季节的'], '银杏树': ['绿里已经有了要黄的预感', '一棵树,是怎么记住季节的'], }; function tagFacets(tag) { return TAG_FACETS[tag] || ['它在另一个领域里是什么——物性、时令,还是一段记忆', '把「' + tag + '」放到一个意想不到的场景里']; } // 借声 · 名家笔法(让某位作家重写这一段) const FAMOUS_VOICES = [ { name: '海明威', style: '短句、克制,只写动作与名词,情绪压在水面下' }, { name: '汪曾祺', style: '平淡里见滋味,写吃食与日常,闲笔不闲' }, { name: '张爱玲', style: '苍凉的比喻,华丽又冷,世故的眼' }, { name: '鲁迅', style: '冷峻反讽,一刀见血,不留情面' }, { name: '村上春树', style: '疏离的第一人称,奇特比喻,孤独与爵士' }, { name: '沈从文', style: '抒情的乡土,句子像水一样流' }, { name: '卡尔维诺', style: '轻盈奇想,把抽象写得可触可感' }, { name: '周作人', style: '冲淡平和,知堂小品,淡而有味' }, ]; // 借声 · 职业之声(把这页写成两位某职业的对话) const PROFESSION_VOICES = [ { prof: '摄影师', lens: '用光、构图与按下快门的那一瞬去看' }, { prof: '医生', lens: '冷静临床,把感受翻译成身体的信号' }, { prof: '美食家', lens: '用味觉、火候与时令去写' }, { prof: '建筑师', lens: '从空间、结构与尺度去看' }, { prof: '心理咨询师', lens: '替情绪命名,问一个温柔的问题' }, { prof: '侦探', lens: '盯住细节,从蛛丝马迹往回推' }, ]; const VOICE_SUBS = [ { id: 'author', label: '名家笔法' }, { id: 'prof', label: '职业之声' }, { id: 'people', label: '找人' }, ]; // —— 语料关系(真实计算,演示无需 faiss)—— // function inspireCorpus(note) { const list = (typeof window !== 'undefined' && window.NOTES) ? window.NOTES : []; return list.filter(n => n && (!note || n.id !== note.id)); } function noteThemes(n) { return [n.cat].concat(n.notes || []).filter(Boolean); } function sharedThemes(a, b) { const set = new Set(noteThemes(b)); return noteThemes(a).filter(t => set.has(t)); } function dateNum(d) { return parseInt(String(d || '').replace(/\D/g, '').slice(0, 8) || '0', 10); } // 找灵感主入口:给一页 + 一个方向,返回带来源的灵感卡 function inspireCards(note, mode, focusTag) { if (!note) return []; const corpus = inspireCorpus(note); const out = []; if (mode === 'cross') { const tag = focusTag || (note.notes && note.notes[0]) || note.cat; if (!tag) return []; tagFacets(tag).forEach((f, i) => out.push({ id: 'cross-' + tag + '-' + i, mode, q: f, whyKind: 'tag', why: '标签「' + tag + '」· 换一个面', tag, draftSeed: '顺着「' + tag + '」的另一个面——' + f + '——替我接着写一两句,写具体的画面,别替我总结。', })); return out; } if (mode === 'time') { const hits = corpus.filter(n => sharedThemes(n, note).length) .sort((a, b) => dateNum(b.date) - dateNum(a.date)).slice(0, 3); hits.forEach((n, i) => out.push({ id: 'time-' + i, mode, q: '你 ' + n.date + ' 在《' + n.title + '》里也碰过「' + sharedThemes(n, note)[0] + '」——那时和现在,变了什么?', whyKind: 'note', why: '你的旧页 ·《' + n.title + '》' + (n.mood ? ' · 心情 ' + n.mood : ''), refId: n.id, draftSeed: '把这一页,和我 ' + n.date + ' 写的《' + n.title + '》接起来,找出之间变了什么,替我写一句过渡。', })); if (!hits.length) out.push(genericCard(note, mode)); return out; } if (mode === 'place') { const hits = corpus.filter(n => sharedThemes(n, note).length && n.city && n.city !== note.city) .sort((a, b) => dateNum(b.date) - dateNum(a.date)).slice(0, 3); hits.forEach((n, i) => out.push({ id: 'place-' + i, mode, q: '同样的「' + sharedThemes(n, note)[0] + '」,你在' + n.city + '也写过——换一座城,它会不一样吗?', whyKind: 'note', why: '你在 ' + n.city + ' 的《' + n.title + '》', refId: n.id, draftSeed: '比一比' + note.city + '和' + n.city + '里同样的「' + sharedThemes(n, note)[0] + '」,替我写一两句。', })); if (!hits.length) out.push(genericCard(note, mode)); return out; } if (mode === 'thread') { // 你给这页留过、却没答的问题 —— 答它就是最省力的下一句 if (note.mokeyQuestion) out.push({ id: 'thread-q', mode, q: '你给这页留过一个没答的问题:「' + note.mokeyQuestion + '」——现在答它,就是下一句。', whyKind: 'mood', why: '墨吞留下的问题 · 待答', draftSeed: '顺着这个问题往下答,替我写一两句:' + note.mokeyQuestion, }); // 反复出现的标签 = 一直没收口的线 const freq = {}; inspireCorpus(null).forEach(n => noteThemes(n).forEach(t => { freq[t] = (freq[t] || 0) + 1; })); const recurring = noteThemes(note).filter(t => (freq[t] || 0) >= 2) .sort((a, b) => freq[b] - freq[a]).slice(0, 3); recurring.forEach((t, i) => { const earliest = corpus.filter(n => noteThemes(n).includes(t)).sort((a, b) => dateNum(a.date) - dateNum(b.date))[0]; out.push({ id: 'thread-' + i, mode, q: '「' + t + '」从 ' + (earliest ? earliest.date : '很早') + ' 起就在你本子里反复出现,这条线一直没收口——这一页要不要替它收个尾?', whyKind: 'mood', why: '反复出现 · ' + freq[t] + ' 处', refId: earliest && earliest.id, draftSeed: '「' + t + '」在我本子里出现了好几次,替我写一句,把这些零散的页收成一条线。', }); }); if (!out.length) out.push(genericCard(note, mode)); return out; } if (mode === 'voice') { const topic = (note.notes && note.notes[0]) || note.cat || '这一页'; const sub = focusTag || 'author'; if (sub === 'people') { const people = (typeof faissMatch === 'function') ? faissMatch(note, '') : []; people.slice(0, 3).forEach((m, i) => { const p = m.p; out.push({ id: 'voice-p-' + i, mode, q: '借 ' + p.name + '(' + p.prof + ')的眼睛,写「' + topic + '」缺的那一句。', whyKind: 'person', why: '通讯录 · ' + p.prof + ' · ' + p.id, person: p, draftSeed: '以一位' + p.prof + '的笔法和视角,替我写一两句关于「' + topic + '」的话。', }); }); if (!people.length) out.push(genericCard(note, mode)); } else if (sub === 'prof') { PROFESSION_VOICES.slice(0, 5).forEach((v, i) => out.push({ id: 'voice-pr-' + i, mode, q: '写成两位' + v.prof + '的对话 · ' + v.lens, whyKind: 'voice', why: '职业之声 · ' + v.prof, draftSeed: '把下面这一页改写成两位' + v.prof + '之间的一段简短对话,' + v.lens + ',借对话把意思讲出来,别替我总结。', })); } else { FAMOUS_VOICES.slice(0, 6).forEach((v, i) => out.push({ id: 'voice-a-' + i, mode, q: '让' + v.name + '重写这一段 · ' + v.style, whyKind: 'voice', why: '名家笔法 · ' + v.name, restyle: v.name, // 借声 · 接 /api/notes/{id}/restyle 的笔法名 draftSeed: '用' + v.name + '的笔法重写下面这段,抓住其特征:' + v.style + '。保留我的原意与主题,不要改写事实。', })); } return out; } return out; } function genericCard(note, mode) { const topic = (note.notes && note.notes[0]) || note.cat || '这一页'; return { id: 'generic-' + mode, mode, q: '关于「' + topic + '」,还有一个你没写到、但答得上来的角度——会是什么?', whyKind: 'tag', why: '本页主题 · ' + topic, draftSeed: '就「' + topic + '」给我一个反常识的角度,并替我写一两句开头。', }; } // —— 为数据中出现的新城市补全线路色 / 经纬度(避免地铁图缺值)—— function ensureCityMaps(notes) { (notes || []).forEach(n => { const c = n.city; if (!c) return; if (!CITY_LINE[c] || !CITY_GEO[c]) { const known = Object.keys(CITY_GEO).find(k => c.indexOf(k) >= 0); if (!CITY_LINE[c]) { const hue = parseInt(poporHash(c).slice(0, 2), 16) * 360 / 255 | 0; CITY_LINE[c] = known ? CITY_LINE[known] : ('hsl(' + hue + ' 36% 42%)'); } if (!CITY_GEO[c]) { CITY_GEO[c] = known ? CITY_GEO[known] : { lat: 22 + (parseInt(poporHash(c).slice(0, 2), 16) % 28), lon: 100 + (parseInt(poporHash(c).slice(2, 4), 16) % 35), }; } } }); } // —— 主入口:从后端拉取并转换;失败回落到示例数据 —— async function loadNotes() { const url = POPOR_API_BASE + '/api/notes?limit=200'; try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 2500); const res = await fetch(url, { signal: ctrl.signal }); clearTimeout(timer); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const raw = Array.isArray(data.notes) ? data.notes : []; const notes = raw.map(transformNote); ensureCityMaps(notes); if (document.body) document.body.setAttribute('data-source', 'api'); console.info('[Popor] 已从 ' + url + ' 载入 ' + notes.length + ' 则真实笔记'); return { notes, notebooks: buildNotebooks(notes) }; } catch (err) { console.warn('[Popor] 无法连接后端 ' + POPOR_API_BASE + ',已回落到本地示例数据。原因:' + (err && err.message)); const notes = FALLBACK_NOTES.map(n => Object.assign({}, n, { notebook: n.cat })); ensureCityMaps(notes); if (document.body) document.body.setAttribute('data-source', 'demo'); return { notes, notebooks: buildNotebooks(notes) }; } } // ============================================================ // 浮现引擎 · 真实语义接口(见 popor_emerge_api_spec.md) // 后端可达时优先用语义结果;不可达 / 出错由调用方回落到 inspireCards()。 // 接口一:POST /api/emerge(书写台停顿浮现 / 找灵感 time·place·cross) // 接口三:POST /api/notes/{id}/restyle(借声 · 名家笔法) // ============================================================ // 浮现:传入当前正文,拿回语义相关的旧笔记 hits(失败返回 null,调用方回落) async function emergeFetch(text, excludeId, topK) { if (!text || !text.trim()) return null; try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 3000); const res = await fetch(POPOR_API_BASE + '/api/emerge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ctrl.signal, body: JSON.stringify({ text: text.slice(-600), exclude_id: excludeId || '', top_k: topK || 3 }), }); clearTimeout(timer); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const hits = (data && Array.isArray(data.hits)) ? data.hits : []; return hits.length ? hits : null; } catch (err) { console.warn('[Popor] /api/emerge 不可达,回落本地 inspireCards()。原因:' + (err && err.message)); return null; } } // 把 emerge hits 映射成「找灵感」卡片结构(与 inspireCards 输出同形) function emergeToCards(hits, mode) { return (hits || []).map((h, i) => ({ id: 'emerge-' + mode + '-' + (h.id || i), mode, q: (h.reason || '你早先也碰过这个') + ' —— 要不要接上那一页?', whyKind: 'note', why: '你的旧页 ·《' + (h.title || '未命名') + '》', refId: h.id, // 后端 hit 自带预览,详情面板不依赖 window.NOTES 也能展开 emergeRef: { preview: h.preview || '', title: h.title || '', date: h.date || '', city: h.location || '' }, draftSeed: '把这一页,和我' + (h.date ? h.date + ' ' : '') + '写的《' + (h.title || '那一页') + '》接起来——' + (h.reason || '') + '。替我写一句过渡,写具体画面,别替我总结。', })); } // 借声 · 名家笔法:让某位作家改写整段(rewrite)或接着写(continue) // 成功返回 { styled_text, style, styles_available };失败抛错(调用方提示并保留原卡) async function restyleNote(noteId, style, mode, text) { const res = await fetch(POPOR_API_BASE + '/api/notes/' + encodeURIComponent(noteId) + '/restyle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ style: style, mode: mode || 'rewrite' }, text ? { text } : {})), }); if (!res.ok) { let detail = '借声失败(' + res.status + ')'; try { const e = await res.json(); if (e && e.detail) detail = e.detail; } catch (_) {} throw new Error(detail); } return res.json(); } // ============================================================ // 通讯录 · 人际关系(在 PEOPLE 语境库之上补社交字段) // 一个联系人可有多重关系;并按本子归类;可标注导入来源 // ============================================================ const RELATION_TYPES = [ { id: 'collab', label: '合作者', tone: 'pp' }, { id: 'work', label: '工作', tone: 'sea' }, { id: 'friend', label: '朋友', tone: 'gr' }, { id: 'fan', label: '粉丝', tone: 'fl' }, { id: 'family', label: '家人', tone: 'yl' }, ]; const SOURCE_LABEL = { popor: 'Popor', wechat: '微信', instagram: 'Instagram', phone: '手机' }; // 联系人 = PEOPLE 语境 + 社交字段;另补两位(家人/粉丝)做关系多样性 const CONTACT_EXTRA = { '@lakeside_zhou': { phone: '138 0021 7745', rel: ['collab', 'friend'], books: ['旅行', '读书'], src: 'wechat', skills: ['风光摄影', '慢门长曝', '黑白胶片'] }, '@wabi_keng': { phone: '139 5567 2210', rel: ['collab'], books: ['旅行', '读书'], src: 'wechat', skills: ['庭园考据', '拓印', '语境补全'] }, '@evans_fan': { phone: '137 8890 3321', rel: ['friend'], books: ['音乐'], src: 'popor', skills: ['即兴编曲', '谱曲', '黑胶选曲'] }, '@ozu_low': { phone: '136 4412 8890', rel: ['collab', 'work'], books: ['电影', '旅行'], src: 'wechat', skills: ['剪辑视频', '空镜拍摄', '分镜设计'] }, '@dr_lin': { phone: '135 0098 4567', rel: ['work', 'friend'], books: ['健康'], src: 'phone', skills: ['健康解读', '数据判读'] }, '@pourover_an': { phone: '188 7741 0092', rel: ['friend'], books: ['生活'], src: 'instagram', skills: ['手冲教学', '风味笔记'] }, '@ledger_he': { phone: '186 3320 5567', rel: ['work'], books: ['账本'], src: 'popor', skills: ['财务复盘', '极简记账'] }, '@ginkgo_x': { phone: '159 6678 1123', rel: ['collab', 'fan'], books: ['旅行', '生活'], src: 'instagram', skills: ['城市漫步', '随笔', '智能排版'] }, }; const CONTACT_GUESTS = [ { id: '@mama_lin', name: '林知秋', prof: '母亲 · 退休教师', av: '#A8643C', now: '苏州', phone: '139 1100 2233', rel: ['family'], books: ['生活', '读书'], src: 'phone', skills: ['家常菜谱', '旧信整理'], note: '外婆的腌梅子', offer: '语境补全' }, { id: '@reader_ye', name: '阿野', prof: '读者 · 独立书店店员', av: '#4A6B5A', now: '成都', phone: '', rel: ['fan'], books: ['读书'], src: 'popor', skills: ['读书摘录', '手写卡片'], note: 'replied 你的《雾里的西湖》', offer: '语境补全' }, ]; function buildContacts() { const base = PEOPLE.map(p => Object.assign({}, p, CONTACT_EXTRA[p.id] || { phone: '', rel: ['friend'], books: [], src: 'popor', skills: p.hobby || [] })); return base.concat(CONTACT_GUESTS); } const CONTACTS = buildContacts(); function relMeta(id) { return RELATION_TYPES.find(r => r.id === id) || { label: id, tone: 'gr' }; } // ============================================================ // 技能 Skills · 工具箱(和文生图 / 谱曲同级的能力) // 官方技能 + 用户自制;通过人脉网络传播;每次调用消耗积分 // ============================================================ const SKILL_CATS = [ { id: 'create', label: '创作' }, { id: 'visual', label: '影像' }, { id: 'read', label: '识别' }, { id: 'tidy', label: '整理' }, ]; const SKILLS = [ { id: 'text-gen', name: '文生文 · 续写', icon: 'pencil', cat: 'create', credits: 2, by: '官方', uses: 24800, installed: true, desc: '顺着你的语气把没写完的那句续下去,只补语境不抢话。' }, { id: 'img-gen', name: '文生图 · 插画', icon: 'image', cat: 'visual', credits: 8, by: '官方', uses: 18600, installed: true, desc: '把一段文字生成一张克制的插画,风格随本子走。' }, { id: 'compose', name: '谱曲 · 配乐', icon: 'music', cat: 'create', credits: 10, by: '官方', uses: 9200, installed: false, desc: '给这一页配一小段旋律,像给文字找一个呼吸。' }, { id: 'video-edit',name: '剪辑视频 · 短片', icon: 'eye', cat: 'visual', credits: 12, by: '@ozu_low', byName: '川岛 · 纪录片导演', uses: 3400, installed: false, shared: true, desc: '把零散素材剪成一支有节奏的短片。川岛的低机位手法。' }, { id: 'ocr', name: 'OCR · 手写识别', icon: 'camera', cat: 'read', credits: 1, by: '官方', uses: 41200, installed: true, desc: '把拍下来的手写稿转成可编辑文字,保留分段。' }, { id: 'layout', name: '智能排版 · 成册', icon: 'type', cat: 'tidy', credits: 3, by: '@ginkgo_x', byName: '夏行 · 城市行走作家', uses: 5600, installed: false, shared: true, desc: '把零散笔记自动排成一册,留白与节奏都替你想好。' }, { id: 'translate', name: '双语对照 · 中英', icon: 'globe', cat: 'read', credits: 2, by: '官方', uses: 7700, installed: false, desc: '为这一页生成中英对照,保留原文的克制。' }, { id: 'summarize', name: '读书摘录 · 卡片', icon: 'quote', cat: 'tidy', credits: 2, by: '@reader_ye', byName: '阿野 · 书店店员', uses: 6100, installed: true, shared: true, desc: '把长文提炼成几张可引用的摘录卡片。' }, { id: 'voice', name: '朗读配音 · TTS', icon: 'bell', cat: 'visual', credits: 4, by: '官方', uses: 4300, installed: false, desc: '用温和的声线把这一页读出来。' }, ]; function skillById(id) { return SKILLS.find(s => s.id === id); } // 这一页是「怎么写成的」——根据内容推断用过的技能足迹(确权用) function noteSkillStack(note) { if (!note) return []; const hay = [note.title, note.cat, ...(note.notes || []), note.preview || '', note.body || ''].join(' '); const stack = []; if (note.hasPhoto || note.img) stack.push('ocr'); if (/旅行|风景|画面|插画|城市|电影|光/.test(hay) || ['旅行', '电影', '创意'].includes(note.cat)) stack.push('img-gen'); if (/音乐|爵士|和弦|谱|乐|旋律/.test(hay) || note.cat === '音乐') stack.push('compose'); if (/视频|短片|剪|影像|纪录/.test(hay)) stack.push('video-edit'); if (/书|读|摘|引用|句子/.test(hay) || ['读书', '学习'].includes(note.cat)) stack.push('summarize'); stack.push('text-gen'); // 墨吞润色几乎人人都用 if (note.body && note.body.length > 240) stack.push('layout'); // 去重 + 保序,最多 4 个 return stack.filter((s, i) => stack.indexOf(s) === i).slice(0, 4); } // ============================================================ // 墨吞计费 · 积分 + 订阅档位 + 模型分档 // ============================================================ const MODEL_TIERS = [ { id: 'std', name: '标准', sub: '墨吞 · 日常', perMsg: 1, needsPlan: false, desc: '回应、找资料、润色,日常够用' }, { id: 'deep', name: '深度', sub: '墨吞 · 深读', perMsg: 4, needsPlan: true, desc: '更长的上下文与更细的推敲,适合长文' }, ]; const PLANS = [ { id: 'free', name: '清茶', price: 0, unit: '', monthly: 200, tiers: ['std'], blurb: '随手记,够用就好' }, { id: 'resident',name: '常驻', price: 19, unit: '/月', monthly: 2000, tiers: ['std', 'deep'], blurb: '常写的人,深度模型管够' }, { id: 'deep', name: '深耕', price: 39, unit: '/月', monthly: 6000, tiers: ['std', 'deep'], blurb: '创作者,技能与深读不设限' }, ]; const USER_WALLET = { credits: 1280, plan: 'free', name: '林知夏', handle: '@zhixia' }; Object.assign(window, { RELATION_TYPES, SOURCE_LABEL, CONTACTS, relMeta, buildContacts, SKILL_CATS, SKILLS, skillById, noteSkillStack, MODEL_TIERS, PLANS, USER_WALLET, }); // 初始占位(真正数据由 app 启动时经 loadNotes 注入) window.NOTES = window.NOTES || []; window.NOTEBOOKS = window.NOTEBOOKS || buildNotebooks([]); Object.assign(window, { buildNotebooks, transformNote, loadNotes, ensureCityMaps, FALLBACK_NOTES, CATEGORY_STYLE, CITY_LINE, CITY_GEO, buildStations, poporHash, NOW, motunSources, motunOpening, motunReply, PEOPLE, coDimensions, faissMatch, motunCoJudge, coIsLive, coIsPro, INSPIRE_MODES, TAG_FACETS, tagFacets, inspireCards, FAMOUS_VOICES, PROFESSION_VOICES, VOICE_SUBS, emergeFetch, emergeToCards, restyleNote, });