Skip to content

Commit 84856be

Browse files
Merge branch 'release/v1.8.0' into feat/auto-20260728-a77aece2
2 parents bc599bd + 07640ce commit 84856be

14 files changed

Lines changed: 517 additions & 144 deletions

File tree

crates/compositor/src/compositor.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,32 @@ fn decode_data_uri(uri: &str) -> Option<Vec<u8>> {
6262
}
6363

6464
fn parse_hex(s: &str) -> Option<[f32; 4]> {
65-
let h = s.trim().trim_start_matches('#');
65+
// Le contrat accepte du CSS, pas seulement de l'hex : la bridge des captions produit du
66+
// `rgba(r, g, b, a)` (l'inspector stocke couleur + opacité séparément, et `captionBackgroundCss`
67+
// les recombine en rgba pour la preview) et les stops de gradient arrivent aussi sous cette
68+
// forme. `transparent` est un cas particulier documenté : alpha 0, pas de plaque. Tout le
69+
// reste tombe sur None → l'appelant applique son fallback (alpha 0 pour un fond, alpha 1
70+
// pour un texte, etc.) — la même sémantique qu'avant l'ajout du parseur rgba.
71+
let trimmed = s.trim();
72+
if trimmed.eq_ignore_ascii_case("transparent") {
73+
return Some([0.0, 0.0, 0.0, 0.0]);
74+
}
75+
// CSS Color 4 fait de `rgb()` et `rgba()` des synonymes : les deux acceptent 3 ou 4
76+
// composantes. On les traite donc par le même chemin plutôt que d'imposer une arité par
77+
// nom — refuser `rgba(0, 0, 0)` ne « signalerait » rien d'utile, ça retomberait sur le
78+
// fallback de l'appelant, c'est-à-dire une plaque invisible : exactement le bug #178.
79+
if let Some(inner) =
80+
strip_color_fn(trimmed, "rgba").or_else(|| strip_color_fn(trimmed, "rgb"))
81+
{
82+
return parse_rgb_components(inner);
83+
}
84+
let h = trimmed.trim_start_matches('#');
85+
// Un corps hex est ASCII par définition, et les découpes par octet ci-dessous (`h[i..=i]`,
86+
// `h[0..2]`…) paniqueraient au milieu d'un caractère multi-octets qui ferait pile 3 ou 6
87+
// octets (`éa`, `€€`). On refuse avant de découper.
88+
if !h.is_ascii() {
89+
return None;
90+
}
6691
let (r, g, b) = match h.len() {
6792
3 => {
6893
let d = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|v| v * 17);
@@ -78,6 +103,58 @@ fn parse_hex(s: &str) -> Option<[f32; 4]> {
78103
Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
79104
}
80105

106+
/// `rgba(0, 0, 0, 0.55)` → `"0, 0, 0, 0.55"` (le contenu entre les parenthèses), None si
107+
/// l'enveloppe n'est pas de la forme `fn(...)`. Tolère les espaces et les tabs, refuse les
108+
/// virgules finales et les arguments vides — le gradient parser a déjà démontré que la couche
109+
/// application produit des chaînes propres, donc rester strict ici évite d'avaler des CSS
110+
/// tordus qu'on ne maîtrise pas. La casse du préfixe est libre (`RGBA(...)` est valide) parce
111+
/// que CSS le permet.
112+
fn strip_color_fn<'a>(s: &'a str, name: &str) -> Option<&'a str> {
113+
// `get` rend None si `name.len()` n'est pas une frontière de caractère : c'est ce qui rend
114+
// le slice `s[..name.len()]` juste en dessous sûr par construction. Un `&s[..n]` direct
115+
// paniquerait au milieu d'un caractère multi-octets (`#ab€cd` coupe dans le `€`), et une
116+
// panique traverserait le pont N-API au lieu de retomber sur le fallback de l'appelant —
117+
// le contraire de ce que ce parseur promet.
118+
let after_name = s.get(name.len()..)?;
119+
if !s[..name.len()].eq_ignore_ascii_case(name) {
120+
return None;
121+
}
122+
let inner = after_name.strip_prefix('(')?.strip_suffix(')')?.trim();
123+
if inner.is_empty() {
124+
return None;
125+
}
126+
Some(inner)
127+
}
128+
129+
/// `"r, g, b"` ou `"r, g, b, a"` (floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en
130+
/// 0..1, l'alpha valant 1 (opaque) quand elle est absente. Toute autre arité → None. Tolère
131+
/// les espaces autour des virgules, pas les pourcentages : le gradient parser n'envoie pas de
132+
/// `rgb(50%, …)` et les couches UI qui le font n'arrivent pas ici (les couleurs wallpaper
133+
/// passent par une autre route, cf. `parseWallpaper`).
134+
fn parse_rgb_components(s: &str) -> Option<[f32; 4]> {
135+
let parts: Vec<&str> = s.split(',').map(str::trim).collect();
136+
let (rgb, alpha) = match parts.as_slice() {
137+
[r, g, b] => ([r, g, b], 1.0),
138+
// L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`).
139+
[r, g, b, a] => ([r, g, b], parse_color_channel(a, 1.0)?),
140+
_ => return None,
141+
};
142+
Some([
143+
parse_color_channel(rgb[0], 255.0)?,
144+
parse_color_channel(rgb[1], 255.0)?,
145+
parse_color_channel(rgb[2], 255.0)?,
146+
alpha,
147+
])
148+
}
149+
150+
fn parse_color_channel(raw: &str, max: f32) -> Option<f32> {
151+
let n: f32 = raw.parse().ok()?;
152+
if !n.is_finite() || n < 0.0 || n > max {
153+
return None;
154+
}
155+
Some(n / max)
156+
}
157+
81158
/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max`
82159
/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1),
83160
/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop.
@@ -3184,6 +3261,89 @@ mod tests {
31843261
assert_eq!(decode_data_uri("data:image/png;base64,SGkh").unwrap(), b"Hi!".to_vec());
31853262
}
31863263

3264+
/// L'inspector stocke les couleurs de caption comme `couleur_hex` + `opacité` puis la
3265+
/// bridge JS recombine en `rgba(r, g, b, a)` pour la preview. Le natif doit rendre la même
3266+
/// plaque (couleur et opacité) — sinon le calque disparaît silencieusement et la caption
3267+
/// n'apparaît qu'en texte brut dans l'export. C'était exactement le bug de l'issue #178.
3268+
#[test]
3269+
fn parse_hex_understands_rgba_caption_backgrounds() {
3270+
let parsed = parse_hex("rgba(0, 0, 0, 0.55)").expect("rgba doit parser");
3271+
assert!((parsed[3] - 0.55).abs() < 1e-6, "alpha 0.55 transmise, pas tombée à 0");
3272+
assert_eq!([parsed[0], parsed[1], parsed[2]], [0.0, 0.0, 0.0]);
3273+
}
3274+
3275+
/// `rgb(...)` sans alpha est sémantiquement `rgba(..., 1)` — il faut le supporter pour
3276+
/// qu'un inspector qui n'expose pas d'opacité n'écrive pas un fond invisible.
3277+
#[test]
3278+
fn parse_hex_treats_rgb_as_opaque() {
3279+
let parsed = parse_hex("rgb(255, 128, 0)").expect("rgb doit parser");
3280+
assert_eq!(parsed, [1.0, 128.0 / 255.0, 0.0, 1.0]);
3281+
}
3282+
3283+
/// Le cas "transparent" est documenté dans le code d'appel : on garde la sémantique
3284+
/// historique (alpha 0) — la plaque est sautée côté rastérisation, ce qui est exactement ce
3285+
/// que veut le CSS. Le nouveau parseur ne doit pas le casser.
3286+
#[test]
3287+
fn parse_hex_keeps_transparent_at_alpha_zero() {
3288+
assert_eq!(parse_hex("transparent"), Some([0.0, 0.0, 0.0, 0.0]));
3289+
// La casse ne doit pas non plus casser : CSS autorise `TRANSPARENT` en théorie, et
3290+
// refuse une chaîne qui ressemble à un rgba mal formé.
3291+
assert_eq!(parse_hex("Transparent"), Some([0.0, 0.0, 0.0, 0.0]));
3292+
assert_eq!(parse_hex("rgba(0, 0, 0, 0)"), Some([0.0, 0.0, 0.0, 0.0]));
3293+
}
3294+
3295+
/// Le contrat historique `#rrggbb` / `rrggbb` ne doit pas régresser : les annotations
3296+
/// normales (saisies via `ColorField`) ne passent que par ce chemin, et leurs snapshots
3297+
/// ne pardonneraient pas un changement d'alpha implicite.
3298+
#[test]
3299+
fn parse_hex_still_understands_hex_colours() {
3300+
assert_eq!(parse_hex("#fff"), Some([1.0, 1.0, 1.0, 1.0]));
3301+
assert_eq!(parse_hex("#000000"), Some([0.0, 0.0, 0.0, 1.0]));
3302+
assert_eq!(
3303+
parse_hex("ff8800"),
3304+
Some([1.0, 136.0 / 255.0, 0.0, 1.0])
3305+
);
3306+
}
3307+
3308+
/// Hors-format (channel > 255, chaîne vide, named color) → None → l'appelant retombe sur
3309+
/// son fallback. C'est la même politique qu'avant l'ajout du parseur rgba, on la garde
3310+
/// explicite pour qu'elle ne dérive pas.
3311+
#[test]
3312+
fn parse_hex_rejects_malformed_colours() {
3313+
assert_eq!(parse_hex(""), None);
3314+
assert_eq!(parse_hex("not-a-color"), None);
3315+
assert_eq!(parse_hex("rgba(256, 0, 0, 1)"), None); // canal >255
3316+
assert_eq!(parse_hex("rgba(0, 0, 0, 1.5)"), None); // alpha >1
3317+
assert_eq!(parse_hex("rgba(0, 0, 0, 0.5, 1)"), None); // 5 composantes
3318+
assert_eq!(parse_hex("rgb(0, 0)"), None); // 2 composantes
3319+
}
3320+
3321+
/// CSS Color 4 : `rgb()` et `rgba()` sont synonymes, les deux prennent 3 ou 4 composantes.
3322+
/// Une couleur bien formée ne doit pas finir sur le fallback de l'appelant — pour un fond
3323+
/// c'est alpha 0, donc une plaque invisible, soit très exactement le symptôme de #178.
3324+
#[test]
3325+
fn parse_hex_accepts_both_arities_on_both_names() {
3326+
assert_eq!(parse_hex("rgba(0, 0, 0)"), Some([0.0, 0.0, 0.0, 1.0]));
3327+
assert_eq!(parse_hex("rgb(0, 0, 0, 0.5)"), Some([0.0, 0.0, 0.0, 0.5]));
3328+
}
3329+
3330+
/// Une couleur non-ASCII doit être refusée, pas paniquer : `strip_color_fn` découpait
3331+
/// `s[..3]` / `s[..4]` sans vérifier la frontière de caractère, donc `#ab€cd` (le `€` occupe
3332+
/// les octets 3..6) tuait le process au lieu de retomber sur le fallback. `parseWallpaper`
3333+
/// laisse passer n'importe quelle chaîne préfixée `#` jusqu'ici, une panique côté natif
3334+
/// traverserait le pont N-API et emporterait l'export.
3335+
#[test]
3336+
fn parse_hex_refuses_non_ascii_without_panicking() {
3337+
assert_eq!(parse_hex("#ab€cd"), None);
3338+
assert_eq!(parse_hex("rg€(0, 0, 0)"), None);
3339+
assert_eq!(parse_hex("é"), None);
3340+
assert_eq!(parse_hex("🎨🎨"), None);
3341+
// Le chemin hex découpe par octet sur les longueurs 3 et 6 : `éa` fait 3 octets et
3342+
// `€€` en fait 6, donc les deux tombaient pile sur une découpe intra-caractère.
3343+
assert_eq!(parse_hex("éa"), None);
3344+
assert_eq!(parse_hex("€€"), None);
3345+
}
3346+
31873347
#[test]
31883348
fn ignores_padding_and_line_breaks_inside_the_payload() {
31893349
// Un URI replié ou paddé doit décoder à l'identique : les caractères hors alphabet sont

electron/ai-edition/chat-service.toolloop.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,17 @@ describe("runChat tool loop", () => {
244244
invokeMock.mockImplementationOnce(async (args) => {
245245
events.push({ kind: "captured", payload: args.userMessage });
246246
args.sink.text("Hi ");
247+
args.sink.thinking("pondering. ");
247248
args.sink.text("there.");
249+
args.sink.thinking("concluding.");
248250
args.sink.toolStart("addTrim", { startSec: 1, endSec: 2 });
249251
args.sink.toolEnd("addTrim", true, "added trim 0:01.0 – 0:02.0");
250252
return { text: "Done.", document: args.document, mutated: true };
251253
});
252254

253255
const sink = {
254256
text: (delta: string) => fixture.events.push({ kind: "text", payload: delta }),
257+
thinking: (delta: string) => fixture.events.push({ kind: "thinking", payload: delta }),
255258
toolStart: (name: string, args: unknown) =>
256259
fixture.events.push({ kind: "toolStart", payload: { name, args } }),
257260
toolEnd: (name: string, ok: boolean, summary?: string) =>
@@ -262,12 +265,22 @@ describe("runChat tool loop", () => {
262265
const s = createSession("proj_sink");
263266
const result = await runChat("proj_sink", s.id, "cut", stubConfig(), fixtureDocument(), sink);
264267
expect(result.success).toBe(true);
265-
expect(fixture.events.map((e) => e.kind)).toEqual(["text", "text", "toolStart", "toolEnd"]);
266-
expect((fixture.events[0].payload as string) + (fixture.events[1].payload as string)).toBe(
268+
expect(fixture.events.map((e) => e.kind)).toEqual([
269+
"text",
270+
"thinking",
271+
"text",
272+
"thinking",
273+
"toolStart",
274+
"toolEnd",
275+
]);
276+
expect((fixture.events[0].payload as string) + (fixture.events[2].payload as string)).toBe(
267277
"Hi there.",
268278
);
269-
expect(fixture.events[2].payload).toMatchObject({ name: "addTrim" });
270-
expect(fixture.events[3].payload).toMatchObject({
279+
expect((fixture.events[1].payload as string) + (fixture.events[3].payload as string)).toBe(
280+
"pondering. concluding.",
281+
);
282+
expect(fixture.events[4].payload).toMatchObject({ name: "addTrim" });
283+
expect(fixture.events[5].payload).toMatchObject({
271284
name: "addTrim",
272285
ok: true,
273286
summary: expect.stringMatching(/added trim/),
@@ -284,6 +297,7 @@ describe("runChat tool loop", () => {
284297
});
285298
const sinkErr = {
286299
text: (delta: string) => fixture.events.push({ kind: "text", payload: delta }),
300+
thinking: (delta: string) => fixture.events.push({ kind: "thinking", payload: delta }),
287301
toolStart: (name: string, args: unknown) =>
288302
fixture.events.push({ kind: "toolStart", payload: { name, args } }),
289303
toolEnd: (name: string, ok: boolean, summary?: string) =>

electron/ai-edition/chat-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ export function deleteSession(projectId: string, sessionId: string): boolean {
204204
export interface ChatEventSink {
205205
/** Streamed text delta from the model. */
206206
text?: (delta: string) => void;
207+
/** Streamed delta from the model's reasoning block (Anthropic/MiniMax
208+
* thinking). Provider-agnostic — never called for providers that don't
209+
* expose thinking. The chat panel streams these into a live "Thinking…"
210+
* block so the reasoning phase doesn't feel like dead air. */
211+
thinking?: (delta: string) => void;
207212
/** A tool call is about to execute. */
208213
toolStart?: (name: string, args: unknown) => void;
209214
/** A tool call has finished. `ok=false` carries the model's error message. */
@@ -218,6 +223,7 @@ const noop = () => undefined;
218223
/** ponytail: zero-config sink that swallows every event. */
219224
const NOOP_SINK: Required<ChatEventSink> = {
220225
text: noop,
226+
thinking: noop,
221227
toolStart: noop,
222228
toolEnd: noop,
223229
error: noop,
@@ -323,6 +329,7 @@ export async function runChat(
323329

324330
const agentSink = {
325331
text: (delta: string) => emit.text(delta),
332+
thinking: (delta: string) => emit.thinking(delta),
326333
toolStart: (name: string, args: unknown) => {
327334
emit.toolStart(name, args);
328335
void editsAllowed;

electron/ai-edition/deep-agent/chat-model.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ANTHROPIC_API_MAX_OUTPUT_TOKENS,
1010
createOpenScreenChatModel,
1111
messageContentToText,
12+
messageContentToThinking,
1213
} from "./chat-model";
1314

1415
/** ChatOpenAI keeps the `configuration` bag it was constructed with on
@@ -104,3 +105,43 @@ describe("messageContentToText", () => {
104105
expect(messageContentToText(42)).toBe("");
105106
});
106107
});
108+
109+
describe("messageContentToThinking", () => {
110+
// Anthropic/MiniMax thinking blocks land in AIMessageChunk content arrays
111+
// as `{type: "thinking", thinking: "..."}` parts (see @langchain/anthropic
112+
// message_outputs.js — `thinking_delta` SSE events). The extractor has to
113+
// pull them out so the chat panel can stream them separately; text parts
114+
// stay on the messageContentToText path.
115+
it("concatenates thinking parts in array order", () => {
116+
expect(
117+
messageContentToThinking([
118+
{ type: "thinking", thinking: "step one. " },
119+
{ type: "text", text: "should be ignored" },
120+
{ type: "thinking", thinking: "step two." },
121+
]),
122+
).toBe("step one. step two.");
123+
});
124+
125+
it("ignores redacted_thinking blocks (encrypted reasoning the provider hides)", () => {
126+
// ChatAnthropic surfaces encrypted reasoning as parts of type
127+
// "redacted_thinking" — we don't have a string to display, so skip.
128+
expect(
129+
messageContentToThinking([
130+
{ type: "thinking", thinking: "visible. " },
131+
{ type: "redacted_thinking" },
132+
{ type: "thinking", thinking: "more visible." },
133+
]),
134+
).toBe("visible. more visible.");
135+
});
136+
137+
it("returns an empty string for a plain string or non-array input", () => {
138+
expect(messageContentToThinking("not a list")).toBe("");
139+
expect(messageContentToThinking(null)).toBe("");
140+
expect(messageContentToThinking(42)).toBe("");
141+
});
142+
143+
it("returns an empty string when there are no thinking parts", () => {
144+
expect(messageContentToThinking([{ type: "text", text: "answer" }])).toBe("");
145+
expect(messageContentToThinking([])).toBe("");
146+
});
147+
});

electron/ai-edition/deep-agent/chat-model.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ export function messageContentToText(content: unknown): string {
8181
return "";
8282
}
8383

84+
// ponytail: counterpart to messageContentToText for the Anthropic/MiniMax
85+
// thinking blocks. ChatAnthropic with `thinking: {type: "adaptive"}` (or
86+
// `enabled`) emits streamed `thinking_delta` SSE events that LangChain turns
87+
// into content parts `{type: "thinking", thinking: "..."}`. We strip that
88+
// thinking text out of the final AIMessage content (where it counts against
89+
// max_tokens on the visible text path, but isn't user-visible text) and pipe
90+
// it separately to the renderer so the chat panel can show a live "Thinking…"
91+
// block instead of dead air. `redacted_thinking` parts (encrypted reasoning
92+
// the provider chose not to show us) are skipped — there's nothing to display.
93+
export function messageContentToThinking(content: unknown): string {
94+
if (!Array.isArray(content)) return "";
95+
let total = "";
96+
for (const part of content) {
97+
if (!part || typeof part !== "object") continue;
98+
const p = part as { type?: unknown; thinking?: unknown };
99+
if (p.type !== "thinking") continue;
100+
if (typeof p.thinking === "string") total += p.thinking;
101+
}
102+
return total;
103+
}
104+
84105
export async function createOpenScreenChatModel(
85106
input: OpenScreenChatModelConfig,
86107
): Promise<BaseChatModel> {

electron/ai-edition/deep-agent/service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,17 @@ import {
3131
import {
3232
createOpenScreenChatModel,
3333
messageContentToText,
34+
messageContentToThinking,
3435
type OpenScreenChatModelConfig,
3536
} from "./chat-model";
3637

3738
export interface OpenScreenAgentSink {
3839
text: (delta: string) => void;
40+
/** Streaming delta from the model's reasoning block (Anthropic/MiniMax
41+
* thinking). Provider-agnostic — for providers without thinking this is
42+
* never called. The chat panel uses it to surface the reasoning phase
43+
* that would otherwise be invisible "dead air" while the model thinks. */
44+
thinking: (delta: string) => void;
3945
toolStart: (name: string, args: unknown) => void;
4046
toolEnd: (name: string, ok: boolean, summary?: string) => void;
4147
error: (message: string) => void;
@@ -233,6 +239,10 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise<InvokeRes
233239
const chunk = data?.chunk as Record<string, unknown> | undefined;
234240
if (chunk) chatModelChunks.push(chunk);
235241
const content = chunk?.content;
242+
const thinkingDelta = messageContentToThinking(content);
243+
if (thinkingDelta) {
244+
sink.thinking(thinkingDelta);
245+
}
236246
const delta = messageContentToText(content);
237247
if (delta) {
238248
sink.text(delta);

electron/ipc/nativeBridge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ function buildChatEventSink(sender: Electron.WebContents, sessionId: string): Ch
189189
};
190190
return {
191191
text: (delta) => send({ kind: "text", sessionId, delta }),
192+
thinking: (delta) => send({ kind: "thinking", sessionId, delta }),
192193
toolStart: (name, args) => send({ kind: "toolStart", sessionId, name, args }),
193194
toolEnd: (name, ok, summary) => send({ kind: "toolEnd", sessionId, name, ok, summary }),
194195
error: (message) => send({ kind: "error", sessionId, message }),

0 commit comments

Comments
 (0)