claude-simple 0.2.27 → 0.2.30

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.
Files changed (79) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +29 -4
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.30.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.30.min.css +2012 -0
  8. package/dist/xterm.min.css +0 -0
  9. package/dist/xterm.min.js +0 -0
  10. package/gateway.js +0 -0
  11. package/package.json +2 -2
  12. package/public/addon-fit.min.js +0 -0
  13. package/public/agent-panel.js +54 -22
  14. package/public/api.js +8 -5
  15. package/public/app.js +7 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +110 -19
  19. package/public/files.js +26 -10
  20. package/public/git.js +2 -2
  21. package/public/icons/icon-180.png +0 -0
  22. package/public/icons/icon-192.png +0 -0
  23. package/public/icons/icon-512.png +0 -0
  24. package/public/icons/icon-maskable-512.png +0 -0
  25. package/public/index.html +2 -2
  26. package/public/lib.js +0 -0
  27. package/public/manifest.webmanifest +0 -0
  28. package/public/markdown.js +16 -0
  29. package/public/math.js +145 -7
  30. package/public/mobile.js +0 -0
  31. package/public/notes.js +316 -0
  32. package/public/notifications.js +0 -0
  33. package/public/offline.html +0 -0
  34. package/public/pwa.js +1 -1
  35. package/public/router.js +4 -0
  36. package/public/scroll-follow.js +78 -0
  37. package/public/settings.js +41 -0
  38. package/public/shell/boot.js +0 -0
  39. package/public/shell/dashboard.js +0 -0
  40. package/public/shell/hub.js +0 -0
  41. package/public/shell/layout.js +0 -0
  42. package/public/shell/notes.js +0 -0
  43. package/public/shell/session-context.js +0 -0
  44. package/public/shell/session-list.js +79 -21
  45. package/public/shell/session-nav.js +70 -21
  46. package/public/shell/templates.js +3 -0
  47. package/public/shell/util.js +0 -0
  48. package/public/shell.js +15 -2
  49. package/public/state.js +7 -7
  50. package/public/style.css +161 -0
  51. package/public/sw.js +10 -5
  52. package/public/terminal.js +0 -0
  53. package/public/wake-lock.js +0 -0
  54. package/public/workspace-path.js +61 -0
  55. package/public/xterm.min.css +0 -0
  56. package/public/xterm.min.js +0 -0
  57. package/src/agent-quota.js +228 -0
  58. package/src/agents/claude.js +0 -0
  59. package/src/agents/codex.js +2 -0
  60. package/src/agents/cursor.js +2 -1
  61. package/src/agents/gemini.js +2 -1
  62. package/src/agents/grok.js +22 -1
  63. package/src/agents/meta-agent.js +7 -4
  64. package/src/agents/models.js +21 -12
  65. package/src/agents/sessions.js +270 -81
  66. package/src/agents/stream-batch.js +0 -0
  67. package/src/app.js +111 -51
  68. package/src/auto-update.js +2 -0
  69. package/src/collab.js +3 -0
  70. package/src/platform.js +133 -0
  71. package/src/project-api-path.js +14 -0
  72. package/src/runtime/approvals.js +0 -0
  73. package/src/runtime/chat-broadcast.js +12 -0
  74. package/src/runtime/outbox.js +0 -0
  75. package/src/runtime/port-websocket.js +80 -0
  76. package/src/runtime/protocol.js +0 -0
  77. package/src/session-history.js +31 -0
  78. package/dist/app.0.2.27.min.js +0 -1182
  79. package/dist/style.0.2.27.min.css +0 -1851
package/public/git.js CHANGED
@@ -74,7 +74,7 @@ export function initGitTab() {
74
74
  area.innerHTML = '<div class="p-4 text-sm text-base-content/50">Enter a project root above, or open a session with a working directory.</div>';
75
75
  return;
76
76
  }
77
- const id = proj?.id || '_';
77
+ const id = '_';
78
78
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
79
79
  try {
80
80
  const data = await api('GET', `/api/projects/${id}/git/status?root=${encodeURIComponent(root)}`, undefined, signal);
@@ -126,7 +126,7 @@ function loadGraph() {
126
126
  const root = gitRoot.peek() || proj?.path;
127
127
  const content = $('git-graph-content');
128
128
  if (!root || !content) return;
129
- const id = proj?.id || '_';
129
+ const id = '_';
130
130
  content.innerHTML = '<span class="text-base-content/40">Loading…</span>';
131
131
  api('GET', `/api/projects/${id}/git/log?root=${encodeURIComponent(root)}`)
132
132
  .then(d => { content.innerHTML = d.graph ? renderGraph(d.graph) : '<span class="text-base-content/40">No commits</span>'; })
File without changes
File without changes
File without changes
File without changes
package/public/index.html CHANGED
@@ -85,7 +85,7 @@
85
85
  <script src="/vendor/mermaid.min.js"></script>
86
86
  <link rel="stylesheet" href="/vendor/katex.min.css">
87
87
  <script src="/vendor/katex.min.js"></script>
88
- <link rel="stylesheet" href="style.css?v=ui-22">
88
+ <link rel="stylesheet" href="style.css?v=ui-25">
89
89
  </head>
90
90
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
91
91
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
@@ -101,6 +101,6 @@
101
101
  }
102
102
  }, 10000);
103
103
  </script>
104
- <script type="module" src="app.js?v=ui-22"></script>
104
+ <script type="module" src="app.js?v=ui-25"></script>
105
105
  </body>
106
106
  </html>
package/public/lib.js CHANGED
File without changes
File without changes
@@ -3,6 +3,8 @@
3
3
  // placeholder. It is not HTML, but a Markdown parser will otherwise pass it
4
4
  // through as an element and a malformed placeholder can corrupt the rest of a
5
5
  // message's DOM.
6
+ import { protectMath, restoreMath } from './math.js';
7
+
6
8
  const TRUNCATED_TRANSCRIPT_MARKER = /(?:<|\[)\s*(?:…\s*)?truncated\s+([\d,]+)\s+bytes\s*(?:>|\])/gi;
7
9
 
8
10
  export function normalizeMarkdownText(value) {
@@ -10,3 +12,17 @@ export function normalizeMarkdownText(value) {
10
12
  return `\n\n> ⚠️ 原始记录已截断(${bytes} bytes)\n\n`;
11
13
  });
12
14
  }
15
+
16
+ export function parseMarkdownWithMath(value, { fallback } = {}) {
17
+ const { text, slots } = protectMath(normalizeMarkdownText(value));
18
+ if (typeof marked === 'undefined') {
19
+ const raw = fallback ? fallback(text) : text;
20
+ return restoreMath(raw, slots);
21
+ }
22
+ try {
23
+ return restoreMath(marked.parse(text, { breaks: true, gfm: true }), slots);
24
+ } catch {
25
+ const raw = fallback ? fallback(text) : text;
26
+ return restoreMath(raw, slots);
27
+ }
28
+ }
package/public/math.js CHANGED
@@ -1,5 +1,137 @@
1
- // KaTeX enhancement for Markdown output. The parser deliberately runs after
2
- // Marked so fenced code and Mermaid source stay literal.
1
+ // KaTeX enhancement for Markdown output. Math is extracted before Marked so
2
+ // `\[...\]` is not treated as an escaped bracket and `_` inside TeX is not
3
+ // turned into emphasis. A leftover pass still runs on the DOM for any tokens
4
+ // that survive as text.
5
+
6
+ const LATEXISH = /\\[a-zA-Z]+|[_^]\{|\\[,;!]|\\[{}()[\]]|\\leq\b|\\geq\b|\\neq\b|\\in\b|\\sum\b|\\int\b|\\frac\b|\\min\b|\\max\b|\\mathrm\b|\\text\b|\\left\b|\\right\b/;
7
+
8
+ export function looksLikeLatex(s) {
9
+ return LATEXISH.test(String(s || ''));
10
+ }
11
+
12
+ function htmlEscape(s) {
13
+ return String(s)
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;');
17
+ }
18
+
19
+ function atLineStart(text, i) {
20
+ return i === 0 || text[i - 1] === '\n';
21
+ }
22
+
23
+ function matchFence(text, i) {
24
+ if (atLineStart(text, i) && (text.startsWith('```', i) || text.startsWith('~~~', i))) {
25
+ const fence = text.startsWith('```', i) ? '```' : '~~~';
26
+ const close = text.indexOf('\n' + fence, i + 3);
27
+ if (close === -1) return { end: text.length };
28
+ const after = close + 1 + fence.length;
29
+ return { end: after };
30
+ }
31
+ if (text[i] === '`') {
32
+ let n = 1;
33
+ while (text[i + n] === '`') n++;
34
+ let j = i + n;
35
+ while (j < text.length) {
36
+ if (text[j] === '`') {
37
+ let k = 0;
38
+ while (text[j + k] === '`') k++;
39
+ if (k === n) return { end: j + k };
40
+ j += k;
41
+ continue;
42
+ }
43
+ j++;
44
+ }
45
+ return { end: i + n };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function matchMath(text, i) {
51
+ const rest = text.slice(i);
52
+
53
+ if (rest.startsWith('$$')) {
54
+ const end = rest.indexOf('$$', 2);
55
+ if (end >= 2) {
56
+ const body = rest.slice(2, end);
57
+ if (body.trim()) return { body, display: true, end: i + end + 2 };
58
+ }
59
+ }
60
+
61
+ if (rest.startsWith('\\[')) {
62
+ const end = rest.indexOf('\\]', 2);
63
+ if (end >= 2) {
64
+ const body = rest.slice(2, end);
65
+ if (body.trim()) return { body, display: true, end: i + end + 2 };
66
+ }
67
+ }
68
+
69
+ if (rest.startsWith('\\(')) {
70
+ const end = rest.indexOf('\\)', 2);
71
+ if (end >= 2) {
72
+ const body = rest.slice(2, end);
73
+ if (body.trim()) return { body, display: false, end: i + end + 2 };
74
+ }
75
+ }
76
+
77
+ if (rest[0] === '$' && rest[1] !== '$') {
78
+ const m = rest.match(/^\$([^$\n]|\\\$)+?(?<!\s)\$/);
79
+ if (m && m[0][1] !== ' ') {
80
+ const body = m[0].slice(1, -1);
81
+ if (body.trim()) return { body, display: false, end: i + m[0].length };
82
+ }
83
+ }
84
+
85
+ if (atLineStart(text, i)) {
86
+ const block = rest.match(/^[ \t]*\[[ \t]*\n([\s\S]*?)\n[ \t]*\][ \t]*(?=\n|$)/);
87
+ if (block && looksLikeLatex(block[1])) {
88
+ return { body: block[1], display: true, end: i + block[0].length };
89
+ }
90
+ const line = rest.match(/^[ \t]*\[([^\]\n]+)\](?!\()/);
91
+ if (line && looksLikeLatex(line[1])) {
92
+ return { body: line[1], display: true, end: i + line[0].length };
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ export function protectMath(src) {
100
+ const slots = [];
101
+ const text = String(src ?? '');
102
+ let out = '';
103
+ let i = 0;
104
+ while (i < text.length) {
105
+ const fence = matchFence(text, i);
106
+ if (fence) {
107
+ out += text.slice(i, fence.end);
108
+ i = fence.end;
109
+ continue;
110
+ }
111
+ const math = matchMath(text, i);
112
+ if (math) {
113
+ const id = slots.length;
114
+ slots.push({ body: math.body, display: math.display });
115
+ out += `@@MATH${id}@@`;
116
+ i = math.end;
117
+ continue;
118
+ }
119
+ out += text[i];
120
+ i++;
121
+ }
122
+ return { text: out, slots };
123
+ }
124
+
125
+ export function restoreMath(html, slots) {
126
+ if (!slots?.length) return html;
127
+ return String(html).replace(/@@MATH(\d+)@@/g, (full, id) => {
128
+ const slot = slots[Number(id)];
129
+ if (!slot) return full;
130
+ const open = slot.display ? '\\[' : '\\(';
131
+ const close = slot.display ? '\\]' : '\\)';
132
+ return htmlEscape(`${open}${slot.body}${close}`);
133
+ });
134
+ }
3
135
 
4
136
  function ignored(node) {
5
137
  return node.parentElement?.closest('pre, code, .mermaid-diagram, .katex, script, style');
@@ -10,9 +142,9 @@ function nextToken(text, from) {
10
142
  { re: /\\\[([\s\S]+?)\\\]/g, display: true },
11
143
  { re: /\$\$([\s\S]+?)\$\$/g, display: true },
12
144
  { re: /\\\(([\s\S]+?)\\\)/g, display: false },
13
- // A closing dollar must not be escaped or preceded by whitespace. This
14
- // leaves ordinary currency such as "$5 and $6" untouched.
15
145
  { re: /(?<!\\)\$([^$\n]|\\\$)+?(?<!\s)(?<!\\)\$/g, display: false },
146
+ { re: /(?:^|\n)[ \t]*\[[ \t]*\n([\s\S]*?)\n[ \t]*\][ \t]*(?=\n|$)/g, display: true, latex: true },
147
+ { re: /(?:^|\n)[ \t]*\[([^\]\n]+\\[a-zA-Z{}_^][^\]\n]*)\](?!\()/g, display: true, latex: true },
16
148
  ];
17
149
  let best = null;
18
150
  for (const pattern of patterns) {
@@ -20,10 +152,16 @@ function nextToken(text, from) {
20
152
  const match = pattern.re.exec(text);
21
153
  if (!match || (best && match.index >= best.match.index)) continue;
22
154
  const raw = match[0];
23
- const body = raw.startsWith('\\[') || raw.startsWith('\\(')
24
- ? raw.slice(2, -2)
25
- : raw.startsWith('$$') ? raw.slice(2, -2) : raw.slice(1, -1);
155
+ let body;
156
+ if (match[1] != null && (pattern.latex || raw.startsWith('\\[') || raw.startsWith('\\(') || raw.startsWith('$$') || raw.startsWith('$'))) {
157
+ body = match[1];
158
+ } else {
159
+ body = raw.startsWith('\\[') || raw.startsWith('\\(')
160
+ ? raw.slice(2, -2)
161
+ : raw.startsWith('$$') ? raw.slice(2, -2) : raw.slice(1, -1);
162
+ }
26
163
  if (!body.trim()) continue;
164
+ if (pattern.latex && !looksLikeLatex(body)) continue;
27
165
  best = { match, body, display: pattern.display };
28
166
  }
29
167
  return best;
package/public/mobile.js CHANGED
File without changes
@@ -0,0 +1,316 @@
1
+ // notes.js — personal notes in the main pane (sidebar stays visible),
2
+ // matching the Flutter HomePage "记事" chip: list + add/edit/delete cards
3
+ // over the same /api/notes store.
4
+
5
+ import { api } from './api.js';
6
+ import { ctx } from './state.js';
7
+
8
+ let notesCache = null;
9
+ let notesEditingId = null;
10
+ let notesBooted = false;
11
+ let notesShowForm = false;
12
+
13
+ export function openNotes() {
14
+ ensureNotesBoot();
15
+ const page = document.getElementById('notes-page');
16
+ if (!page) return;
17
+ page.hidden = false;
18
+ document.getElementById('welcome')?.classList.add('hidden');
19
+ const pv = document.getElementById('project-view');
20
+ if (pv) {
21
+ pv.classList.add('hidden');
22
+ pv.style.display = 'none';
23
+ }
24
+ document.documentElement.classList.remove('notes-open');
25
+ if ((location.hash || '') !== '#/notes') location.hash = '/notes';
26
+ loadNotes({ keepList: true });
27
+ }
28
+
29
+ export function closeNotes({ restore = true } = {}) {
30
+ const page = document.getElementById('notes-page');
31
+ if (!page) return;
32
+ page.hidden = true;
33
+ document.documentElement.classList.remove('notes-open');
34
+ notesEditingId = null;
35
+ notesShowForm = false;
36
+ const form = document.getElementById('notes-add-form');
37
+ if (form) form.hidden = true;
38
+ if (!restore) return;
39
+ if (ctx.sessionId) {
40
+ const pv = document.getElementById('project-view');
41
+ if (pv) {
42
+ pv.classList.remove('hidden');
43
+ pv.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden';
44
+ }
45
+ } else {
46
+ const welcome = document.getElementById('welcome');
47
+ if (welcome) {
48
+ welcome.classList.remove('hidden');
49
+ welcome.style.display = '';
50
+ }
51
+ }
52
+ }
53
+
54
+ function ensureNotesBoot() {
55
+ if (notesBooted) return;
56
+ notesBooted = true;
57
+ const main = document.getElementById('main') || document.getElementById('root');
58
+ main.insertAdjacentHTML('beforeend', notesMarkup());
59
+ document.addEventListener('router:notes', () => openNotes());
60
+
61
+ const page = document.getElementById('notes-page');
62
+ page.addEventListener('click', (event) => {
63
+ const close = event.target.closest('[data-notes-close]');
64
+ if (close) {
65
+ closeNotes();
66
+ if ((location.hash || '') === '#/notes') history.back();
67
+ return;
68
+ }
69
+ const add = event.target.closest('[data-notes-add]');
70
+ if (add) {
71
+ event.preventDefault();
72
+ notesShowForm = !notesShowForm;
73
+ const form = document.getElementById('notes-add-form');
74
+ if (form) form.hidden = !notesShowForm;
75
+ add.textContent = notesShowForm ? '收起' : '+ 新增';
76
+ if (notesShowForm) document.getElementById('notes-add-text')?.focus();
77
+ return;
78
+ }
79
+ const submit = event.target.closest('[data-notes-submit]');
80
+ if (submit) {
81
+ event.preventDefault();
82
+ submitNote();
83
+ return;
84
+ }
85
+ const edit = event.target.closest('[data-notes-edit]');
86
+ if (edit) {
87
+ event.preventDefault();
88
+ startEditNote(edit.dataset.notesEdit);
89
+ return;
90
+ }
91
+ const save = event.target.closest('[data-notes-save]');
92
+ if (save) {
93
+ event.preventDefault();
94
+ saveEditNote();
95
+ return;
96
+ }
97
+ const cancel = event.target.closest('[data-notes-cancel]');
98
+ if (cancel) {
99
+ event.preventDefault();
100
+ if (notesEditingId) cancelEditNote();
101
+ else {
102
+ notesShowForm = false;
103
+ const form = document.getElementById('notes-add-form');
104
+ if (form) form.hidden = true;
105
+ const addBtn = document.querySelector('[data-notes-add]');
106
+ if (addBtn) addBtn.textContent = '+ 新增';
107
+ }
108
+ return;
109
+ }
110
+ const remove = event.target.closest('[data-notes-remove]');
111
+ if (remove) {
112
+ event.preventDefault();
113
+ const id = remove.dataset.notesRemove;
114
+ if (confirm('删除这条记录?')) deleteNote(id);
115
+ }
116
+ });
117
+
118
+ // Submit on Enter inside the textarea (Cmd/Ctrl+Enter), plain Enter inserts a newline.
119
+ const ta = document.getElementById('notes-add-text');
120
+ if (ta) {
121
+ ta.addEventListener('keydown', (e) => {
122
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
123
+ e.preventDefault();
124
+ submitNote();
125
+ }
126
+ });
127
+ }
128
+ }
129
+
130
+ function notesMarkup() {
131
+ return `
132
+ <section id="notes-page" class="notes-page" hidden>
133
+ <header class="notes-header">
134
+ <div>
135
+ <h2>记事</h2>
136
+ <p id="notes-count" class="notes-count">多端实时同步</p>
137
+ </div>
138
+ <div class="notes-header-actions">
139
+ <button class="btn btn-sm btn-ghost border border-base-300" data-notes-add>+ 新增</button>
140
+ <button class="btn btn-sm btn-ghost border border-base-300" data-notes-close>关闭</button>
141
+ </div>
142
+ </header>
143
+ <form id="notes-add-form" class="notes-add" hidden>
144
+ <textarea id="notes-add-text" placeholder="记一条… (Cmd/Ctrl+Enter 提交)" rows="3" maxlength="8000"></textarea>
145
+ <div class="notes-form-actions">
146
+ <button class="btn btn-sm btn-primary" data-notes-submit>保存</button>
147
+ <button class="btn btn-sm btn-ghost" type="button" data-notes-cancel>取消</button>
148
+ </div>
149
+ </form>
150
+ <ul id="notes-list" class="notes-list">
151
+ <li class="notes-empty">加载中…</li>
152
+ </ul>
153
+ </section>`;
154
+ }
155
+
156
+ async function loadNotes({ keepList = false } = {}) {
157
+ const list = document.getElementById('notes-list');
158
+ if (!list) return;
159
+ if (!keepList || !notesCache) {
160
+ list.innerHTML = '<li class="notes-empty">加载中…</li>';
161
+ }
162
+ try {
163
+ const res = await api('GET', '/api/notes?limit=200', undefined, undefined, { hub: true });
164
+ notesCache = res?.items || [];
165
+ renderNotes();
166
+ } catch (err) {
167
+ list.innerHTML = `<li class="notes-empty notes-error">加载失败:${err?.message || err}</li>`;
168
+ }
169
+ }
170
+
171
+ function renderNotes() {
172
+ const list = document.getElementById('notes-list');
173
+ if (!list) return;
174
+ const count = document.getElementById('notes-count');
175
+ if (count) {
176
+ count.textContent = notesCache?.length
177
+ ? `${notesCache.length} 条记事 · 多端实时同步`
178
+ : '多端实时同步';
179
+ }
180
+ const form = document.getElementById('notes-add-form');
181
+ if (form) form.hidden = !notesShowForm;
182
+ if (!notesCache || notesCache.length === 0) {
183
+ list.innerHTML = '<li class="notes-empty">还没有记事。点上面的「新增」,或者在 Meta Agent 里输入「记: xxx」</li>';
184
+ return;
185
+ }
186
+ list.innerHTML = '';
187
+ for (const note of notesCache) {
188
+ const li = document.createElement('li');
189
+ li.className = 'notes-item';
190
+ li.dataset.id = note.id;
191
+ if (notesEditingId === note.id) {
192
+ li.innerHTML = `
193
+ <textarea class="notes-edit-text" rows="4">${escapeHtml(note.text)}</textarea>
194
+ <div class="notes-item-actions">
195
+ <button class="btn btn-sm btn-primary" data-notes-save>保存</button>
196
+ <button class="btn btn-sm btn-ghost" data-notes-cancel>取消</button>
197
+ </div>`;
198
+ } else {
199
+ li.innerHTML = `
200
+ <div class="notes-text">${escapeHtml(note.text)}</div>
201
+ <div class="notes-item-foot">
202
+ <span class="notes-meta">${formatTime(note.createdAt)}</span>
203
+ <div class="notes-item-actions">
204
+ <button class="btn btn-ghost btn-xs" data-notes-edit="${note.id}" title="编辑">编辑</button>
205
+ <button class="btn btn-ghost btn-xs text-error" data-notes-remove="${note.id}" title="删除">删除</button>
206
+ </div>
207
+ </div>`;
208
+ }
209
+ list.appendChild(li);
210
+ }
211
+ }
212
+
213
+ function startEditNote(id) {
214
+ notesEditingId = id;
215
+ renderNotes();
216
+ }
217
+
218
+ function cancelEditNote() {
219
+ notesEditingId = null;
220
+ renderNotes();
221
+ }
222
+
223
+ async function saveEditNote() {
224
+ const list = document.getElementById('notes-list');
225
+ if (!list) return;
226
+ const li = list.querySelector(`li[data-id="${cssEscape(notesEditingId)}"]`);
227
+ if (!li) return;
228
+ const ta = li.querySelector('.notes-edit-text');
229
+ if (!ta) return;
230
+ const text = (ta.value || '').trim();
231
+ if (!text) {
232
+ if (confirm('文本为空,删除这条记录?')) {
233
+ await deleteNote(notesEditingId);
234
+ }
235
+ return;
236
+ }
237
+ try {
238
+ await api('PATCH', `/api/notes/${notesEditingId}`, { text }, undefined, { hub: true });
239
+ notesEditingId = null;
240
+ await loadNotes({ keepList: true });
241
+ } catch (err) {
242
+ alert('保存失败:' + (err?.message || err));
243
+ }
244
+ }
245
+
246
+ async function submitNote() {
247
+ const ta = document.getElementById('notes-add-text');
248
+ if (!ta) return;
249
+ const text = (ta.value || '').trim();
250
+ if (!text) {
251
+ ta.focus();
252
+ return;
253
+ }
254
+ try {
255
+ await api('POST', '/api/notes', { text }, undefined, { hub: true });
256
+ ta.value = '';
257
+ notesShowForm = false;
258
+ const form = document.getElementById('notes-add-form');
259
+ if (form) form.hidden = true;
260
+ const addBtn = document.querySelector('[data-notes-add]');
261
+ if (addBtn) addBtn.textContent = '+ 新增';
262
+ await loadNotes({ keepList: true });
263
+ } catch (err) {
264
+ alert('保存失败:' + (err?.message || err));
265
+ }
266
+ }
267
+
268
+ async function deleteNote(id) {
269
+ try {
270
+ await api('DELETE', `/api/notes/${id}`, undefined, undefined, { hub: true });
271
+ if (notesEditingId === id) notesEditingId = null;
272
+ await loadNotes({ keepList: true });
273
+ } catch (err) {
274
+ alert('删除失败:' + (err?.message || err));
275
+ }
276
+ }
277
+
278
+ function formatTime(ms) {
279
+ if (!ms) return '';
280
+ const d = new Date(ms);
281
+ const pad = (n) => String(n).padStart(2, '0');
282
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
283
+ }
284
+
285
+ // cssEscape polyfill (modern browsers expose CSS.escape on globalThis).
286
+ function cssEscape(value) {
287
+ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value);
288
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c);
289
+ }
290
+
291
+ function escapeHtml(value) {
292
+ return String(value ?? '')
293
+ .replace(/&/g, '&amp;')
294
+ .replace(/</g, '&lt;')
295
+ .replace(/>/g, '&gt;')
296
+ .replace(/"/g, '&quot;')
297
+ .replace(/'/g, '&#39;');
298
+ }
299
+
300
+ // Re-render if a Hub note event arrives while the page is open. The events
301
+ // come from the chat WebSocket, so we listen on ctx.ws indirectly via state.
302
+ if (typeof window !== 'undefined') {
303
+ window.addEventListener('hub:note-added', () => {
304
+ if (!document.getElementById('notes-page')?.hidden) loadNotes();
305
+ });
306
+ window.addEventListener('hub:note-updated', () => {
307
+ if (!document.getElementById('notes-page')?.hidden) loadNotes();
308
+ });
309
+ window.addEventListener('hub:note-removed', () => {
310
+ if (!document.getElementById('notes-page')?.hidden) loadNotes();
311
+ });
312
+ }
313
+
314
+ export function initNotes() {
315
+ ensureNotesBoot();
316
+ }
File without changes
File without changes
package/public/pwa.js CHANGED
@@ -69,7 +69,7 @@ export function initPwa() {
69
69
 
70
70
  // Register after first paint so install doesn't compete with boot.
71
71
  const register = () => {
72
- navigator.serviceWorker.register('/sw.js', { scope: '/' })
72
+ navigator.serviceWorker.register('/sw.js?v=33', { scope: '/' })
73
73
  .then((reg) => {
74
74
  reg.addEventListener('updatefound', () => {
75
75
  const sw = reg.installing;
package/public/router.js CHANGED
@@ -59,6 +59,10 @@ function applyHash(path) {
59
59
  document.dispatchEvent(new CustomEvent('router:settings'));
60
60
  return;
61
61
  }
62
+ if (pathname === '/notes') {
63
+ document.dispatchEvent(new CustomEvent('router:notes'));
64
+ return;
65
+ }
62
66
  // /session/:id or /session/:id/:tab
63
67
  const parsed = parseHashPath(path);
64
68
  if (!parsed) {
@@ -0,0 +1,78 @@
1
+ /** Pure helpers for chat scroll-follow. Keep reading history from being yanked. */
2
+
3
+ export const NEAR_BOTTOM_PX = 80;
4
+
5
+ export function isNearBottom(el, threshold = NEAR_BOTTOM_PX) {
6
+ if (!el) return true;
7
+ return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
8
+ }
9
+
10
+ /** Wheel toward older messages (negative deltaY) unpins immediately. */
11
+ export function followAfterWheel(deltaY, following) {
12
+ if (deltaY < 0) return false;
13
+ return following;
14
+ }
15
+
16
+ export function followAfterScroll(nearBottom) {
17
+ return nearBottom;
18
+ }
19
+
20
+ export function shouldStick({ force = false, following = true, userScrolling = false } = {}) {
21
+ if (force) return true;
22
+ if (userScrolling) return false;
23
+ return following;
24
+ }
25
+
26
+ /**
27
+ * Bind a messages container. Returns { stick, setFollowing, isFollowing, destroy }.
28
+ */
29
+ export function bindScrollFollow(el) {
30
+ let following = true;
31
+ let userScrolling = false;
32
+ let touchStartY = 0;
33
+
34
+ const onScroll = () => {
35
+ following = isNearBottom(el);
36
+ userScrolling = false;
37
+ };
38
+ const onWheel = (e) => {
39
+ following = followAfterWheel(e.deltaY, following);
40
+ if (e.deltaY < 0) userScrolling = true;
41
+ };
42
+ const onTouchStart = (e) => {
43
+ touchStartY = e.touches?.[0]?.clientY || 0;
44
+ };
45
+ const onTouchMove = (e) => {
46
+ const y = e.touches?.[0]?.clientY || 0;
47
+ if (y - touchStartY > 8) {
48
+ following = false;
49
+ userScrolling = true;
50
+ }
51
+ };
52
+
53
+ el.addEventListener('scroll', onScroll, { passive: true });
54
+ el.addEventListener('wheel', onWheel, { passive: true });
55
+ el.addEventListener('touchstart', onTouchStart, { passive: true });
56
+ el.addEventListener('touchmove', onTouchMove, { passive: true });
57
+
58
+ return {
59
+ stick(force = false) {
60
+ if (shouldStick({ force, following, userScrolling })) {
61
+ el.scrollTop = el.scrollHeight;
62
+ following = true;
63
+ }
64
+ },
65
+ setFollowing(v) {
66
+ following = !!v;
67
+ },
68
+ isFollowing() {
69
+ return following;
70
+ },
71
+ destroy() {
72
+ el.removeEventListener('scroll', onScroll);
73
+ el.removeEventListener('wheel', onWheel);
74
+ el.removeEventListener('touchstart', onTouchStart);
75
+ el.removeEventListener('touchmove', onTouchMove);
76
+ },
77
+ };
78
+ }