@restregular/dsh-notepad 0.3.1 → 0.5.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/README.md +50 -22
- package/client.js +1013 -48
- package/index.js +243 -9
- package/lib/image-store.js +50 -1
- package/lib/note-store.js +76 -9
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import path from 'node:path';
|
|
|
22
22
|
import {
|
|
23
23
|
IMAGE_TYPES,
|
|
24
24
|
MAX_IMAGE_BYTES,
|
|
25
|
+
deleteStoredImage,
|
|
25
26
|
saveClipboardImage,
|
|
26
27
|
} from './lib/image-store.js';
|
|
27
28
|
import {
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
NOTE_VERSION,
|
|
30
31
|
NoteStoreError,
|
|
31
32
|
readNote,
|
|
33
|
+
unsentCount,
|
|
32
34
|
writeNote,
|
|
33
35
|
} from './lib/note-store.js';
|
|
34
36
|
|
|
@@ -36,7 +38,7 @@ import {
|
|
|
36
38
|
const ROUTE = '/dsh-notepad/api';
|
|
37
39
|
|
|
38
40
|
/** Plugin version mirrored in package.json. */
|
|
39
|
-
const VERSION = '0.
|
|
41
|
+
const VERSION = '0.5.0';
|
|
40
42
|
|
|
41
43
|
/** Request body cap for note writes. */
|
|
42
44
|
const BODY_LIMIT = 1024 * 1024;
|
|
@@ -44,6 +46,30 @@ const BODY_LIMIT = 1024 * 1024;
|
|
|
44
46
|
/** Request body cap for base64 image uploads (decoded cap + base64 overhead). */
|
|
45
47
|
const IMAGE_BODY_LIMIT = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 64 * 1024;
|
|
46
48
|
|
|
49
|
+
/** Longest segment accepted by the AI optimizer. */
|
|
50
|
+
const MAX_OPTIMIZE_CHARS = 60_000;
|
|
51
|
+
|
|
52
|
+
/** End-to-end deadline for one AI optimization. */
|
|
53
|
+
const OPTIMIZE_TIMEOUT_MS = 120_000;
|
|
54
|
+
|
|
55
|
+
/** Default output cap for one optimized segment. */
|
|
56
|
+
const OPTIMIZE_MAX_TOKENS = 4096;
|
|
57
|
+
|
|
58
|
+
/** Entries kept in the in-process optimizer cache. */
|
|
59
|
+
const OPTIMIZE_CACHE_LIMIT = 64;
|
|
60
|
+
|
|
61
|
+
/** Maximum key + value characters kept in the optimizer cache. */
|
|
62
|
+
const OPTIMIZE_CACHE_CHARS = 2_000_000;
|
|
63
|
+
|
|
64
|
+
/** Shared instruction for the optional writing assistant. */
|
|
65
|
+
const OPTIMIZE_SYSTEM = [
|
|
66
|
+
'You are a writing assistant for a developer notepad.',
|
|
67
|
+
'Rewrite the user\'s note so it is clearer, more concise, and better organized,',
|
|
68
|
+
'while preserving its original language, meaning, technical facts, Markdown,',
|
|
69
|
+
'code, file paths, and image links. Never invent facts or drop information.',
|
|
70
|
+
'Return only the rewritten note text: no preamble, no explanation, and no code fence.',
|
|
71
|
+
].join(' ');
|
|
72
|
+
|
|
47
73
|
// ---------------------------------------------------------------------------
|
|
48
74
|
// HTTP helpers
|
|
49
75
|
// ---------------------------------------------------------------------------
|
|
@@ -174,6 +200,166 @@ async function isKnownRoot(ctx, root, sessionId) {
|
|
|
174
200
|
return false;
|
|
175
201
|
}
|
|
176
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Count pending segments for every registered workspace. The sidebar badge
|
|
205
|
+
* only needs counts, so this route never returns note text or image paths.
|
|
206
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
207
|
+
* @returns {Promise<Array<{id: string, path: string, title: string, count: number}>>}
|
|
208
|
+
*/
|
|
209
|
+
async function listUnsentCounts(ctx) {
|
|
210
|
+
const registry = ctx.get('workspaceRegistry');
|
|
211
|
+
const list = registry !== undefined && typeof registry.list === 'function' ? registry.list() : [];
|
|
212
|
+
if (!Array.isArray(list)) return [];
|
|
213
|
+
const out = [];
|
|
214
|
+
for (const workspace of list) {
|
|
215
|
+
const root = typeof workspace?.path === 'string' ? workspace.path : '';
|
|
216
|
+
if (root === '') continue;
|
|
217
|
+
let count = 0;
|
|
218
|
+
try {
|
|
219
|
+
count = unsentCount(await readNote(root));
|
|
220
|
+
} catch {
|
|
221
|
+
// A workspace directory may have disappeared; keep the other badges.
|
|
222
|
+
count = 0;
|
|
223
|
+
}
|
|
224
|
+
out.push({
|
|
225
|
+
id: String(workspace.id ?? workspace.workspaceId ?? ''),
|
|
226
|
+
path: root,
|
|
227
|
+
title: typeof workspace.title === 'string' ? workspace.title : '',
|
|
228
|
+
count,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Strip a surrounding Markdown code fence the model may add despite the
|
|
236
|
+
* instruction, then trim. Inner code fences survive.
|
|
237
|
+
* @param {string} value
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
function cleanOptimizedText(value) {
|
|
241
|
+
let text = String(value ?? '').replace(/^\uFEFF/u, '').trim();
|
|
242
|
+
const fenced = /^```[^\n]*\r?\n([\s\S]*?)\r?\n```$/u.exec(text);
|
|
243
|
+
if (fenced !== null) text = fenced[1].trim();
|
|
244
|
+
return text;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Results of earlier optimizer calls. Re-optimizing content that has not
|
|
249
|
+
* changed returns the previous answer instead of billing another model call.
|
|
250
|
+
* The cache is per host process and bounded by entry count and characters.
|
|
251
|
+
* @type {Map<string, string>}
|
|
252
|
+
*/
|
|
253
|
+
const optimizeCache = new Map();
|
|
254
|
+
let optimizeCacheChars = 0;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {string} key
|
|
258
|
+
* @param {string} text
|
|
259
|
+
*/
|
|
260
|
+
function cacheOptimizedText(key, text) {
|
|
261
|
+
const previous = optimizeCache.get(key);
|
|
262
|
+
if (previous !== undefined) {
|
|
263
|
+
optimizeCacheChars -= key.length + previous.length;
|
|
264
|
+
optimizeCache.delete(key);
|
|
265
|
+
}
|
|
266
|
+
optimizeCache.set(key, text);
|
|
267
|
+
optimizeCacheChars += key.length + text.length;
|
|
268
|
+
while (optimizeCache.size > OPTIMIZE_CACHE_LIMIT || optimizeCacheChars > OPTIMIZE_CACHE_CHARS) {
|
|
269
|
+
const oldest = optimizeCache.keys().next();
|
|
270
|
+
if (oldest.done === true) break;
|
|
271
|
+
const value = optimizeCache.get(oldest.value) ?? '';
|
|
272
|
+
optimizeCacheChars -= oldest.value.length + value.length;
|
|
273
|
+
optimizeCache.delete(oldest.value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Workspace + title + text: everything the optimizer prompt depends on. */
|
|
278
|
+
function optimizeCacheKey(root, title, text) {
|
|
279
|
+
return `${root}\u0000${title}\u0000${text}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Ask the currently selected default model to improve one note segment.
|
|
284
|
+
* The LLM service is optional: a deployment without one reports a stable
|
|
285
|
+
* `unavailable` code instead of failing the whole notepad.
|
|
286
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
287
|
+
* @param {string} text
|
|
288
|
+
* @param {string} title
|
|
289
|
+
* @returns {Promise<string>}
|
|
290
|
+
*/
|
|
291
|
+
async function optimizeText(ctx, text, title) {
|
|
292
|
+
const llm = ctx.get('llm');
|
|
293
|
+
if (llm === undefined || llm === null || typeof llm.prepareCall !== 'function'
|
|
294
|
+
|| typeof llm.resolveCallConfig !== 'function') {
|
|
295
|
+
throw new NoteStoreError('unavailable', 'The AI model service is not available.');
|
|
296
|
+
}
|
|
297
|
+
const defaultModel = ctx.get('agentDefaultModel');
|
|
298
|
+
if (defaultModel === undefined || defaultModel === null || typeof defaultModel.currentSelection !== 'function') {
|
|
299
|
+
throw new NoteStoreError('unavailable', 'No default model is configured.');
|
|
300
|
+
}
|
|
301
|
+
let selection;
|
|
302
|
+
try {
|
|
303
|
+
selection = defaultModel.currentSelection();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
throw new NoteStoreError('unavailable', `No default model is configured: ${error?.message ?? error}`);
|
|
306
|
+
}
|
|
307
|
+
if (selection === undefined || selection === null
|
|
308
|
+
|| typeof selection.provider !== 'string' || selection.provider === ''
|
|
309
|
+
|| typeof selection.model !== 'string' || selection.model === '') {
|
|
310
|
+
throw new NoteStoreError('unavailable', 'No default model is configured.');
|
|
311
|
+
}
|
|
312
|
+
const signal = AbortSignal.timeout(OPTIMIZE_TIMEOUT_MS);
|
|
313
|
+
const maxTokens = Math.min(8192, Math.max(1024, Math.ceil(text.length / 2)));
|
|
314
|
+
let base;
|
|
315
|
+
try {
|
|
316
|
+
base = await llm.resolveCallConfig({
|
|
317
|
+
provider: selection.provider,
|
|
318
|
+
model: selection.model,
|
|
319
|
+
maxTokens,
|
|
320
|
+
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
throw new NoteStoreError('llm-failed', `Could not prepare the model call: ${error?.message ?? error}`);
|
|
323
|
+
}
|
|
324
|
+
if (typeof base !== 'object' || base === null) {
|
|
325
|
+
throw new NoteStoreError('llm-failed', 'The model call configuration is empty.');
|
|
326
|
+
}
|
|
327
|
+
const heading = title.trim() === '' ? '' : `Card title: ${title.trim()}\n\n`;
|
|
328
|
+
const prompt = `${heading}Rewrite this note:\n\n${text}`;
|
|
329
|
+
let output = '';
|
|
330
|
+
try {
|
|
331
|
+
const prepared = await llm.prepareCall(base, signal);
|
|
332
|
+
for await (const chunk of prepared.stream({
|
|
333
|
+
...base,
|
|
334
|
+
system: OPTIMIZE_SYSTEM,
|
|
335
|
+
messages: [{
|
|
336
|
+
role: 'user',
|
|
337
|
+
content: [{ type: 'text', text: prompt }],
|
|
338
|
+
}],
|
|
339
|
+
signal,
|
|
340
|
+
})) {
|
|
341
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') output += chunk.text;
|
|
342
|
+
else if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') {
|
|
343
|
+
const failure = chunk.reason.failure;
|
|
344
|
+
throw new NoteStoreError('llm-failed', `The model call failed: ${failure?.message ?? 'unknown error'}`);
|
|
345
|
+
} else if (chunk?.type === 'finish' && chunk.reason?.kind === 'aborted') {
|
|
346
|
+
throw new NoteStoreError('timeout', 'The model did not answer in time.');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error instanceof NoteStoreError) throw error;
|
|
351
|
+
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
|
|
352
|
+
throw new NoteStoreError('timeout', 'The model did not answer in time.');
|
|
353
|
+
}
|
|
354
|
+
throw new NoteStoreError('llm-failed', `The model call failed: ${error?.message ?? error}`);
|
|
355
|
+
}
|
|
356
|
+
const cleaned = cleanOptimizedText(output);
|
|
357
|
+
if (cleaned === '') {
|
|
358
|
+
throw new NoteStoreError('llm-failed', 'The model returned no text.');
|
|
359
|
+
}
|
|
360
|
+
return cleaned;
|
|
361
|
+
}
|
|
362
|
+
|
|
177
363
|
// ---------------------------------------------------------------------------
|
|
178
364
|
// Route
|
|
179
365
|
// ---------------------------------------------------------------------------
|
|
@@ -190,9 +376,12 @@ export async function handleRequest(ctx, req, res) {
|
|
|
190
376
|
} catch (error) {
|
|
191
377
|
if (error instanceof NoteStoreError) {
|
|
192
378
|
let status = 400;
|
|
193
|
-
if (error.code === 'unknown-workspace' || error.code === 'bad-root') status = 403;
|
|
379
|
+
if (error.code === 'unknown-workspace' || error.code === 'bad-root' || error.code === 'not-owned') status = 403;
|
|
194
380
|
else if (error.code === 'too-large') status = 413;
|
|
195
381
|
else if (error.code === 'unsupported-type') status = 415;
|
|
382
|
+
else if (error.code === 'unavailable') status = 503;
|
|
383
|
+
else if (error.code === 'timeout') status = 504;
|
|
384
|
+
else if (error.code === 'llm-failed') status = 502;
|
|
196
385
|
sendJson(res, { ok: false, code: error.code, error: error.message }, status);
|
|
197
386
|
return;
|
|
198
387
|
}
|
|
@@ -230,18 +419,63 @@ async function dispatchRequest(ctx, req, res) {
|
|
|
230
419
|
return;
|
|
231
420
|
}
|
|
232
421
|
if (action === 'image') {
|
|
422
|
+
if (req.method === 'POST') {
|
|
423
|
+
const body = await readJsonBody(req, IMAGE_BODY_LIMIT);
|
|
424
|
+
const root = await resolveRequestRoot(ctx, body);
|
|
425
|
+
const image = await saveClipboardImage(root, {
|
|
426
|
+
name: body.name,
|
|
427
|
+
mime: body.mime,
|
|
428
|
+
data: body.data,
|
|
429
|
+
});
|
|
430
|
+
sendJson(res, { ok: true, root, ...image });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (req.method === 'DELETE') {
|
|
434
|
+
const body = await readJsonBody(req);
|
|
435
|
+
const root = await resolveRequestRoot(ctx, body);
|
|
436
|
+
const result = await deleteStoredImage(root, body.path);
|
|
437
|
+
sendJson(res, { ok: true, root, ...result });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
sendJson(res, { ok: false, code: 'method', error: 'Use POST or DELETE.' }, 405);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (action === 'unsent') {
|
|
444
|
+
if (req.method !== 'GET') {
|
|
445
|
+
sendJson(res, { ok: false, code: 'method', error: 'Use GET.' }, 405);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const workspaces = await listUnsentCounts(ctx);
|
|
449
|
+
sendJson(res, { ok: true, workspaces });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (action === 'optimize') {
|
|
233
453
|
if (req.method !== 'POST') {
|
|
234
454
|
sendJson(res, { ok: false, code: 'method', error: 'Use POST.' }, 405);
|
|
235
455
|
return;
|
|
236
456
|
}
|
|
237
|
-
const body = await readJsonBody(req
|
|
457
|
+
const body = await readJsonBody(req);
|
|
238
458
|
const root = await resolveRequestRoot(ctx, body);
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
459
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
460
|
+
if (text.trim() === '') {
|
|
461
|
+
throw new NoteStoreError('bad-input', 'There is no text to optimize.');
|
|
462
|
+
}
|
|
463
|
+
if (text.length > MAX_OPTIMIZE_CHARS) {
|
|
464
|
+
throw new NoteStoreError(
|
|
465
|
+
'too-large',
|
|
466
|
+
`Segment is too large to optimize (${text.length} characters; limit ${MAX_OPTIMIZE_CHARS}).`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const title = typeof body.title === 'string' ? body.title : '';
|
|
470
|
+
const cacheKey = optimizeCacheKey(root, title, text);
|
|
471
|
+
const cached = optimizeCache.get(cacheKey);
|
|
472
|
+
if (typeof cached === 'string' && cached !== '') {
|
|
473
|
+
sendJson(res, { ok: true, root, text: cached, cached: true });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const optimized = await optimizeText(ctx, text, title);
|
|
477
|
+
cacheOptimizedText(cacheKey, optimized);
|
|
478
|
+
sendJson(res, { ok: true, root, text: optimized, cached: false });
|
|
245
479
|
return;
|
|
246
480
|
}
|
|
247
481
|
if (action !== 'note') {
|
package/lib/image-store.js
CHANGED
|
@@ -129,6 +129,55 @@ function displayName(name) {
|
|
|
129
129
|
return cleaned === '' ? 'image' : cleaned;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Absolute plugin-owned image directory for one workspace.
|
|
134
|
+
* @param {string} root workspace root
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function imageDirOf(root) {
|
|
138
|
+
return path.join(root, ...IMAGE_DIR.split('/'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Delete one image previously stored under the plugin directory.
|
|
143
|
+
*
|
|
144
|
+
* The client may only remove files this plugin owns: the resolved target must
|
|
145
|
+
* be a regular file inside {@link IMAGE_DIR}. Paths outside that directory are
|
|
146
|
+
* refused instead of being passed to `fs.rm`.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} root workspace root
|
|
149
|
+
* @param {unknown} pathValue workspace-relative or absolute image path
|
|
150
|
+
* @returns {Promise<{deleted: boolean}>}
|
|
151
|
+
*/
|
|
152
|
+
export async function deleteStoredImage(root, pathValue) {
|
|
153
|
+
if (typeof pathValue !== 'string' || pathValue.trim() === '') {
|
|
154
|
+
throw new NoteStoreError('bad-input', 'Image path is required.');
|
|
155
|
+
}
|
|
156
|
+
const dir = imageDirOf(root);
|
|
157
|
+
const resolved = path.resolve(root, pathValue.replace(/\\/gu, '/'));
|
|
158
|
+
const relative = path.relative(dir, resolved);
|
|
159
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
160
|
+
throw new NoteStoreError('not-owned', 'Only images stored by the notepad can be deleted.');
|
|
161
|
+
}
|
|
162
|
+
let stat;
|
|
163
|
+
try {
|
|
164
|
+
stat = await fs.lstat(resolved);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error?.code === 'ENOENT') return { deleted: false };
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
if (!stat.isFile()) {
|
|
170
|
+
throw new NoteStoreError('not-owned', 'Only regular image files can be deleted.');
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await fs.unlink(resolved);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error?.code === 'ENOENT') return { deleted: false };
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
return { deleted: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
132
181
|
/**
|
|
133
182
|
* Save one pasted image under the plugin directory.
|
|
134
183
|
*
|
|
@@ -155,7 +204,7 @@ export async function saveClipboardImage(root, input) {
|
|
|
155
204
|
const extension = IMAGE_TYPES[declared];
|
|
156
205
|
const name = displayName(input?.name);
|
|
157
206
|
const stem = fileStem(name);
|
|
158
|
-
const dir =
|
|
207
|
+
const dir = imageDirOf(root);
|
|
159
208
|
await fs.mkdir(dir, { recursive: true });
|
|
160
209
|
const fileName = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}-${stem}.${extension}`;
|
|
161
210
|
const file = path.join(dir, fileName);
|
package/lib/note-store.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* ordered list of text segments; each segment may be archived after it was
|
|
6
6
|
* sent, and only archived segments can be deleted by the client.
|
|
7
7
|
*
|
|
8
|
-
* Version
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Version 4 adds an optional card title and creation timestamp to every
|
|
9
|
+
* segment. Version 2 stores the segment list under `segments` and repeats the
|
|
10
|
+
* merged body under `text` so the file stays human readable and older builds
|
|
11
|
+
* can still read the content. Version 1 files (a single `text`) and
|
|
12
|
+
* hand-edited plain-text files are migrated on read as one segment.
|
|
12
13
|
*
|
|
13
14
|
* @module dsh-notepad/lib/note-store
|
|
14
15
|
*/
|
|
@@ -32,8 +33,14 @@ export const MAX_SEGMENTS = 200;
|
|
|
32
33
|
/** Maximum characters in one segment. */
|
|
33
34
|
export const MAX_SEGMENT_CHARS = 160_000;
|
|
34
35
|
|
|
36
|
+
/** Maximum characters kept for one segment title. */
|
|
37
|
+
export const MAX_SEGMENT_TITLE_CHARS = 200;
|
|
38
|
+
|
|
39
|
+
/** Maximum characters kept for one creation timestamp. */
|
|
40
|
+
export const MAX_SEGMENT_TIME_CHARS = 64;
|
|
41
|
+
|
|
35
42
|
/** Storage envelope version. */
|
|
36
|
-
export const NOTE_VERSION =
|
|
43
|
+
export const NOTE_VERSION = 4;
|
|
37
44
|
|
|
38
45
|
/** Maximum image references accepted on one segment. */
|
|
39
46
|
export const MAX_IMAGES_PER_SEGMENT = 20;
|
|
@@ -184,7 +191,7 @@ export function segmentBody(segment) {
|
|
|
184
191
|
* @param {unknown} value
|
|
185
192
|
* @param {number} index
|
|
186
193
|
* @param {Set<string>} seen
|
|
187
|
-
* @returns {{id: string, text: string, images: Array<{path: string, name: string}>, archived: boolean, archivedAt: string | null} | null}
|
|
194
|
+
* @returns {{id: string, text: string, title: string, createdAt: string | null, images: Array<{path: string, name: string}>, archived: boolean, archivedAt: string | null} | null}
|
|
188
195
|
*/
|
|
189
196
|
function readSegment(value, index, seen) {
|
|
190
197
|
if (typeof value === 'string') value = { text: value };
|
|
@@ -199,11 +206,15 @@ function readSegment(value, index, seen) {
|
|
|
199
206
|
suffix += 1;
|
|
200
207
|
}
|
|
201
208
|
seen.add(id);
|
|
209
|
+
const title = typeof value.title === 'string' ? value.title.slice(0, MAX_SEGMENT_TITLE_CHARS) : '';
|
|
210
|
+
const createdAt = typeof value.createdAt === 'string' && value.createdAt !== ''
|
|
211
|
+
? value.createdAt.slice(0, MAX_SEGMENT_TIME_CHARS)
|
|
212
|
+
: null;
|
|
202
213
|
const archived = value.archived === true;
|
|
203
214
|
const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
|
|
204
215
|
? value.archivedAt
|
|
205
216
|
: null;
|
|
206
|
-
return { id, text, images, archived, archivedAt };
|
|
217
|
+
return { id, text, title, createdAt, images, archived, archivedAt };
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
/**
|
|
@@ -219,7 +230,7 @@ function readSegment(value, index, seen) {
|
|
|
219
230
|
export function normalizeSegments(input) {
|
|
220
231
|
if (typeof input === 'string') {
|
|
221
232
|
if (input === '') return [];
|
|
222
|
-
return [{ id: 'seg-1', text: input, images: [], archived: false, archivedAt: null }];
|
|
233
|
+
return [{ id: 'seg-1', text: input, title: '', createdAt: null, images: [], archived: false, archivedAt: null }];
|
|
223
234
|
}
|
|
224
235
|
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
|
|
225
236
|
if (Array.isArray(input.segments)) return normalizeSegments(input.segments);
|
|
@@ -271,7 +282,61 @@ function normalizeWriteSegment(value, index, seen) {
|
|
|
271
282
|
const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
|
|
272
283
|
? value.archivedAt
|
|
273
284
|
: null;
|
|
274
|
-
|
|
285
|
+
const title = value.title === undefined || value.title === null
|
|
286
|
+
? ''
|
|
287
|
+
: typeof value.title === 'string'
|
|
288
|
+
? value.title.slice(0, MAX_SEGMENT_TITLE_CHARS)
|
|
289
|
+
: invalidTitle(index);
|
|
290
|
+
const createdAt = value.createdAt === undefined || value.createdAt === null
|
|
291
|
+
? null
|
|
292
|
+
: typeof value.createdAt === 'string'
|
|
293
|
+
? value.createdAt.slice(0, MAX_SEGMENT_TIME_CHARS)
|
|
294
|
+
: invalidCreatedAt(index);
|
|
295
|
+
return { id, text: value.text, title, createdAt, images, archived, archivedAt };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {number} index
|
|
300
|
+
* @returns {never}
|
|
301
|
+
*/
|
|
302
|
+
function invalidTitle(index) {
|
|
303
|
+
throw new NoteStoreError('bad-input', `Segment ${index + 1} title is not a string.`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @param {number} index
|
|
308
|
+
* @returns {never}
|
|
309
|
+
*/
|
|
310
|
+
function invalidCreatedAt(index) {
|
|
311
|
+
throw new NoteStoreError('bad-input', `Segment ${index + 1} createdAt is not a string.`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Whether one segment still counts as unsent work: it is active and carries
|
|
316
|
+
* text or at least one image. Archived segments were already sent or filed
|
|
317
|
+
* away, so they never contribute to the pending badge.
|
|
318
|
+
* @param {{text?: string, images?: unknown[], archived?: boolean}} segment
|
|
319
|
+
* @returns {boolean}
|
|
320
|
+
*/
|
|
321
|
+
export function isUnsentSegment(segment) {
|
|
322
|
+
if (segment === null || typeof segment !== 'object' || segment.archived === true) return false;
|
|
323
|
+
const text = typeof segment.text === 'string' ? segment.text : '';
|
|
324
|
+
const images = Array.isArray(segment.images) ? segment.images : [];
|
|
325
|
+
return text.trim() !== '' || images.length > 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Number of pending segments in one decoded note.
|
|
330
|
+
* @param {{segments?: Array<{text?: string, images?: unknown[], archived?: boolean}>}} note
|
|
331
|
+
* @returns {number}
|
|
332
|
+
*/
|
|
333
|
+
export function unsentCount(note) {
|
|
334
|
+
const segments = Array.isArray(note?.segments) ? note.segments : [];
|
|
335
|
+
let count = 0;
|
|
336
|
+
for (const segment of segments) {
|
|
337
|
+
if (isUnsentSegment(segment)) count += 1;
|
|
338
|
+
}
|
|
339
|
+
return count;
|
|
275
340
|
}
|
|
276
341
|
|
|
277
342
|
/**
|
|
@@ -367,6 +432,8 @@ export async function writeNote(root, note, options = {}) {
|
|
|
367
432
|
segments: segments.map((segment) => ({
|
|
368
433
|
id: segment.id,
|
|
369
434
|
text: segment.text,
|
|
435
|
+
...(segment.title !== '' ? { title: segment.title } : {}),
|
|
436
|
+
...(segment.createdAt !== null ? { createdAt: segment.createdAt } : {}),
|
|
370
437
|
...(segment.images.length > 0
|
|
371
438
|
? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
|
|
372
439
|
: {}),
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Notepad",
|
|
4
|
-
"description": "A per-workspace floating notepad in the session view.
|
|
4
|
+
"description": "A per-workspace floating notepad in the session view. New segment cards lead the list; an empty title is just a placeholder until clicked. Click an index to select, drag to reorder, merge a selection, move between cards with the arrow keys, and improve only the selected cards with AI — the button turns into undo and identical content reuses the previous answer. Paste or pick images as path-based chips; removing a chip cleans up plugin-owned files. Pending counts appear on the hidden launcher and the sidebar workspace row. Insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Auto refresh keeps the list scroll position, Ctrl+Shift+0 resets the panel geometry, the panel never covers the host title bar, and it is resizable and dark-mode aware."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "记事本",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "在会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶,空标题点击后才显示输入框,点击序号选中,可拖拽排序、合并多段、用上下方向键切换分段;AI 优化只作用于选中的分段,优化后按钮变为撤销,相同内容直接复用上次结果;支持粘贴或选择图片并以路径附件形式引入,删除图片芯片会清理插件目录里的文件;隐藏按钮和侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送后自动归档,归档可显示、隐藏、恢复与删除;自动刷新保持滚动位置,浮窗可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,且不会越过窗口标题栏。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@restregular/dsh-notepad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view:
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with titles that appear only after a click, drag to reorder, merge selected segments, arrow keys to move between cards, AI improve limited to the selected segments with a one-click undo and cached results, image chips with automatic file cleanup, pending-count badges on the hidden button and the sidebar workspace, batch insert / replace / send, archive management, scroll position kept across automatic refreshes, resizable panel, Ctrl+Shift+0 geometry reset, a title-bar-safe position, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且空标题只在点击后显示输入框,可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化,优化后按钮变为撤销并复用相同内容的缓存结果;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除;自动刷新保持列表滚动位置;可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|