dsh-smooth-stream 0.6.0 → 0.6.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/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +406 -77
- package/lib/client.js.map +1 -1
- package/lib/index.js +203 -1
- package/package.json +1 -1
- package/src/client/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.tsx +13 -0
- package/src/client/TypewriterAssistantNodeView.tsx +16 -2
- package/src/client/index.ts +2 -0
- package/src/client/locales.ts +5 -0
- package/src/client/smooth-stream-card-controller.ts +6 -3
- package/src/client/smooth-stream-settings-api.ts +5 -3
- package/src/client/teleprompterGlide.ts +8 -18
- package/src/client/useLogarithmicFade.ts +293 -0
- package/src/plugin.ts +16 -4
- package/src/settings-api.ts +2 -0
- package/src/settings-channel.ts +277 -0
- package/src/settings.ts +3 -0
package/lib/index.js
CHANGED
|
@@ -136,6 +136,196 @@ function updateNpmProfilePackage(profileDir, packageName) {
|
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
138
|
//#endregion
|
|
139
|
+
//#region src/settings-channel.ts
|
|
140
|
+
/** Largest request body this channel buffers; settings payloads are tiny. */
|
|
141
|
+
const MAX_REQUEST_BYTES = 1 << 20;
|
|
142
|
+
/** Endpoint segment shape accepted by the Connection router, mirrored here. */
|
|
143
|
+
const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
|
|
144
|
+
/** Envelope discriminator the browser caller sends. */
|
|
145
|
+
const CLIENT_REQUEST = "client-request";
|
|
146
|
+
/** Envelope discriminator the browser caller expects back. */
|
|
147
|
+
const SERVER_RESPONSE = "server-response";
|
|
148
|
+
/**
|
|
149
|
+
* Register the settings RPC channel on the most capable path the kernel has.
|
|
150
|
+
* The registration is an effect of `ctx`, so the caller's fiber owns the
|
|
151
|
+
* channel's lifetime on either path.
|
|
152
|
+
* @param ctx - Host context carrying `connection` and, when composed, `webServer`.
|
|
153
|
+
* @param channel - absolute channel prefix, e.g. `/smooth-stream`.
|
|
154
|
+
* @param handler - decoded endpoint handler for every endpoint on the channel.
|
|
155
|
+
* @returns Disposer that withdraws the channel.
|
|
156
|
+
*/
|
|
157
|
+
function registerSettingsChannel(ctx, channel, handler) {
|
|
158
|
+
const connection = ctx.get("connection");
|
|
159
|
+
return ctx.effect(() => {
|
|
160
|
+
const viaService = tryServiceChannel(connection, channel, handler);
|
|
161
|
+
if (viaService !== void 0) return viaService;
|
|
162
|
+
return mountDirectRoute(ctx, connection, channel, handler);
|
|
163
|
+
}, `dsh-smooth-stream: ${channel} RPC channel`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Try the service-owned registration, which is the only path that also carries
|
|
167
|
+
* the kernel's own channel policy.
|
|
168
|
+
* @returns Disposer on success, `undefined` when the kernel cannot mount it.
|
|
169
|
+
*/
|
|
170
|
+
function tryServiceChannel(connection, channel, handler) {
|
|
171
|
+
const rpc = connection?.rpc;
|
|
172
|
+
if (rpc === void 0 || typeof rpc.handle !== "function") return void 0;
|
|
173
|
+
try {
|
|
174
|
+
const release = rpc.handle(channel, handler, { authority: "loopback" });
|
|
175
|
+
if (typeof release !== "function") return void 0;
|
|
176
|
+
return () => {
|
|
177
|
+
release();
|
|
178
|
+
};
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.warn(`[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; falling back to a directly registered route`, error);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Mount the channel prefix on the Web server with the connection fence intact.
|
|
186
|
+
* @throws when the kernel exposes neither the fence nor a Web server, so the
|
|
187
|
+
* failure is reported instead of silently leaving the card unreachable.
|
|
188
|
+
*/
|
|
189
|
+
function mountDirectRoute(ctx, connection, channel, handler) {
|
|
190
|
+
const webServer = ctx.get("webServer");
|
|
191
|
+
const reject = connection?.requestRejection;
|
|
192
|
+
if (webServer === void 0 || typeof webServer.register !== "function") throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`);
|
|
193
|
+
if (typeof reject !== "function") throw new Error(`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} cannot be mounted behind the connection trust fence`);
|
|
194
|
+
return webServer.register({
|
|
195
|
+
kind: "prefix",
|
|
196
|
+
path: channel,
|
|
197
|
+
handler: async (req, res) => {
|
|
198
|
+
const rejection = reject.call(connection, req);
|
|
199
|
+
if (rejection !== void 0) {
|
|
200
|
+
res.writeHead(rejection);
|
|
201
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await answer(req, res, channel, handler);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Decode one request envelope, dispatch it, and write the decoded reply.
|
|
210
|
+
* Mirrors the Connection router's own carrier rules: method and endpoint must
|
|
211
|
+
* agree, the body must be JSON, and only endpoint failures are results while
|
|
212
|
+
* transport failures are statuses.
|
|
213
|
+
*/
|
|
214
|
+
async function answer(req, res, channel, handler) {
|
|
215
|
+
const endpoint = endpointOf(channel, req.url);
|
|
216
|
+
if (req.method !== "POST" || endpoint === void 0) {
|
|
217
|
+
res.writeHead(404);
|
|
218
|
+
res.end("not found");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if ((req.headers["content-type"] ?? "").split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
222
|
+
res.writeHead(415);
|
|
223
|
+
res.end("content type must be application/json");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const body = await readBody(req);
|
|
227
|
+
if (body.kind === "too-large") {
|
|
228
|
+
res.writeHead(413);
|
|
229
|
+
res.end("request body too large");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (body.kind === "invalid") {
|
|
233
|
+
res.writeHead(400);
|
|
234
|
+
res.end("body is not JSON");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const envelope = requestEnvelope(body.value);
|
|
238
|
+
if (envelope === void 0) {
|
|
239
|
+
write(res, rpcIdOf(body.value), {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: {
|
|
242
|
+
code: "gateway/bad-request",
|
|
243
|
+
message: "invalid client-request message",
|
|
244
|
+
details: { issues: [] }
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (envelope.method !== endpoint) {
|
|
250
|
+
write(res, envelope.rpcId, {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: {
|
|
253
|
+
code: "gateway/bad-request",
|
|
254
|
+
message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
|
255
|
+
details: { issues: [] }
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const controller = new AbortController();
|
|
261
|
+
res.on("close", () => {
|
|
262
|
+
controller.abort();
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal));
|
|
266
|
+
} catch (error) {
|
|
267
|
+
res.writeHead(500);
|
|
268
|
+
res.end(`handler failure: ${String(error)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the channel-relative endpoint for one request path.
|
|
273
|
+
* @returns The endpoint, or `undefined` when the path is outside the channel
|
|
274
|
+
* or carries a segment the Connection router would refuse.
|
|
275
|
+
*/
|
|
276
|
+
function endpointOf(channel, rawUrl) {
|
|
277
|
+
const pathname = new URL(rawUrl ?? "/", "http://dsh.internal").pathname;
|
|
278
|
+
if (!pathname.startsWith(`${channel}/`)) return void 0;
|
|
279
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
280
|
+
if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT.test(segment))) return;
|
|
281
|
+
return endpoint;
|
|
282
|
+
}
|
|
283
|
+
/** Structure one decoded request, rejecting anything the caller could not have sent. */
|
|
284
|
+
function requestEnvelope(value) {
|
|
285
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
286
|
+
const record = value;
|
|
287
|
+
if (record.type !== CLIENT_REQUEST) return void 0;
|
|
288
|
+
if (typeof record.rpcId !== "string" || typeof record.method !== "string") return void 0;
|
|
289
|
+
return {
|
|
290
|
+
rpcId: record.rpcId,
|
|
291
|
+
method: record.method,
|
|
292
|
+
payload: record.payload
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** Recover the correlation id of a malformed envelope so the caller can match it. */
|
|
296
|
+
function rpcIdOf(value) {
|
|
297
|
+
const raw = value?.rpcId;
|
|
298
|
+
return typeof raw === "string" ? raw : "invalid-request";
|
|
299
|
+
}
|
|
300
|
+
/** Write one decoded reply in the envelope the browser caller parses. */
|
|
301
|
+
function write(res, rpcId, result) {
|
|
302
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
303
|
+
res.end(JSON.stringify({
|
|
304
|
+
type: SERVER_RESPONSE,
|
|
305
|
+
rpcId,
|
|
306
|
+
result
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
/** Buffer one request body, refusing both malformed JSON and unbounded input. */
|
|
310
|
+
async function readBody(req) {
|
|
311
|
+
const chunks = [];
|
|
312
|
+
let bytes = 0;
|
|
313
|
+
for await (const chunk of req) {
|
|
314
|
+
const buffer = chunk;
|
|
315
|
+
bytes += buffer.length;
|
|
316
|
+
if (bytes > MAX_REQUEST_BYTES) return { kind: "too-large" };
|
|
317
|
+
chunks.push(buffer);
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
return {
|
|
321
|
+
kind: "value",
|
|
322
|
+
value: JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
|
323
|
+
};
|
|
324
|
+
} catch {
|
|
325
|
+
return { kind: "invalid" };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
//#endregion
|
|
139
329
|
//#region src/settings-api.ts
|
|
140
330
|
/** Shared wire vocabulary for the plugin-owned settings RPC channel. */
|
|
141
331
|
/** Dedicated, loopback-only RPC channel registered by the Host half. */
|
|
@@ -166,6 +356,7 @@ const DEFAULT_STREAM_SETTINGS = {
|
|
|
166
356
|
controlScroll: true,
|
|
167
357
|
motionPreference: "auto",
|
|
168
358
|
thinkAutoExpand: true,
|
|
359
|
+
logarithmicFade: true,
|
|
169
360
|
debugEnabled: false,
|
|
170
361
|
debugTuning: {
|
|
171
362
|
revealScale: 1,
|
|
@@ -207,6 +398,7 @@ const StreamSettingsSchema = Schema.object({
|
|
|
207
398
|
Schema.const("force-reduced")
|
|
208
399
|
]).default(DEFAULT_STREAM_SETTINGS.motionPreference),
|
|
209
400
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
401
|
+
logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
|
|
210
402
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
211
403
|
debugTuning: Schema.object({
|
|
212
404
|
revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
|
|
@@ -249,6 +441,7 @@ function apply(ctx, config) {
|
|
|
249
441
|
controlScroll: settings.controlScroll,
|
|
250
442
|
motionPreference: settings.motionPreference,
|
|
251
443
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
444
|
+
logarithmicFade: settings.logarithmicFade,
|
|
252
445
|
canUpgrade: installation.kind === "npm"
|
|
253
446
|
};
|
|
254
447
|
};
|
|
@@ -296,6 +489,14 @@ function apply(ctx, config) {
|
|
|
296
489
|
details: { ns: STREAM_SETTINGS_NS }
|
|
297
490
|
}
|
|
298
491
|
};
|
|
492
|
+
if (next.logarithmicFade !== void 0 && typeof next.logarithmicFade !== "boolean") return {
|
|
493
|
+
ok: false,
|
|
494
|
+
error: {
|
|
495
|
+
code: "settings-rejected",
|
|
496
|
+
message: "logarithmicFade must be a boolean",
|
|
497
|
+
details: { ns: STREAM_SETTINGS_NS }
|
|
498
|
+
}
|
|
499
|
+
};
|
|
299
500
|
const hasDebug = next.debugEnabled !== void 0 || next.debugTuning !== void 0;
|
|
300
501
|
if (hasDebug && (typeof next.debugEnabled !== "boolean" || !validDebugTuning(next.debugTuning))) return {
|
|
301
502
|
ok: false,
|
|
@@ -310,6 +511,7 @@ function apply(ctx, config) {
|
|
|
310
511
|
controlScroll: next.controlScroll,
|
|
311
512
|
...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
|
|
312
513
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
514
|
+
...next.logarithmicFade === void 0 ? {} : { logarithmicFade: next.logarithmicFade },
|
|
313
515
|
...hasDebug ? {
|
|
314
516
|
debugEnabled: next.debugEnabled,
|
|
315
517
|
debugTuning: next.debugTuning
|
|
@@ -427,7 +629,7 @@ function apply(ctx, config) {
|
|
|
427
629
|
}
|
|
428
630
|
};
|
|
429
631
|
};
|
|
430
|
-
connectionCtx
|
|
632
|
+
registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle);
|
|
431
633
|
});
|
|
432
634
|
});
|
|
433
635
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* Shared buckets change paint only; original DOM, layout and colors survive. */
|
|
2
|
+
.scope::highlight(dsh-smooth-stream-log-fade-0),
|
|
3
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-0) {
|
|
4
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 0.00000%, transparent);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.scope::highlight(dsh-smooth-stream-log-fade-1),
|
|
8
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-1) {
|
|
9
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 3.22581%, transparent);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.scope::highlight(dsh-smooth-stream-log-fade-2),
|
|
13
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-2) {
|
|
14
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 6.45161%, transparent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.scope::highlight(dsh-smooth-stream-log-fade-3),
|
|
18
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-3) {
|
|
19
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 9.67742%, transparent);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.scope::highlight(dsh-smooth-stream-log-fade-4),
|
|
23
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-4) {
|
|
24
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 12.90323%, transparent);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.scope::highlight(dsh-smooth-stream-log-fade-5),
|
|
28
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-5) {
|
|
29
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 16.12903%, transparent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.scope::highlight(dsh-smooth-stream-log-fade-6),
|
|
33
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-6) {
|
|
34
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 19.35484%, transparent);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.scope::highlight(dsh-smooth-stream-log-fade-7),
|
|
38
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-7) {
|
|
39
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 22.58065%, transparent);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.scope::highlight(dsh-smooth-stream-log-fade-8),
|
|
43
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-8) {
|
|
44
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 25.80645%, transparent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.scope::highlight(dsh-smooth-stream-log-fade-9),
|
|
48
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-9) {
|
|
49
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 29.03226%, transparent);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.scope::highlight(dsh-smooth-stream-log-fade-10),
|
|
53
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-10) {
|
|
54
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 32.25806%, transparent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.scope::highlight(dsh-smooth-stream-log-fade-11),
|
|
58
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-11) {
|
|
59
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 35.48387%, transparent);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.scope::highlight(dsh-smooth-stream-log-fade-12),
|
|
63
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-12) {
|
|
64
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 38.70968%, transparent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.scope::highlight(dsh-smooth-stream-log-fade-13),
|
|
68
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-13) {
|
|
69
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 41.93548%, transparent);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.scope::highlight(dsh-smooth-stream-log-fade-14),
|
|
73
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-14) {
|
|
74
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 45.16129%, transparent);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.scope::highlight(dsh-smooth-stream-log-fade-15),
|
|
78
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-15) {
|
|
79
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 48.38710%, transparent);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.scope::highlight(dsh-smooth-stream-log-fade-16),
|
|
83
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-16) {
|
|
84
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 51.61290%, transparent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.scope::highlight(dsh-smooth-stream-log-fade-17),
|
|
88
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-17) {
|
|
89
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 54.83871%, transparent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.scope::highlight(dsh-smooth-stream-log-fade-18),
|
|
93
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-18) {
|
|
94
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 58.06452%, transparent);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.scope::highlight(dsh-smooth-stream-log-fade-19),
|
|
98
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-19) {
|
|
99
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 61.29032%, transparent);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.scope::highlight(dsh-smooth-stream-log-fade-20),
|
|
103
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-20) {
|
|
104
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 64.51613%, transparent);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.scope::highlight(dsh-smooth-stream-log-fade-21),
|
|
108
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-21) {
|
|
109
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 67.74194%, transparent);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.scope::highlight(dsh-smooth-stream-log-fade-22),
|
|
113
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-22) {
|
|
114
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 70.96774%, transparent);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.scope::highlight(dsh-smooth-stream-log-fade-23),
|
|
118
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-23) {
|
|
119
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 74.19355%, transparent);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.scope::highlight(dsh-smooth-stream-log-fade-24),
|
|
123
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-24) {
|
|
124
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 77.41935%, transparent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.scope::highlight(dsh-smooth-stream-log-fade-25),
|
|
128
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-25) {
|
|
129
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 80.64516%, transparent);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.scope::highlight(dsh-smooth-stream-log-fade-26),
|
|
133
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-26) {
|
|
134
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 83.87097%, transparent);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.scope::highlight(dsh-smooth-stream-log-fade-27),
|
|
138
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-27) {
|
|
139
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 87.09677%, transparent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.scope::highlight(dsh-smooth-stream-log-fade-28),
|
|
143
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-28) {
|
|
144
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 90.32258%, transparent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.scope::highlight(dsh-smooth-stream-log-fade-29),
|
|
148
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-29) {
|
|
149
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 93.54839%, transparent);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.scope::highlight(dsh-smooth-stream-log-fade-30),
|
|
153
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-30) {
|
|
154
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 96.77419%, transparent);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.scope::highlight(dsh-smooth-stream-log-fade-31),
|
|
158
|
+
.scope *::highlight(dsh-smooth-stream-log-fade-31) {
|
|
159
|
+
color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 100.00000%, transparent);
|
|
160
|
+
}
|
|
@@ -84,6 +84,19 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
84
84
|
</span>
|
|
85
85
|
<span className={css.hint}>{t('controlScrollHint')}</span>
|
|
86
86
|
</label>
|
|
87
|
+
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
88
|
+
<span className={css.fieldHead}>
|
|
89
|
+
<span className={css.label}>{t('logarithmicFade')}</span>
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
className={css.toggle}
|
|
93
|
+
checked={state.logarithmicFade}
|
|
94
|
+
disabled={!state.writable || state.saving || !state.enabled}
|
|
95
|
+
onChange={(event) => { props.edit({ logarithmicFade: event.target.checked }) }}
|
|
96
|
+
/>
|
|
97
|
+
</span>
|
|
98
|
+
<span className={css.hint}>{t('logarithmicFadeHint')}</span>
|
|
99
|
+
</label>
|
|
87
100
|
{/* Motion preference is a radio group, so this row is a div:
|
|
88
101
|
nesting the choice labels inside a field <label> would be
|
|
89
102
|
illegal HTML (label within label) and browsers route the
|
|
@@ -6,6 +6,7 @@ import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
|
|
|
6
6
|
import { notifyFollowCommit } from './teleprompterGlide.ts'
|
|
7
7
|
import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
|
|
8
8
|
import { useFpsGuard } from './useFpsGuard.ts'
|
|
9
|
+
import { useLogarithmicFade } from './useLogarithmicFade.ts'
|
|
9
10
|
import { FollowHost } from './FollowHost.tsx'
|
|
10
11
|
import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
|
|
11
12
|
import { DEFAULT_STREAM_SETTINGS, type StreamMotionPreference } from '../settings.ts'
|
|
@@ -45,6 +46,7 @@ function useMotionReduced(preference: StreamMotionPreference): boolean {
|
|
|
45
46
|
|
|
46
47
|
interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
47
48
|
streaming: boolean
|
|
49
|
+
logarithmicFade: boolean
|
|
48
50
|
/** Whether the resolved reduced-motion gate keeps the reveal engine off. */
|
|
49
51
|
motionReduced: boolean
|
|
50
52
|
/** True on the last text block: that block owns conversation follow. */
|
|
@@ -307,6 +309,7 @@ function AnimatedMarkdownText({
|
|
|
307
309
|
labels,
|
|
308
310
|
fileMentions,
|
|
309
311
|
streaming,
|
|
312
|
+
logarithmicFade,
|
|
310
313
|
motionReduced,
|
|
311
314
|
ownFollow,
|
|
312
315
|
followSpeedCpsRef,
|
|
@@ -337,6 +340,7 @@ function AnimatedMarkdownText({
|
|
|
337
340
|
})
|
|
338
341
|
const shown = reduced ? text : displayed
|
|
339
342
|
const live = typing && !reduced
|
|
343
|
+
useLogarithmicFade(followRootRef, logarithmicFade && !reduced, live, speedCpsRef)
|
|
340
344
|
|
|
341
345
|
useEffect(() => {
|
|
342
346
|
const root = followRootRef.current
|
|
@@ -494,6 +498,7 @@ function AnimatedReasoning({
|
|
|
494
498
|
preset,
|
|
495
499
|
thinkAutoExpand,
|
|
496
500
|
motionReduced,
|
|
501
|
+
logarithmicFade,
|
|
497
502
|
shouldHoldBack,
|
|
498
503
|
followSpeedCpsRef,
|
|
499
504
|
followRevealScaleRef,
|
|
@@ -504,6 +509,7 @@ function AnimatedReasoning({
|
|
|
504
509
|
preset: StreamSmoothingPreset
|
|
505
510
|
thinkAutoExpand: boolean
|
|
506
511
|
motionReduced: boolean
|
|
512
|
+
logarithmicFade: boolean
|
|
507
513
|
shouldHoldBack: () => boolean
|
|
508
514
|
followSpeedCpsRef?: { current: number } | undefined
|
|
509
515
|
followRevealScaleRef?: { current: number } | undefined
|
|
@@ -513,6 +519,9 @@ function AnimatedReasoning({
|
|
|
513
519
|
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
514
520
|
const [autoClosed, setAutoClosed] = useState(false)
|
|
515
521
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
522
|
+
const fadeRootRef = useRef<HTMLDivElement>(null)
|
|
523
|
+
const localFadeSpeedRef = useRef(35)
|
|
524
|
+
const fadeSpeedRef = followSpeedCpsRef ?? localFadeSpeedRef
|
|
516
525
|
const commitAnchorRef = useRef<HTMLDivElement>(null)
|
|
517
526
|
// The running→false flip is the AUTO-close: it collapses instantly and the
|
|
518
527
|
// follower's settle spring absorbs the height step. A later manual toggle
|
|
@@ -521,12 +530,13 @@ function AnimatedReasoning({
|
|
|
521
530
|
enabled: running && !reduced,
|
|
522
531
|
preset,
|
|
523
532
|
shouldHoldBack,
|
|
524
|
-
speedCpsRef:
|
|
533
|
+
speedCpsRef: fadeSpeedRef,
|
|
525
534
|
revealScaleRef: followRevealScaleRef,
|
|
526
535
|
onRevealCommit: () => { notifyFollowCommit(commitAnchorRef.current) },
|
|
527
536
|
})
|
|
528
537
|
const shown = running && !reduced ? displayed : text
|
|
529
538
|
const summary = running ? latestLine(shown) : firstLine(text)
|
|
539
|
+
useLogarithmicFade(fadeRootRef, logarithmicFade && !reduced && expanded, running, fadeSpeedRef)
|
|
530
540
|
|
|
531
541
|
useLayoutEffect(() => {
|
|
532
542
|
// Only the running state owns disclosure while auto-expand is on; with it
|
|
@@ -576,7 +586,7 @@ function AnimatedReasoning({
|
|
|
576
586
|
</>
|
|
577
587
|
)}
|
|
578
588
|
>
|
|
579
|
-
<div className={css.thinkBody}>{shown}</div>
|
|
589
|
+
<div ref={fadeRootRef} className={css.thinkBody}>{shown}</div>
|
|
580
590
|
</AnimatedDisclosure>
|
|
581
591
|
</div>
|
|
582
592
|
</div>
|
|
@@ -600,6 +610,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
600
610
|
scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
|
|
601
611
|
maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
|
|
602
612
|
thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
613
|
+
logarithmicFade = DEFAULT_STREAM_SETTINGS.logarithmicFade,
|
|
603
614
|
controlScroll = true,
|
|
604
615
|
motionPreference = DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
605
616
|
node,
|
|
@@ -616,6 +627,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
616
627
|
scrollSpeedPxPerSec?: number
|
|
617
628
|
maxScrollSpeedPxPerSec?: number
|
|
618
629
|
thinkAutoExpand?: boolean
|
|
630
|
+
logarithmicFade?: boolean
|
|
619
631
|
controlScroll?: boolean
|
|
620
632
|
motionPreference?: StreamMotionPreference
|
|
621
633
|
}) {
|
|
@@ -704,6 +716,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
704
716
|
labels={markdownLabels}
|
|
705
717
|
fileMentions={mentions}
|
|
706
718
|
streaming={streaming}
|
|
719
|
+
logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
|
|
707
720
|
motionReduced={reduced}
|
|
708
721
|
ownFollow={!streaming && index === lastFollow}
|
|
709
722
|
followSpeedCpsRef={index === lastFollow ? rootSpeedRef : undefined}
|
|
@@ -724,6 +737,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
724
737
|
running={streaming && index === last}
|
|
725
738
|
preset={preset}
|
|
726
739
|
thinkAutoExpand={thinkAutoExpand}
|
|
740
|
+
logarithmicFade={logarithmicFade && data.status !== 'interrupted'}
|
|
727
741
|
motionReduced={reduced}
|
|
728
742
|
shouldHoldBack={shouldHoldBack}
|
|
729
743
|
followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
|
package/src/client/index.ts
CHANGED
|
@@ -159,6 +159,7 @@ class SettingsCell {
|
|
|
159
159
|
&& next.controlScroll === this.value.controlScroll
|
|
160
160
|
&& next.motionPreference === this.value.motionPreference
|
|
161
161
|
&& next.thinkAutoExpand === this.value.thinkAutoExpand
|
|
162
|
+
&& next.logarithmicFade === this.value.logarithmicFade
|
|
162
163
|
&& next.debugEnabled === this.value.debugEnabled
|
|
163
164
|
&& next.debugTuning === this.value.debugTuning
|
|
164
165
|
) return
|
|
@@ -270,6 +271,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
270
271
|
scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
|
|
271
272
|
maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
|
|
272
273
|
thinkAutoExpand: preferences.thinkAutoExpand,
|
|
274
|
+
logarithmicFade: preferences.logarithmicFade,
|
|
273
275
|
controlScroll: preferences.controlScroll,
|
|
274
276
|
motionPreference: preferences.motionPreference,
|
|
275
277
|
})
|
package/src/client/locales.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type SmoothStreamLocaleKey =
|
|
|
13
13
|
| 'motionForceSmooth' | 'motionForceSmoothHint'
|
|
14
14
|
| 'motionForceReduced' | 'motionForceReducedHint'
|
|
15
15
|
| 'thinkAutoExpand' | 'thinkAutoExpandHint'
|
|
16
|
+
| 'logarithmicFade' | 'logarithmicFadeHint'
|
|
16
17
|
| 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
|
|
17
18
|
| 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
|
|
18
19
|
| 'debugLive' | 'debugIdle' | 'debugUnsaved'
|
|
@@ -50,6 +51,8 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
|
50
51
|
motionForceReduced: 'Always raw',
|
|
51
52
|
motionForceReducedHint: 'Render raw text even when the system allows motion.',
|
|
52
53
|
thinkAutoExpand: 'Auto-expand thinking',
|
|
54
|
+
logarithmicFade: 'Logarithmic fade',
|
|
55
|
+
logarithmicFadeHint: 'Fade new answer and expanded thinking text into view. Follows the motion preference.',
|
|
53
56
|
thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
|
|
54
57
|
debugEnabled: 'Show render diagnostics',
|
|
55
58
|
debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
|
|
@@ -138,6 +141,8 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
138
141
|
motionForceReduced: '始终原始',
|
|
139
142
|
motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
|
|
140
143
|
thinkAutoExpand: '自动展开思考',
|
|
144
|
+
logarithmicFade: '对数淡入',
|
|
145
|
+
logarithmicFadeHint: '让回答正文和展开的思考文字由淡变实,遵循动效偏好。',
|
|
141
146
|
thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
|
|
142
147
|
debugEnabled: '显示渲染调试面板',
|
|
143
148
|
debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
|
|
@@ -26,6 +26,7 @@ export interface SmoothStreamCardState {
|
|
|
26
26
|
controlScroll: boolean
|
|
27
27
|
motionPreference: StreamMotionPreference
|
|
28
28
|
thinkAutoExpand: boolean
|
|
29
|
+
logarithmicFade: boolean
|
|
29
30
|
debugEnabled: boolean
|
|
30
31
|
debugTuning: StreamDebugTuning
|
|
31
32
|
debugAvailable: boolean
|
|
@@ -54,7 +55,7 @@ export class SmoothStreamCardController {
|
|
|
54
55
|
private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
|
|
55
56
|
private loaded: StreamSettingsView | undefined
|
|
56
57
|
private loadedDebug: StreamDebugSettingsView | undefined
|
|
57
|
-
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
|
|
58
|
+
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
|
|
58
59
|
private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
|
|
59
60
|
private saving = false
|
|
60
61
|
private failed = false
|
|
@@ -92,13 +93,14 @@ export class SmoothStreamCardController {
|
|
|
92
93
|
hooks: { smoothStreamCard: this.store },
|
|
93
94
|
edit: (patch) => {
|
|
94
95
|
if (this.saving) return
|
|
95
|
-
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
|
|
96
|
+
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
|
|
96
97
|
this.stagedBase = {
|
|
97
98
|
...this.baseValues(),
|
|
98
99
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
99
100
|
...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
|
|
100
101
|
...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
|
|
101
102
|
...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
|
|
103
|
+
...(patch.logarithmicFade === undefined ? {} : { logarithmicFade: patch.logarithmicFade }),
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
|
|
@@ -143,12 +145,13 @@ export class SmoothStreamCardController {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
|
|
148
|
+
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
|
|
147
149
|
return this.stagedBase ?? {
|
|
148
150
|
enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
|
|
149
151
|
controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
|
|
150
152
|
motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
151
153
|
thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
154
|
+
logarithmicFade: this.loaded?.logarithmicFade ?? DEFAULT_STREAM_SETTINGS.logarithmicFade,
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
|