@khorsheed/dsh-reader 0.2.1 → 0.3.1
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/CHANGELOG.md +27 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +30580 -6892
- package/lib/index.js +4189 -621
- package/lib/typert.host.js +2265 -319
- package/lib/typert.remote-client.d.ts +99 -3
- package/lib/typert.remote-client.js +1618 -240
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +905 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +138 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +501 -28
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +82 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/image-fetch.d.ts +81 -0
- package/lib/types/image-fetch.js +385 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +127 -1
- package/lib/types/remote.js +162 -1
- package/lib/types/service.d.ts +100 -1
- package/lib/types/service.js +320 -59
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +106 -0
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +29 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
package/lib/types/service.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, emptyStateDoc, memoryTableChars, TRANSLATION_MEMORY_FILE, } from "./store.js";
|
|
2
|
-
import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, } from "./types.js";
|
|
1
|
+
import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, emptyStateDoc, memoryTableChars, resolveReaderStateRoot, TRANSLATION_MEMORY_FILE, } from "./store.js";
|
|
2
|
+
import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, isCookieHandshake, } from "./types.js";
|
|
3
3
|
import { delayUntilNext, isCatchUpDue } from "./schedule.js";
|
|
4
4
|
import { arxivHtmlUrl } from "./arxiv.js";
|
|
5
|
+
import { fetchImageBytes, ImageFetchCache } from "./image-fetch.js";
|
|
5
6
|
import { resolveLink } from "./link-resolvers.js";
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { LocalTranscriber } from "./local-transcribe.js";
|
|
9
|
+
import { channelRss, episodePage, ReaderMediaHost, resolveShortLink, syntheticLabel, videoPage } from "./media-host.js";
|
|
10
|
+
import { canonicalMediaUrl, isShortMediaLink, mediaLink } from "./media.js";
|
|
11
|
+
import { podcastPageToRss, readEpisodePage } from "./xiaoyuzhou.js";
|
|
6
12
|
/**
|
|
7
13
|
* How many entries one automatic backfill run may fetch.
|
|
8
14
|
*
|
|
@@ -13,6 +19,15 @@ import { resolveLink } from "./link-resolvers.js";
|
|
|
13
19
|
export const BACKFILL_MAX_PER_RUN = 8;
|
|
14
20
|
/** How long a FAILED fetch is left alone before it may be retried (6 hours). */
|
|
15
21
|
export const BACKFILL_RETRY_MS = 6 * 60 * 60 * 1000;
|
|
22
|
+
/**
|
|
23
|
+
* How many sources one refresh fetches at once.
|
|
24
|
+
*
|
|
25
|
+
* A strictly sequential loop made one dead source (a full 30 s timeout) stall
|
|
26
|
+
* every source behind it — the "refresh takes minutes" half of the slow-load
|
|
27
|
+
* report. Three at a time is the politeness ceiling this package applies to
|
|
28
|
+
* other people's servers everywhere (the backfill runs at two).
|
|
29
|
+
*/
|
|
30
|
+
export const REFRESH_CONCURRENCY = 3;
|
|
16
31
|
/** Cross-origin redirect hops the service will follow before giving up. */
|
|
17
32
|
const MAX_REDIRECT_HOPS = 3;
|
|
18
33
|
/** Upper bound on summaries handed to the browser in one call. */
|
|
@@ -76,11 +91,17 @@ export function classifyPayload(raw) {
|
|
|
76
91
|
/** The reader service. */
|
|
77
92
|
export class ReaderService {
|
|
78
93
|
store;
|
|
94
|
+
/** Video and audio entries: synthetic pages, channel feeds, transcripts. */
|
|
95
|
+
media;
|
|
96
|
+
/** Local transcription of entries without captions (the optional `transcribe` engine). */
|
|
97
|
+
local;
|
|
79
98
|
ctx;
|
|
80
99
|
timer;
|
|
81
100
|
disposed = false;
|
|
82
101
|
/** The document of record when no filesystem is mounted. */
|
|
83
102
|
memoryDoc;
|
|
103
|
+
/** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
|
|
104
|
+
imageCache = new ImageFetchCache();
|
|
84
105
|
/**
|
|
85
106
|
* @param ctx - owning context.
|
|
86
107
|
* @param config - optional state-root override.
|
|
@@ -88,8 +109,30 @@ export class ReaderService {
|
|
|
88
109
|
constructor(ctx, config = {}) {
|
|
89
110
|
this.ctx = ctx;
|
|
90
111
|
this.store = new ReaderStore(config);
|
|
112
|
+
const stateRoot = resolveReaderStateRoot(config.stateRoot);
|
|
113
|
+
this.media = new ReaderMediaHost(stateRoot, config.ytdlpPath);
|
|
114
|
+
this.local = new LocalTranscriber({
|
|
115
|
+
engine: () => ctx.get('transcribe'),
|
|
116
|
+
tmpRoot: join(stateRoot, 'tmp'),
|
|
117
|
+
videoAudio: (url, dir, options) => this.media.ytdlp.audio(url, dir, options),
|
|
118
|
+
store: async (entryId, url, html, language, speakers) => {
|
|
119
|
+
await this.storeEntryBody({ entryId, url, html, keep: true });
|
|
120
|
+
const before = this.media.captions(entryId);
|
|
121
|
+
// Names survive a re-transcription that finds the same number of voices.
|
|
122
|
+
const names = before?.names !== undefined && (speakers === undefined || speakers === before.speakers) ? before.names : undefined;
|
|
123
|
+
const count = speakers ?? before?.speakers;
|
|
124
|
+
this.media.remember(entryId, {
|
|
125
|
+
state: 'local',
|
|
126
|
+
...(language === undefined ? {} : { language }),
|
|
127
|
+
checkedAt: new Date().toISOString(),
|
|
128
|
+
...(count === undefined ? {} : { speakers: count }),
|
|
129
|
+
...(names === undefined ? {} : { names }),
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
});
|
|
91
133
|
ctx.effect(() => () => {
|
|
92
134
|
this.disposed = true;
|
|
135
|
+
this.local.dispose();
|
|
93
136
|
if (this.timer !== undefined)
|
|
94
137
|
clearTimeout(this.timer);
|
|
95
138
|
}, 'reader: daily refresh');
|
|
@@ -158,7 +201,7 @@ export class ReaderService {
|
|
|
158
201
|
return 'duplicate';
|
|
159
202
|
let fetched;
|
|
160
203
|
try {
|
|
161
|
-
fetched = await this.
|
|
204
|
+
fetched = await this.fetchSource(upgrade ?? url);
|
|
162
205
|
}
|
|
163
206
|
catch (error) {
|
|
164
207
|
if (upgrade !== undefined && isNoHtmlVersion(error)) {
|
|
@@ -177,7 +220,7 @@ export class ReaderService {
|
|
|
177
220
|
}
|
|
178
221
|
}
|
|
179
222
|
const feed = classifyPayload(fetched.raw) === 'feed';
|
|
180
|
-
if (!feed) {
|
|
223
|
+
if (!feed && fetched.media !== true) {
|
|
181
224
|
// A page that fetched fine can still have nothing to read: a bot
|
|
182
225
|
// challenge, a login interstitial, an empty shell. Classify BEFORE the
|
|
183
226
|
// payload is stored, so a challenge page never becomes the card's body —
|
|
@@ -186,13 +229,20 @@ export class ReaderService {
|
|
|
186
229
|
const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
|
|
187
230
|
if (problem !== undefined) {
|
|
188
231
|
return await this.saveLinkOnly({
|
|
189
|
-
|
|
232
|
+
// The address the reader gave, not where the refusal landed: a
|
|
233
|
+
// challenge redirect (mp.weixin.qq.com → /wappoc_appmsgcaptcha)
|
|
234
|
+
// would otherwise become the card's title and the URL every later
|
|
235
|
+
// fetch — the rendered one included — asks for.
|
|
236
|
+
url,
|
|
190
237
|
...(request.label === undefined ? {} : { label: request.label }),
|
|
191
238
|
...(resolvedFrom === undefined ? {} : { resolvedFrom }),
|
|
192
239
|
}, { code: problem.code, message: problem.message, at: now }, now);
|
|
193
240
|
}
|
|
194
241
|
}
|
|
195
|
-
const label = request.label
|
|
242
|
+
const label = request.label
|
|
243
|
+
?? (resolution?.kind === 'arxiv' ? resolution.title : undefined)
|
|
244
|
+
?? (fetched.media === true ? syntheticLabel(fetched.raw) : undefined)
|
|
245
|
+
?? defaultSourceLabel(fetched.url);
|
|
196
246
|
const source = {
|
|
197
247
|
id: `${feed ? 'rss' : 'link'}-${hash(fetched.url)}`,
|
|
198
248
|
kind: feed ? 'rss' : 'link',
|
|
@@ -206,7 +256,7 @@ export class ReaderService {
|
|
|
206
256
|
...(resolvedFrom === undefined ? {} : { resolvedFrom }),
|
|
207
257
|
raw: fetched.raw,
|
|
208
258
|
};
|
|
209
|
-
await this.commit(current => (
|
|
259
|
+
await this.commit(current => withNewSource(current, source));
|
|
210
260
|
return { outcome: feed ? 'subscribed' : 'saved-link', kind: source.kind, id: source.id, label };
|
|
211
261
|
}
|
|
212
262
|
/**
|
|
@@ -242,7 +292,7 @@ export class ReaderService {
|
|
|
242
292
|
failure,
|
|
243
293
|
...(request.resolvedFrom === undefined ? {} : { resolvedFrom: request.resolvedFrom }),
|
|
244
294
|
};
|
|
245
|
-
await this.commit(current => (
|
|
295
|
+
await this.commit(current => withNewSource(current, source));
|
|
246
296
|
await this.recordFetchFailure(linkEntryId(source.id), failure.message);
|
|
247
297
|
return {
|
|
248
298
|
outcome: 'saved-link',
|
|
@@ -342,63 +392,80 @@ export class ReaderService {
|
|
|
342
392
|
const results = [];
|
|
343
393
|
const patches = new Map();
|
|
344
394
|
const base = new Map(doc.sources.map(source => [source.id, source]));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Failures to record AFTER the pool: the workers below run concurrently,
|
|
397
|
+
* but every document write (a failure record is its own commit) stays
|
|
398
|
+
* single-filed here — the store's commit is a read-modify-write, and two
|
|
399
|
+
* in flight at once is how a lost update happens.
|
|
400
|
+
*/
|
|
401
|
+
const failures = [];
|
|
402
|
+
const queue = [...wanted];
|
|
403
|
+
const workers = Array.from({ length: Math.min(REFRESH_CONCURRENCY, queue.length) }, async () => {
|
|
404
|
+
for (;;) {
|
|
405
|
+
const source = queue.shift();
|
|
406
|
+
if (source === undefined)
|
|
407
|
+
return;
|
|
408
|
+
const fetchedAt = new Date().toISOString();
|
|
409
|
+
try {
|
|
410
|
+
const fetched = await this.fetchSource(source.url);
|
|
411
|
+
// The same classification the add performs: a refresh that comes back
|
|
412
|
+
// with a challenge page or a PDF must not replace a good body with it,
|
|
413
|
+
// and must record why there is nothing to read.
|
|
414
|
+
const problem = source.kind === 'link' && fetched.media !== true ? inspectPreview(fetched.raw, fetched.url, source.url) : undefined;
|
|
415
|
+
if (problem !== undefined) {
|
|
416
|
+
const failure = { code: problem.code, message: problem.message, at: fetchedAt };
|
|
417
|
+
patches.set(source.id, {
|
|
418
|
+
// Keep the last-known-good payload: a site that starts refusing us
|
|
419
|
+
// must not erase the article the reader already had.
|
|
420
|
+
// …and keep its address: the refusal's landing page is not it.
|
|
421
|
+
...withoutFailure(source),
|
|
422
|
+
fetchedAt,
|
|
423
|
+
status: 'error',
|
|
424
|
+
error: failure.message,
|
|
425
|
+
failure,
|
|
426
|
+
});
|
|
427
|
+
failures.push({ entryId: linkEntryId(source.id), message: failure.message });
|
|
428
|
+
results.push({ id: source.id, status: 'unavailable', message: failure.message });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
patches.set(source.id, {
|
|
432
|
+
...withoutFailure(source),
|
|
433
|
+
url: fetched.url,
|
|
434
|
+
raw: fetched.raw,
|
|
435
|
+
fetchedAt,
|
|
436
|
+
status: 'ok',
|
|
437
|
+
truncated: fetched.truncated,
|
|
438
|
+
});
|
|
439
|
+
results.push({
|
|
440
|
+
id: source.id,
|
|
441
|
+
status: fetched.truncated ? 'truncated' : 'ok',
|
|
442
|
+
...(fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
const failure = classifyFetchFailure(error, fetchedAt);
|
|
447
|
+
// A failed refresh keeps the previous payload (last-known-good) and
|
|
448
|
+
// records why: a transient network error must not erase the reader's
|
|
449
|
+
// copy of an article.
|
|
450
|
+
const previous = base.get(source.id);
|
|
355
451
|
patches.set(source.id, {
|
|
356
|
-
|
|
357
|
-
// must not erase the article the reader already had.
|
|
358
|
-
...withoutFailure({ ...source, url: fetched.url, ...(source.raw === undefined ? {} : { raw: source.raw }) }),
|
|
452
|
+
...(previous ?? source),
|
|
359
453
|
fetchedAt,
|
|
360
454
|
status: 'error',
|
|
361
455
|
error: failure.message,
|
|
362
456
|
failure,
|
|
363
457
|
});
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
458
|
+
results.push({
|
|
459
|
+
id: source.id,
|
|
460
|
+
status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
|
|
461
|
+
message: failure.message,
|
|
462
|
+
});
|
|
367
463
|
}
|
|
368
|
-
patches.set(source.id, {
|
|
369
|
-
...withoutFailure(source),
|
|
370
|
-
url: fetched.url,
|
|
371
|
-
raw: fetched.raw,
|
|
372
|
-
fetchedAt,
|
|
373
|
-
status: 'ok',
|
|
374
|
-
truncated: fetched.truncated,
|
|
375
|
-
});
|
|
376
|
-
results.push({
|
|
377
|
-
id: source.id,
|
|
378
|
-
status: fetched.truncated ? 'truncated' : 'ok',
|
|
379
|
-
...(fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}),
|
|
380
|
-
});
|
|
381
464
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// copy of an article.
|
|
387
|
-
const previous = base.get(source.id);
|
|
388
|
-
patches.set(source.id, {
|
|
389
|
-
...(previous ?? source),
|
|
390
|
-
fetchedAt,
|
|
391
|
-
status: 'error',
|
|
392
|
-
error: failure.message,
|
|
393
|
-
failure,
|
|
394
|
-
});
|
|
395
|
-
results.push({
|
|
396
|
-
id: source.id,
|
|
397
|
-
status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
|
|
398
|
-
message: failure.message,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
}
|
|
465
|
+
});
|
|
466
|
+
await Promise.all(workers);
|
|
467
|
+
for (const failure of failures)
|
|
468
|
+
await this.recordFetchFailure(failure.entryId, failure.message);
|
|
402
469
|
if (patches.size > 0) {
|
|
403
470
|
await this.commit(current => ({
|
|
404
471
|
...current,
|
|
@@ -531,6 +598,15 @@ export class ReaderService {
|
|
|
531
598
|
await this.markFetching(request.entryId);
|
|
532
599
|
try {
|
|
533
600
|
const fetched = await this.fetchFollowing(url);
|
|
601
|
+
// A bot challenge answers 200 with its own little page (mp.weixin.qq.com's
|
|
602
|
+
// 「环境异常」, for one): extracted, it would be stored as the article.
|
|
603
|
+
// Record it as the refusal it is, so the browser half can escalate to a
|
|
604
|
+
// rendered fetch instead of showing the challenge text as the body.
|
|
605
|
+
const wall = inspectPreview(fetched.raw, fetched.url, url);
|
|
606
|
+
if (wall?.code === 'blocked') {
|
|
607
|
+
await this.recordFetchFailure(request.entryId, wall.message, wall.code);
|
|
608
|
+
return { entryId: request.entryId, error: wall.message };
|
|
609
|
+
}
|
|
534
610
|
// The raw payload is written to disk BEFORE the caller gets it, so a
|
|
535
611
|
// browser that goes away mid-request does not cost the reader the
|
|
536
612
|
// download: the next visit finds the stored payload and extracts it.
|
|
@@ -549,6 +625,43 @@ export class ReaderService {
|
|
|
549
625
|
return { entryId: request.entryId, error: failure.message };
|
|
550
626
|
}
|
|
551
627
|
}
|
|
628
|
+
/**
|
|
629
|
+
* One image the browser could not load cross-origin, fetched host-side.
|
|
630
|
+
*
|
|
631
|
+
* The pane calls this only for an `<img>` that already FAILED: the page's
|
|
632
|
+
* bytes arrived fine (a direct fetch gets a 200) but the origin answers them
|
|
633
|
+
* with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
|
|
634
|
+
* them from this page no matter what the tag asks — measured on claude.dev's
|
|
635
|
+
* `/media/*` figures (2026-09-29), which broke with the alt text showing.
|
|
636
|
+
* CORP is an embedder-side check, so a host-process fetch is the way through.
|
|
637
|
+
*
|
|
638
|
+
* This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
|
|
639
|
+
* a closed html|text union and its provider refuses binary content types
|
|
640
|
+
* outright. The image fetch re-implements the seam's hardening at image
|
|
641
|
+
* scope instead (public-address validation with the connection pinned to the
|
|
642
|
+
* validated answers, a byte cap, a timeout, per-hop re-validation, and an
|
|
643
|
+
* image/* content-type gate) — see `image-fetch.ts`.
|
|
644
|
+
*
|
|
645
|
+
* Rescued bytes are kept in an in-memory LRU under a byte budget: the
|
|
646
|
+
* sidebar unmounts wholesale when another panel takes over, and a remount
|
|
647
|
+
* re-fails every blocked image before this cache can answer it.
|
|
648
|
+
*
|
|
649
|
+
* @param request - the image URL the pane failed on.
|
|
650
|
+
* @returns the bytes and their declared type, or why there are none.
|
|
651
|
+
*/
|
|
652
|
+
async fetchImage(request) {
|
|
653
|
+
const url = normalizeUrl(request.url);
|
|
654
|
+
if (url === undefined)
|
|
655
|
+
return { url: request.url, error: 'invalid-url' };
|
|
656
|
+
const cached = this.imageCache.get(url);
|
|
657
|
+
if (cached !== undefined)
|
|
658
|
+
return { url, mime: cached.mime, base64: cached.base64 };
|
|
659
|
+
const fetched = await fetchImageBytes(url);
|
|
660
|
+
if (!fetched.ok)
|
|
661
|
+
return { url, error: fetched.error };
|
|
662
|
+
this.imageCache.set(url, fetched.mime, fetched.base64);
|
|
663
|
+
return { url, mime: fetched.mime, base64: fetched.base64 };
|
|
664
|
+
}
|
|
552
665
|
/**
|
|
553
666
|
* Which entries have a body, a stored raw payload, or a recorded failure.
|
|
554
667
|
*
|
|
@@ -680,7 +793,7 @@ export class ReaderService {
|
|
|
680
793
|
fetchedAt: now.toISOString(),
|
|
681
794
|
// 0 = keep until the budget evicts it; a far-future date keeps the
|
|
682
795
|
// freshness check a single comparison instead of a special case.
|
|
683
|
-
expiresAt: ttlHours === 0
|
|
796
|
+
expiresAt: ttlHours === 0 || request.keep === true
|
|
684
797
|
? new Date(now.getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString()
|
|
685
798
|
: new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(),
|
|
686
799
|
url: request.url,
|
|
@@ -760,6 +873,9 @@ export class ReaderService {
|
|
|
760
873
|
continue;
|
|
761
874
|
if (entry.hasBody)
|
|
762
875
|
continue;
|
|
876
|
+
// A video or podcast page is not an article: its text is the transcript.
|
|
877
|
+
if (mediaLink(entry.url) !== undefined)
|
|
878
|
+
continue;
|
|
763
879
|
// A reason that will answer the same way forever — a bot wall, a login
|
|
764
880
|
// wall, a PDF, a 404 — is not retried on a timer: that is a crawler with
|
|
765
881
|
// a grudge, not a reader. The manual refresh in the subscription page
|
|
@@ -1370,6 +1486,128 @@ export class ReaderService {
|
|
|
1370
1486
|
}
|
|
1371
1487
|
throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
|
|
1372
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Fetch a source's payload, media-aware (media-host.ts).
|
|
1491
|
+
*
|
|
1492
|
+
* A YouTube channel is fetched as YouTube's own channel feed; a Xiaoyuzhou
|
|
1493
|
+
* podcast page as the RSS document it is turned into; a single video or
|
|
1494
|
+
* Xiaoyuzhou episode as a synthetic page carrying its media description.
|
|
1495
|
+
* Everything else is the ordinary {@link fetchFollowing}.
|
|
1496
|
+
*
|
|
1497
|
+
* @param url - the source URL.
|
|
1498
|
+
*/
|
|
1499
|
+
async fetchSource(url) {
|
|
1500
|
+
// A share short link (b23.tv) is a cross-site redirect the ordinary fetch
|
|
1501
|
+
// refuses by design: resolve it first, then treat the page it names.
|
|
1502
|
+
if (isShortMediaLink(url)) {
|
|
1503
|
+
const target = await resolveShortLink(url);
|
|
1504
|
+
if (target === '' || isShortMediaLink(target))
|
|
1505
|
+
throw new UnsupportedContent('the short link leads nowhere');
|
|
1506
|
+
return this.fetchSource(canonicalMediaUrl(target) ?? target);
|
|
1507
|
+
}
|
|
1508
|
+
const link = mediaLink(url);
|
|
1509
|
+
if (link === undefined)
|
|
1510
|
+
return this.fetchFollowing(url);
|
|
1511
|
+
if (link.provider === 'youtube' && link.kind === 'channel') {
|
|
1512
|
+
return { url, raw: channelRss(await this.media.ytdlp.channel(url), url), truncated: false };
|
|
1513
|
+
}
|
|
1514
|
+
if (link.provider === 'xiaoyuzhou') {
|
|
1515
|
+
const page = await this.fetchFollowing(url);
|
|
1516
|
+
if (link.kind === 'podcast') {
|
|
1517
|
+
const rss = podcastPageToRss(page.raw, url);
|
|
1518
|
+
if (rss === undefined)
|
|
1519
|
+
throw new UnsupportedContent('the podcast page carries no episodes');
|
|
1520
|
+
return { url, raw: rss, truncated: false };
|
|
1521
|
+
}
|
|
1522
|
+
const episode = readEpisodePage(page.raw);
|
|
1523
|
+
if (episode === undefined)
|
|
1524
|
+
throw new UnsupportedContent('the episode page carries no audio');
|
|
1525
|
+
return { url, raw: episodePage(episode), truncated: false, media: true };
|
|
1526
|
+
}
|
|
1527
|
+
if (link.kind === 'video' && (link.provider === 'youtube' || link.provider === 'bilibili')) {
|
|
1528
|
+
const clean = canonicalMediaUrl(url) ?? url;
|
|
1529
|
+
const info = await this.media.ytdlp.info(clean);
|
|
1530
|
+
return { url: clean, raw: videoPage(info, link.provider, link.id), truncated: false, media: true };
|
|
1531
|
+
}
|
|
1532
|
+
return this.fetchFollowing(url);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* A video entry's transcript: the stored one, else the platform captions
|
|
1536
|
+
* fetched now and kept (no TTL — it costs a yt-dlp run). A video known to
|
|
1537
|
+
* have no captions answers so without running yt-dlp again.
|
|
1538
|
+
*
|
|
1539
|
+
* @param request - the entry and its video page URL.
|
|
1540
|
+
* @returns the transcript markup, `none`, or why it failed.
|
|
1541
|
+
*/
|
|
1542
|
+
async mediaTranscript(request) {
|
|
1543
|
+
const known = this.media.captions(request.entryId);
|
|
1544
|
+
// `local`: the platform had none — the stored body is the local transcript.
|
|
1545
|
+
if (known?.state === 'none' || known?.state === 'local')
|
|
1546
|
+
return { entryId: request.entryId, captions: 'none' };
|
|
1547
|
+
const link = mediaLink(request.url);
|
|
1548
|
+
if (link?.kind !== 'video' || (link.provider !== 'youtube' && link.provider !== 'bilibili')) {
|
|
1549
|
+
return { entryId: request.entryId, captions: 'none' };
|
|
1550
|
+
}
|
|
1551
|
+
try {
|
|
1552
|
+
const transcript = await this.media.transcript(request.url);
|
|
1553
|
+
if (transcript === 'none') {
|
|
1554
|
+
this.media.remember(request.entryId, { state: 'none', checkedAt: new Date().toISOString() });
|
|
1555
|
+
return { entryId: request.entryId, captions: 'none' };
|
|
1556
|
+
}
|
|
1557
|
+
const stored = await this.storeEntryBody({ entryId: request.entryId, url: request.url, html: transcript.html, keep: true });
|
|
1558
|
+
this.media.remember(request.entryId, { state: 'platform', language: transcript.language, checkedAt: new Date().toISOString() });
|
|
1559
|
+
return { entryId: request.entryId, captions: 'platform', language: transcript.language, ...(stored.html === undefined ? {} : { html: stored.html }) };
|
|
1560
|
+
}
|
|
1561
|
+
catch (error) {
|
|
1562
|
+
return { entryId: request.entryId, captions: 'failed', error: error instanceof Error ? error.message : String(error) };
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
/** The reader's names for an entry's speakers, by index. */
|
|
1566
|
+
speakerNames(request) {
|
|
1567
|
+
return { ...(this.media.captions(request.entryId)?.names ?? {}) };
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Name one speaker of an entry's transcript (an empty name restores the default).
|
|
1571
|
+
*
|
|
1572
|
+
* @param request - the entry, the speaker's index, the name.
|
|
1573
|
+
*/
|
|
1574
|
+
nameSpeaker(request) {
|
|
1575
|
+
const record = this.media.captions(request.entryId);
|
|
1576
|
+
if (record === undefined)
|
|
1577
|
+
return {};
|
|
1578
|
+
const names = { ...(record.names ?? {}) };
|
|
1579
|
+
const name = request.name.trim().slice(0, 40);
|
|
1580
|
+
if (name === '')
|
|
1581
|
+
delete names[String(request.speaker)];
|
|
1582
|
+
else
|
|
1583
|
+
names[String(request.speaker)] = name;
|
|
1584
|
+
this.media.remember(request.entryId, { ...record, names });
|
|
1585
|
+
return names;
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* The wall's media cards: a live local job first, else what the captions
|
|
1589
|
+
* record says (platform captions, a stored local transcript, none).
|
|
1590
|
+
*
|
|
1591
|
+
* @param request - the card ids on screen.
|
|
1592
|
+
*/
|
|
1593
|
+
mediaStates(request) {
|
|
1594
|
+
const states = {};
|
|
1595
|
+
for (const entryId of request.entryIds) {
|
|
1596
|
+
const job = this.local.status(entryId);
|
|
1597
|
+
if (job.state === 'fetching' || job.state === 'queued' || job.state === 'transcribing') {
|
|
1598
|
+
states[entryId] = { state: job.state, ...(job.progress === undefined ? {} : { progress: job.progress }), ...(job.ahead === undefined ? {} : { ahead: job.ahead }) };
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
if (job.state === 'failed') {
|
|
1602
|
+
states[entryId] = { state: 'failed' };
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
const record = this.media.captions(entryId);
|
|
1606
|
+
if (record !== undefined)
|
|
1607
|
+
states[entryId] = { state: record.state };
|
|
1608
|
+
}
|
|
1609
|
+
return { engine: this.local.engineAvailable(), states };
|
|
1610
|
+
}
|
|
1373
1611
|
/** Arm (or re-arm) the daily refresh timer against the current document. */
|
|
1374
1612
|
async armSchedule() {
|
|
1375
1613
|
if (this.disposed)
|
|
@@ -1410,6 +1648,25 @@ export function normalizeUrl(input) {
|
|
|
1410
1648
|
}
|
|
1411
1649
|
}
|
|
1412
1650
|
/** Whether two URLs point at the same target, ignoring a trailing slash. */
|
|
1651
|
+
/**
|
|
1652
|
+
* The document with `source` added, unless an add that raced this one already
|
|
1653
|
+
* filed it.
|
|
1654
|
+
*
|
|
1655
|
+
* `addSource` checks for a duplicate BEFORE its fetch, which takes long enough
|
|
1656
|
+
* (hundreds of ms to seconds) for a second click on 「添加」 to pass the same
|
|
1657
|
+
* check; both then committed, and the reader saw the card twice under one id
|
|
1658
|
+
* (measured on 3080, 2026-10-04: two `link-1v6imqy` rows 0.7 s apart). The
|
|
1659
|
+
* re-check inside the commit is what makes the add idempotent.
|
|
1660
|
+
*
|
|
1661
|
+
* @param doc - the current document.
|
|
1662
|
+
* @param source - the source to file.
|
|
1663
|
+
* @returns the document, with the source prepended when it is new.
|
|
1664
|
+
*/
|
|
1665
|
+
function withNewSource(doc, source) {
|
|
1666
|
+
if (doc.sources.some(existing => existing.id === source.id || sameTarget(existing.url, source.url)))
|
|
1667
|
+
return doc;
|
|
1668
|
+
return { ...doc, sources: [source, ...doc.sources] };
|
|
1669
|
+
}
|
|
1413
1670
|
function sameTarget(a, b) {
|
|
1414
1671
|
const strip = (value) => value.replace(/\/+$/, '');
|
|
1415
1672
|
return strip(a) === strip(b);
|
|
@@ -1474,7 +1731,7 @@ function errorCode(error) {
|
|
|
1474
1731
|
return typeof code === 'string' ? code : undefined;
|
|
1475
1732
|
}
|
|
1476
1733
|
/** Marker words a bot wall leaves on an otherwise empty page. */
|
|
1477
|
-
const BOT_WALL_PATTERN = /just a moment|checking your browser|cf-chl|cf_chl|attention required|unusual traffic|captcha|verifying you are human|enable javascript and cookies|access denied|are you a robot
|
|
1734
|
+
const BOT_WALL_PATTERN = /just a moment|checking your browser|cf-chl|cf_chl|attention required|unusual traffic|captcha|verifying you are human|enable javascript and cookies|access denied|are you a robot|验证码|人机验证|访问验证|安全验证|环境异常|完成验证后/;
|
|
1478
1735
|
/** Marker words an authentication interstitial leaves behind. */
|
|
1479
1736
|
const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
|
|
1480
1737
|
/** Hosts whose NAME alone says the hop is an authentication wall. */
|
|
@@ -1535,6 +1792,10 @@ export function classifyFetchFailure(error, at) {
|
|
|
1535
1792
|
return { code: 'unsupported-type', message, at };
|
|
1536
1793
|
}
|
|
1537
1794
|
if (code === 'WEB_REDIRECT_BLOCKED' || /cross-origin redirect/i.test(message)) {
|
|
1795
|
+
// A cookie handshake (Springer Nature's idp bounce) is a wall a browser
|
|
1796
|
+
// walks through, not a login: `blocked` lets the pane render it.
|
|
1797
|
+
if (isCookieHandshake(message))
|
|
1798
|
+
return { code: 'blocked', message, at };
|
|
1538
1799
|
// A hop to a host whose name says "login" is a login wall, not merely a
|
|
1539
1800
|
// move: the reader's next action differs (sign in elsewhere vs paste the
|
|
1540
1801
|
// destination URL).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `reader` skill: how to discuss an article from the user's reader, when
|
|
3
|
+
* to write into its notes, and how a 「让Agent考考我」 round runs. The tool
|
|
4
|
+
* descriptions stay one or two lines; this is loaded on demand (the canvas
|
|
5
|
+
* skill's pattern).
|
|
6
|
+
*
|
|
7
|
+
* The 考考我 contract is the 2026-10-19 design (proposals/active/
|
|
8
|
+
* 2026-10-19-reader-notes-feynman.md, D5–D7): the model plays a student, asks
|
|
9
|
+
* one batch of explain-it questions, and after the user's single answer writes
|
|
10
|
+
* down only what was not yet right — never a score.
|
|
11
|
+
*
|
|
12
|
+
* @module @khorsheed/dsh-reader/skill
|
|
13
|
+
*/
|
|
14
|
+
/** The skill's catalog name. */
|
|
15
|
+
export declare const READER_SKILL_NAME = "reader";
|
|
16
|
+
/** The one catalog line the model sees before loading it. */
|
|
17
|
+
export declare const READER_SKILL_DESCRIPTION: string;
|
|
18
|
+
/** The skill body. */
|
|
19
|
+
export declare const READER_SKILL_CONTENT = "# reader \u2014 discussing an article and keeping what it came to\n\nThe user reads articles in the reader (\u7075\u611F\u7A7A\u95F4), keeps excerpts with comments (notes n1, n2\u2026), and brings them into\nthe conversation. A draft from the reader quotes notes as `n2 \u00B63\u300C\u2026\u300D` and names the article id.\n\n## Reading \u2014 reader_read\n\n- No arguments: the article this conversation discussed, else the one the user opened last. `entry` takes an id,\n a link or part of the title. Long articles page: continue with `from`.\n- The text is \u00B6-numbered; cite paragraphs as \u00B63 so the user can jump back. The notes and earlier discussion notes\n follow the text \u2014 use them, do not ask the user to paste.\n- An article the user never opened has no text on the host; say so and ask them to open it once.\n\n## Writing notes \u2014 reader_note\n\nWrite only when the user asks (\u300C\u8BB0\u5230\u7B14\u8BB0\u300D\u300C\u628A\u7ED3\u8BBA\u5B58\u4E0B\u6765\u300D\u2026) or a \u8003\u8003\u6211 round ends. Never on your own initiative,\nnever every turn. You cannot edit or delete what the user wrote; they can edit what you wrote.\n\n- A conclusion about ONE note: `op=conclusion`, `note=n2`, `text` = the conclusion in one or two sentences,\n in the user's language, as plain as possible.\n- Conclusions spanning several notes, or a whole discussion: `op=discussion`, `text` = one point per line; put\n `\u00B63` in a line to cite the paragraph it rests on.\n- If nested or optional arguments may not arrive, the text alone works: first lines `\u7B14\u8BB0\uFF1An2` or `\u7C7B\u578B\uFF1A\u8003\u8003\u6211`,\n then the body.\n\n## \u8BA9Agent\u8003\u8003\u6211 \u2014 a Feynman check, not an exam\n\nThe user finished the article and wants to see what they really understood by explaining it.\n\n1. Read the article and the notes with reader_read.\n2. Play a student who has NOT read it. Ask 3\u20135 questions in ONE message, numbered. Every question asks the user to\n explain in their own words \u2014 \u300C\u7ED9\u6211\u8BB2\u8BB2\u2026\u300D\u300C\u4E3A\u4EC0\u4E48\u2026\u300D\u300C\u6253\u4E2A\u6BD4\u65B9\uFF1F\u300D. No multiple choice, no fill-in-the-blank, no\n recall of numbers or names for their own sake.\n3. Pick questions from two places: what the user noted (do they really get it?) and the article's key points they\n did NOT note (likely skimmed).\n4. Wait for the user to answer all of them in one message. Do not judge, hint or correct while asking.\n5. Then, against the article: say in one line which answers were fine, and for each one that was wrong, missing a\n step, or vague (\u300C\u6548\u679C\u597D\u300D is vague \u2014 say what the actual reason is), give the specific explanation and the \u00B6 it\n rests on. Do not ask follow-up questions first; the user can keep talking if they want more.\n6. Write it down: `reader_note` with `op=discussion`, `kind=check`, one point per line \u2014 ONLY what was not yet\n right, each with its explanation and \u00B6; a last line \u300C\u5176\u4F59 N \u9898\u8BB2\u5230\u4F4D\u4E86\u300D when some were fine.\n\nNo score, no grade, no \"retake\": another round is simply another discussion note.\n\n## \u8BA9Agent\u6DA6\u8272\u8BD1\u6587 \u2014 reader_translate\n\nThe user asked for a better Chinese translation of the whole article than the browser's machine translation.\n\n1. Read the original with reader_read, a batch at a time (`from`). Translate from the ORIGINAL, whole paragraphs,\n with the paragraphs around them in mind: faithful, natural Chinese, not word for word. Keep names, terms and how\n each speaker is rendered consistent across batches. Do not add notes or explanations inside the translation.\n A transcript paragraph starts with `[mm:ss] \u540D\u5B57\uFF1A` \u2014 translate only the speech after it.\n2. Send each batch with reader_translate: `text` = lines `\u00B612\uFF1A\u8BD1\u6587`, one paragraph per line, the \u00B6 numbers\n reader_read showed. Its answer says how many are done and where the next batch starts.\n3. Keep going batch after batch in the same turn until it says every paragraph is done. Do not stop to ask. Follow\n any wish the user added to the request (\u300C\u53E3\u8BED\u4E00\u70B9\u300D\u2026).\n4. Finish with one line to the user: done, and anything you could not render well. Do not paste the translation\n into the conversation \u2014 it is in the reader.\n";
|
|
20
|
+
//# sourceMappingURL=skill.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `reader` skill: how to discuss an article from the user's reader, when
|
|
3
|
+
* to write into its notes, and how a 「让Agent考考我」 round runs. The tool
|
|
4
|
+
* descriptions stay one or two lines; this is loaded on demand (the canvas
|
|
5
|
+
* skill's pattern).
|
|
6
|
+
*
|
|
7
|
+
* The 考考我 contract is the 2026-10-19 design (proposals/active/
|
|
8
|
+
* 2026-10-19-reader-notes-feynman.md, D5–D7): the model plays a student, asks
|
|
9
|
+
* one batch of explain-it questions, and after the user's single answer writes
|
|
10
|
+
* down only what was not yet right — never a score.
|
|
11
|
+
*
|
|
12
|
+
* @module @khorsheed/dsh-reader/skill
|
|
13
|
+
*/
|
|
14
|
+
/** The skill's catalog name. */
|
|
15
|
+
export const READER_SKILL_NAME = 'reader';
|
|
16
|
+
/** The one catalog line the model sees before loading it. */
|
|
17
|
+
export const READER_SKILL_DESCRIPTION = 'How to work with the user\'s reader (灵感空间) articles and notes: reading with reader_read, when and how to write '
|
|
18
|
+
+ 'conclusions and discussion notes with reader_note, how a 「让Agent考考我」 (quiz me) round runs, and how to 润色 (re-translate) an article with reader_translate. Load before reader_note or reader_translate.';
|
|
19
|
+
/** The skill body. */
|
|
20
|
+
export const READER_SKILL_CONTENT = `# reader — discussing an article and keeping what it came to
|
|
21
|
+
|
|
22
|
+
The user reads articles in the reader (灵感空间), keeps excerpts with comments (notes n1, n2…), and brings them into
|
|
23
|
+
the conversation. A draft from the reader quotes notes as \`n2 ¶3「…」\` and names the article id.
|
|
24
|
+
|
|
25
|
+
## Reading — reader_read
|
|
26
|
+
|
|
27
|
+
- No arguments: the article this conversation discussed, else the one the user opened last. \`entry\` takes an id,
|
|
28
|
+
a link or part of the title. Long articles page: continue with \`from\`.
|
|
29
|
+
- The text is ¶-numbered; cite paragraphs as ¶3 so the user can jump back. The notes and earlier discussion notes
|
|
30
|
+
follow the text — use them, do not ask the user to paste.
|
|
31
|
+
- An article the user never opened has no text on the host; say so and ask them to open it once.
|
|
32
|
+
|
|
33
|
+
## Writing notes — reader_note
|
|
34
|
+
|
|
35
|
+
Write only when the user asks (「记到笔记」「把结论存下来」…) or a 考考我 round ends. Never on your own initiative,
|
|
36
|
+
never every turn. You cannot edit or delete what the user wrote; they can edit what you wrote.
|
|
37
|
+
|
|
38
|
+
- A conclusion about ONE note: \`op=conclusion\`, \`note=n2\`, \`text\` = the conclusion in one or two sentences,
|
|
39
|
+
in the user's language, as plain as possible.
|
|
40
|
+
- Conclusions spanning several notes, or a whole discussion: \`op=discussion\`, \`text\` = one point per line; put
|
|
41
|
+
\`¶3\` in a line to cite the paragraph it rests on.
|
|
42
|
+
- If nested or optional arguments may not arrive, the text alone works: first lines \`笔记:n2\` or \`类型:考考我\`,
|
|
43
|
+
then the body.
|
|
44
|
+
|
|
45
|
+
## 让Agent考考我 — a Feynman check, not an exam
|
|
46
|
+
|
|
47
|
+
The user finished the article and wants to see what they really understood by explaining it.
|
|
48
|
+
|
|
49
|
+
1. Read the article and the notes with reader_read.
|
|
50
|
+
2. Play a student who has NOT read it. Ask 3–5 questions in ONE message, numbered. Every question asks the user to
|
|
51
|
+
explain in their own words — 「给我讲讲…」「为什么…」「打个比方?」. No multiple choice, no fill-in-the-blank, no
|
|
52
|
+
recall of numbers or names for their own sake.
|
|
53
|
+
3. Pick questions from two places: what the user noted (do they really get it?) and the article's key points they
|
|
54
|
+
did NOT note (likely skimmed).
|
|
55
|
+
4. Wait for the user to answer all of them in one message. Do not judge, hint or correct while asking.
|
|
56
|
+
5. Then, against the article: say in one line which answers were fine, and for each one that was wrong, missing a
|
|
57
|
+
step, or vague (「效果好」 is vague — say what the actual reason is), give the specific explanation and the ¶ it
|
|
58
|
+
rests on. Do not ask follow-up questions first; the user can keep talking if they want more.
|
|
59
|
+
6. Write it down: \`reader_note\` with \`op=discussion\`, \`kind=check\`, one point per line — ONLY what was not yet
|
|
60
|
+
right, each with its explanation and ¶; a last line 「其余 N 题讲到位了」 when some were fine.
|
|
61
|
+
|
|
62
|
+
No score, no grade, no "retake": another round is simply another discussion note.
|
|
63
|
+
|
|
64
|
+
## 让Agent润色译文 — reader_translate
|
|
65
|
+
|
|
66
|
+
The user asked for a better Chinese translation of the whole article than the browser's machine translation.
|
|
67
|
+
|
|
68
|
+
1. Read the original with reader_read, a batch at a time (\`from\`). Translate from the ORIGINAL, whole paragraphs,
|
|
69
|
+
with the paragraphs around them in mind: faithful, natural Chinese, not word for word. Keep names, terms and how
|
|
70
|
+
each speaker is rendered consistent across batches. Do not add notes or explanations inside the translation.
|
|
71
|
+
A transcript paragraph starts with \`[mm:ss] 名字:\` — translate only the speech after it.
|
|
72
|
+
2. Send each batch with reader_translate: \`text\` = lines \`¶12:译文\`, one paragraph per line, the ¶ numbers
|
|
73
|
+
reader_read showed. Its answer says how many are done and where the next batch starts.
|
|
74
|
+
3. Keep going batch after batch in the same turn until it says every paragraph is done. Do not stop to ask. Follow
|
|
75
|
+
any wish the user added to the request (「口语一点」…).
|
|
76
|
+
4. Finish with one line to the user: done, and anything you could not render well. Do not paste the translation
|
|
77
|
+
into the conversation — it is in the reader.
|
|
78
|
+
`;
|