Compare commits

...

13 Commits

Author SHA1 Message Date
agent_vscode 2637640291 prompt(agents): drop the role-name label prefix from comments (#315)
Chat now shows the agent's name in the comment header, so the '[Copyedit]' /
'[Structure]' / '[Style]' / '[Facts]' prefix each role prepended just
duplicated the visible author.

- Remove the 'open the comment with the label [Role]' instruction from all four
  labelled roles (structural-editor, line-editor, fact-checker, proofreader),
  ru + en; the narrator was already label-free.
- Severity tags ([Critical]/[Major]/[Minor]) and the fact-checker's verdicts
  ([Incorrect]/[Unverified]/…) are kept — they carry meaning, not the role name.
- Versions bumped: structural-editor 3->4, line-editor 3->4, fact-checker 4->5,
  proofreader 6->7; content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 06:08:12 +03:00
agent_vscode aa0428e28b prompt(agents): make the copyeditor exhaustive in one pass (#315)
The copyeditor had to be re-run several times to surface all issues: it has no
'work the whole document' instruction (unlike the developmental editor and the
narrator), and the severity labels nudge it toward reporting only the salient
few.

- Add a HOW TO WORK section (ru + en): one pass over the whole text start to
  finish; flag EVERY violation including all repeat occurrences and [Minor]
  items; don't summarize instead of marking up; one run covers the whole text,
  not just 'the most important'.
- proofreader version 5 -> 6, content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 05:50:39 +03:00
vvzvlad 0a3e32e7f6 Merge pull request 'perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту (дыра #302, фриз в Safari)' (#323) from perf/ai-chat-open-lag into develop
Reviewed-on: #323
2026-07-04 05:00:29 +03:00
claude code agent 227 b1ede48319 test(ai-chat): pin the streaming plain-text text-sink invariant + fix stale CSS ref (#323 F1/F2)
F1: StreamingPlainText/PlainChunk render untrusted model reasoning as a React
text node (escaped), NOT via innerHTML — the load-bearing security property. The
existing tests asserted via textContent, which strips tags, so they couldn't
tell an escaped literal from injected DOM: a future switch to
dangerouslySetInnerHTML would reintroduce XSS with zero failing tests. Add a test
feeding an <img onerror> + <b> payload and asserting querySelector("img"/"b") is
null AND the raw markup survives in textContent — non-vacuous (fails if the
string were parsed as HTML).

F2: the .reasoningText CSS note still described the removed <Text> pre-wrap
fallback and pointed at reasoning-block.tsx (both stale), while PlainChunk's JSDoc
points back to this note — a broken mutual reference. Update the note to point at
PlainChunk / streaming-plain-text.tsx, where pre-wrap is now applied.

No production rendering logic changed. vitest: 8 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 04:25:53 +03:00
agent_vscode d4d05c8e8b test(ai-chat): add dev-only perf harness for the chat stream pipeline
Mounts the real ChatThread against a synthetic AI SDK v6 UI-message SSE
stream (multi-step reasoning + getPage tool calls + markdown answer;
5k/20k/50k-token presets, 15/5 ms chunk cadence) with long-task, FPS
and mount-time instrumentation. Two scenarios: mount a persisted
transcript (open-chat cost) and stream a live turn through the real
useChat pipeline via a window.fetch patch scoped to /api/ai-chat/stream.

Served only by the vite dev server at /perf/ai-chat-perf.html; the
production build keeps its single index.html entry, so none of this
ships. Also ignore local trace dumps under .claude/perf-traces/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:51:22 +03:00
agent_vscode 351860ba4b perf(ai-chat): stop per-delta markdown re-parse in expanded streaming reasoning (#302 follow-up)
The expanded "Thinking" block re-ran marked+DOMPurify and re-set
dangerouslySetInnerHTML with the whole growing reasoning text on every
throttled stream delta (~20 Hz) — the O(n²) hole #302 deliberately left
open ("expanded while streaming"). In Safari this saturates the main
thread and freezes the entire tab during long agent runs, including
while the window is minimized (the JS storm keeps running) and on
re-expanding it mid-turn (one huge layout burst).

- streaming-plain-text.tsx (new): chunked plain-text renderer; chunks
  split at blank-line boundaries with an append-only stable-prefix
  invariant, so per delta only the tail chunk's text node updates —
  no marked, no DOMPurify, no innerHTML swaps.
- reasoning-block.tsx: parse markdown only when expanded AND finalized
  (one-time); while streaming, render chunked plain text; collapsed
  stays parse-free (#302 unchanged).
- message-item.tsx / message-list.tsx: reasoning liveness = part
  state:"streaming" AND the turn is live AND the row is the tail —
  a part stranded at state:"streaming" (manual Stop during thinking,
  or a provider that never emits reasoning-end) finalizes at turn end
  and never re-activates when later turns stream.

Verified with the Chrome perf harness: per-delta marked/DOMPurify work
is gone from the hot path; collapsed streaming stays at 0 long tasks
up to 143k tokens even at 4x CPU throttle; finalized expanded blocks
still render parsed markdown. 245 client tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:50:48 +03:00
claude_code 795dde463b prompt(agents): forbid the copyeditor's blanket 'change everywhere' notes (#315)
The copyeditor was emitting summary comments ('throughout, unify KB'; 'switch
the decimal separator everywhere') that carry no applicable suggestedText — a
human can't one-click any of them. This was actively instructed: the TONE
section told it to 'group repeated fixes so you don't spawn dozens of identical
comments'.

- Invert that TONE guidance: spread repeated fixes across the specific spots;
  ten targeted comments each with a ready replacement beat one blanket note;
  'spawning' comments is normal for a copyeditor.
- HOW-TO: explicitly forbid summary/consistency notes and require a separate
  targeted comment with its own suggestedText on EVERY occurrence; the only
  exception is a note that genuinely can't be a fragment replacement.
- ru + en mirrored; proofreader version 4 -> 5, content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:05:51 +03:00
vvzvlad 0392566af9 Merge pull request 'fix(temporary-notes): баннер временной заметки на мобильном — адаптивные icon-only действия + flex-basis (#321)' (#322) from fix/321-banner-mobile into develop
Reviewed-on: #322
2026-07-04 00:00:37 +03:00
vvzvlad f43696a1c4 Merge pull request 'feat(#300 ui): генерируемая OKLCH-палитра аватарок агентов (модуль + многоканальность)' (#320) from feat/300-avatar-oklch into develop
Reviewed-on: #320
2026-07-03 23:57:40 +03:00
claude code agent 227 8971912d9e test(#320): make the palette WCAG/gamut check non-vacuous per entry (F1)
The old avatar-palette test only did expect(["white","black"]).toContain(
entry.text), which can never fail (text is typed "white"|"black" and always
assigned) — so the load-bearing property "all 20 colors are readable" was only
really checked for the single golden name. A generator bug producing a
low-contrast or out-of-gamut slot would survive the suite.

Export the four existing color-math helpers (oklchToSrgb, isInGamut,
relativeLuminance, contrastRatio — no logic change) and assert, for EVERY
PALETTE entry:
- (a) real contrast of the chosen text on the entry hex >= 3 (the code's
  threshold), scale-matched (hex 0..255 → /255 before relativeLuminance). Since
  buildPalette PREFERS white and only falls back to black when white fails 3:1,
  the test also asserts: if text=="black" then white's contrast is < 3 (black was
  mandatory) — matching the code's actual decision, not a max-contrast pick.
- (b) the OKLCH is in sRGB gamut post-clamp: isInGamut(oklchToSrgb(L,C,h)).

Demonstrated non-vacuous: a light bg mislabeled text:"white" → chosen contrast
1.67 (< 3) fails; an out-of-gamut component fails isInGamut. Golden-name and
minPairwiseDistance tests untouched.

vitest: 15 passed. No palette/hash/consumer logic changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:40:55 +03:00
claude_code 588596fb2f prompt(agents): teach agent prompts to use comment suggestedText fixes (#315)
- editorial roles (ru/en): proofreader and line editor attach suggestedText
  replacements to targeted fixes; fact-checker ALWAYS attaches the ready
  correction for [Incorrect] verdicts; structural editor and narrator get a
  light-touch rule for in-place rewordings; role versions bumped and the
  content-hash lock refreshed
- MCP SERVER_INSTRUCTIONS: route 'propose a concrete text fix for one-click
  human approval' to create_comment with suggestedText (unique-selection
  reminder); build/ artifacts rebuilt
- AI-chat SAFETY_FRAMEWORK: mention the comment-suggestion capability so the
  default assistant offers ready fixes instead of only describing changes

Checks: catalog check.mjs OK; @docmost/mcp tests 448/448; server
ai-chat.prompt spec 28/28.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:22:37 +03:00
claude_code 45478098f5 refactor(#300 ui): extract avatar palette into generated OKLCH module
Replace the inline hand-transcribed palette with the self-contained
src/lib/avatar-palette.ts: the 20-color palette is GENERATED at module load
from an OKLCH ring config (chroma clamped to sRGB, WCAG text color per color),
so it is fully tunable and validated (min pairwise ΔE-OK ≈ 0.066).

avatarStyle() slices one cyrb53 hash of the normalized name into independent
channels: base color (20) × color-wheel scheme (analogous ±20–45° / complement
180° / triadic ±120°) × split angle (24 dirs). avatarBackgroundCss() renders a
two-stop gradient with a soft boundary. Pure, cross-platform, deterministic —
same name → same avatar everywhere, nothing persisted.

The glyph now consumes avatarStyle/avatarBackgroundCss from the module;
agent-avatar-stack no longer defines its own hash/palette.

Tests: avatar-palette.test.ts pins minPairwiseDistance ≥ 0.06, PALETTE length,
normalization, and a golden name→style slice (Backend Developer →
#a55795/#90355e/150°) so a config change that repaints every avatar can't slip
through unnoticed. client tsc clean, 30 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:09:49 +03:00
claude_code 62b818bb36 feat(#300 ui): quantized OKLCH palette + multi-channel agent avatar
Replaces the ad-hoc 14-color hsl palette with a perceptually-even, validated
scheme so agent glyphs are reliably distinguishable:

- cyrb53 deterministic, cross-platform 53-bit hash over a normalized name
  (NFC + trim + lowercase + collapse whitespace) — no built-in/rand hash, so
  the same name renders the same avatar on every device without persistence.
- 20-color OKLCH palette (12 light / 8 dark), chroma clamped to sRGB, min
  pairwise ΔEOK ≈ 0.066: any two entries are identical or clearly distinct —
  "almost the same" colors are impossible by construction.
- Disjoint hash-bit channels: base color (20) × gradient partner (2) ×
  gradient angle (8) = 320 combinations, so a base-color collision (inevitable
  past ~20 agents) is still disambiguated by the gradient — and by the emoji
  drawn on top. Text color (black on light ring, white on dark) is
  WCAG-checked.

Glyph now renders an explicit solid backgroundColor (fallback + testable) plus
a linear-gradient backgroundImage. avatarStyle() replaces agentGlyphBackground().
client tsc clean, 26 tests pass (avatarStyle determinism/normalization/structure
+ DOM base-color).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:50:19 +03:00
25 changed files with 1917 additions and 156 deletions
+2
View File
@@ -43,6 +43,8 @@ lerna-debug.log*
.nx/cache .nx/cache
.claude/worktrees/ .claude/worktrees/
.claude/tmp/ .claude/tmp/
# Local Chrome performance traces recorded by the AI-chat perf harness
.claude/perf-traces/
# TypeScript incremental build artifacts # TypeScript incremental build artifacts
*.tsbuildinfo *.tsbuildinfo
+11 -6
View File
@@ -34,11 +34,13 @@ roles:
Read the whole text first. Think at the level of sections and paragraphs, not sentences. Read the whole text first. Think at the level of sections and paragraphs, not sentences.
HOW TO LEAVE COMMENTS HOW TO LEAVE COMMENTS
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity: You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing. - [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline. - [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
- [Minor] — an optional improvement to framing or flow. - [Minor] — an optional improvement to framing or flow.
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context.
TONE TONE
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question. Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
@@ -85,7 +87,7 @@ roles:
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it. - Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
HOW TO LEAVE COMMENTS HOW TO LEAVE COMMENTS
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity: You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
- [Critical] — the sentence is unclear or distorts the meaning. - [Critical] — the sentence is unclear or distorts the meaning.
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading. - [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
- [Minor] — a stylistic improvement to taste. - [Minor] — a stylistic improvement to taste.
@@ -126,7 +128,7 @@ roles:
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable]. - Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
HOW TO LEAVE COMMENTS HOW TO LEAVE COMMENTS
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. Tag severity: You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation. - [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
- [Major] — a doubtful or unconfirmed claim that needs a source. - [Major] — a doubtful or unconfirmed claim that needs a source.
- [Minor] — a small correction, or false precision worth rounding or confirming. - [Minor] — a small correction, or false precision worth rounding or confirming.
@@ -166,14 +168,17 @@ roles:
- Don't verify facts — that's the Fact-checker. - Don't verify facts — that's the Fact-checker.
- Don't make substantive changes. Edits are minimal and mechanical. - Don't make substantive changes. Edits are minimal and mechanical.
HOW TO WORK
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important".
HOW TO LEAVE COMMENTS HOW TO LEAVE COMMENTS
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Open the comment with the label `[Copyedit]`. Tag severity: You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
- [Critical] — a grammar/spelling error or typo visible to the reader. - [Critical] — a grammar/spelling error or typo visible to the reader.
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it). - [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
- [Minor] — optional polish. - [Minor] — optional polish.
TONE TONE
To the point, no explaining the obvious. Group repeated fixes (e.g. "throughout: straight quotes → curly") so you don't spawn dozens of identical comments. To the point, no explaining the obvious. Don't fold repeated fixes into a single "change it everywhere" note — spread them across the specific spots: ten targeted comments each carrying a ready replacement beat one blanket comment that can't be applied with a button. Don't worry about "spawning" comments — for a copyeditor that's normal.
WHEN UNSURE WHEN UNSURE
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant. If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
@@ -272,7 +277,7 @@ roles:
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy. First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
═══ HOW TO LEAVE NOTES ═══ ═══ HOW TO LEAVE NOTES ═══
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing. You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
═══ TONE ═══ ═══ TONE ═══
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it. Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
+11 -6
View File
@@ -34,11 +34,13 @@ roles:
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений. Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность: Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента. - [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок. - [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
- [Незначительно] — улучшение подачи или стройности, не обязательное. - [Незначительно] — улучшение подачи или стройности, не обязательное.
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
ТОН ТОН
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом. Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
@@ -85,7 +87,7 @@ roles:
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой. - Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность: Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
- [Критично] — предложение непонятно или искажает смысл. - [Критично] — предложение непонятно или искажает смысл.
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение. - [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
- [Незначительно] — стилистическое улучшение на вкус. - [Незначительно] — стилистическое улучшение на вкус.
@@ -126,7 +128,7 @@ roles:
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо]. - Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность: Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации. - [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника. - [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить. - [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
@@ -167,14 +169,17 @@ roles:
- Не проверяешь достоверность фактов — это фактчекер. - Не проверяешь достоверность фактов — это фактчекер.
- Не вносишь содержательных изменений. Правки — минимальные и механические. - Не вносишь содержательных изменений. Правки — минимальные и механические.
КАК РАБОТАТЬ
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное».
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность: Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю. - [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте). - [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
- [Незначительно] — необязательная шлифовка. - [Незначительно] — необязательная шлифовка.
ТОН ТОН
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев. По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
ПРИ НЕУВЕРЕННОСТИ ПРИ НЕУВЕРЕННОСТИ
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант. Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
@@ -273,7 +278,7 @@ roles:
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью. Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══ ═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь. Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
═══ ТОН ═══ ═══ ТОН ═══
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть. Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
+5 -5
View File
@@ -12,15 +12,15 @@ bundles:
- en - en
roles: roles:
- slug: structural-editor - slug: structural-editor
version: 2 version: 4
- slug: line-editor - slug: line-editor
version: 2 version: 4
- slug: fact-checker - slug: fact-checker
version: 3 version: 5
- slug: proofreader - slug: proofreader
version: 3 version: 7
- slug: narrator - slug: narrator
version: 1 version: 2
- id: research - id: research
name: name:
ru: Исследование ru: Исследование
+10 -10
View File
@@ -1,26 +1,26 @@
{ {
"fact-checker": { "fact-checker": {
"version": 3, "version": 5,
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf" "hash": "d7769872968109a1ccfb58d71bc3f3564a750b91766156f59031762848de4f24"
}, },
"line-editor": { "line-editor": {
"version": 2, "version": 4,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb" "hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
}, },
"narrator": { "narrator": {
"version": 1, "version": 2,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c" "hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
}, },
"proofreader": { "proofreader": {
"version": 3, "version": 7,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952" "hash": "fdf8e0a443fa3c4102095e024146401363629a3f9015fb938c7bac2642825e56"
}, },
"researcher": { "researcher": {
"version": 1, "version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace" "hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
}, },
"structural-editor": { "structural-editor": {
"version": 2, "version": 4,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74" "hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
} }
} }
+50
View File
@@ -0,0 +1,50 @@
/**
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
* /perf/ai-chat-perf.html; never part of the production build, which uses the
* single default index.html entry).
*
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
* React mounts so ChatThread's DefaultChatTransport requests to
* /api/ai-chat/stream are answered by the synthetic SSE generator.
*/
import "@mantine/core/styles.css";
import ReactDOM from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { mantineCssResolver, theme } from "../src/theme.ts";
// i18n side-effect init (http-backend). Translations load from /locales in dev;
// missing keys fall back to the key text, which is fine for the harness.
import "../src/i18n.ts";
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
import PerfHarness from "./harness.tsx";
// MUST run before React mounts: ChatThread creates its transport with the
// global fetch, so the patch has to be in place before the first send.
installAiChatStreamFetchPatch();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
},
},
});
const container = document.getElementById("root") as HTMLElement;
ReactDOM.createRoot(container).render(
<MemoryRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<QueryClientProvider client={queryClient}>
<PerfHarness />
</QueryClientProvider>
</MantineProvider>
</MemoryRouter>,
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI chat perf harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./ai-chat-perf-main.tsx"></script>
</body>
</html>
+390
View File
@@ -0,0 +1,390 @@
/**
* DEV-ONLY perf harness UI for the AI chat feature.
*
* Left panel: controls + live stats. Right side: a bordered box (~real chat
* window size) hosting the REAL ChatThread component.
*
* Scenario A "Open existing chat": mount ChatThread seeded with a large
* persisted transcript and measure click -> post-mount-paint time.
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
* through the real useChat pipeline.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, MutableRefObject } from "react";
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
import {
PRESETS,
buildPersistedRows,
buildTurnScript,
setLiveStreamSettings,
type PresetKey,
} from "./synthetic-turn.ts";
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
const AUTO_SEND_TIMEOUT_MS = 1000;
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
const STATS_FLUSH_MS = 500;
// ---------------------------------------------------------------------------
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
// ---------------------------------------------------------------------------
interface PerfStats {
longtaskCount: number;
longtaskTotalMs: number;
longtaskMaxMs: number;
fps: number;
sseChunks: number;
sseChars: number;
mountAMs: number | null;
streamState: "idle" | "streaming" | "done" | "aborted";
}
function emptyStats(): PerfStats {
return {
longtaskCount: 0,
longtaskTotalMs: 0,
longtaskMaxMs: 0,
fps: 0,
sseChunks: 0,
sseChars: 0,
mountAMs: null,
streamState: "idle",
};
}
/**
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
* re-renders only this panel — NOT the ChatThread under measurement.
*/
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
// Long tasks (main-thread blocks > 50ms).
useEffect(() => {
let observer: PerformanceObserver | null = null;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
stats.current.longtaskCount += 1;
stats.current.longtaskTotalMs += entry.duration;
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// longtask entries unsupported in this browser — panel shows zeros.
}
return () => observer?.disconnect();
}, [stats]);
// FPS: frames rendered within the trailing 1s window.
useEffect(() => {
let raf = 0;
const frames: number[] = [];
const loop = (now: number) => {
frames.push(now);
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
stats.current.fps = frames.length;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [stats]);
// Flush the mutable stats into the display at most 2x/s.
useEffect(() => {
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
return () => window.clearInterval(id);
}, [stats]);
const resetLongtasks = () => {
stats.current.longtaskCount = 0;
stats.current.longtaskTotalMs = 0;
stats.current.longtaskMaxMs = 0;
setSnapshot({ ...stats.current });
};
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
return (
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
<div style={row}>
<span>Mount A</span>
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
</div>
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
Reset long tasks
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
// ---------------------------------------------------------------------------
/**
* Fill the composer textarea via the native value setter + an `input` event
* (React 18 controlled-input pattern), then click the enabled "Send" button.
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
*/
function autoSend(host: HTMLElement, text: string): void {
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
const tryClick = () => {
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
if (button && !button.disabled) {
button.click();
return;
}
if (performance.now() < deadline) requestAnimationFrame(tryClick);
else console.error("[perf] auto-send: Send button never became clickable");
};
const trySetValue = () => {
const textarea = host.querySelector("textarea");
if (!textarea) {
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
else console.error("[perf] auto-send: textarea not found");
return;
}
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, text);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
// Click on a later frame so React commits the controlled value (which
// enables the Send button) before we press it.
requestAnimationFrame(tryClick);
};
requestAnimationFrame(trySetValue);
}
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
interface MountState {
mode: "A" | "B";
key: number;
chatId: string | null;
rows: IAiChatMessageRow[];
}
const noop = (): void => {};
export default function PerfHarness() {
const [preset, setPreset] = useState<PresetKey>("20k");
const [intervalMs, setIntervalMs] = useState<number>(15);
const [mounted, setMounted] = useState<MountState | null>(null);
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
const statsRef = useRef<PerfStats>(emptyStats());
const hostRef = useRef<HTMLDivElement>(null);
const keyCounterRef = useRef(0);
const mountStartRef = useRef(0);
const pendingMountMeasureRef = useRef(false);
// The scripted live turn for the current preset (reused across B runs; the
// script is immutable data, so rebuilding per run is unnecessary).
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
// Scenario A: mount ChatThread seeded with a large persisted transcript.
const handleMountA = () => {
const fixture = buildPersistedRows(PRESETS[preset]);
setFixtureInfo(
`Persisted fixture: ${fixture.rows.length} rows, ` +
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
);
statsRef.current.mountAMs = null;
// Mark AFTER fixture generation: we measure mount cost, not generation cost
// (production receives its rows from the network).
performance.mark("perf:mountA:start");
mountStartRef.current = performance.now();
pendingMountMeasureRef.current = true;
keyCounterRef.current += 1;
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
};
// Measure scenario A: effect runs after the mount commit; double rAF lands
// after the first paint of the mounted transcript.
useEffect(() => {
if (!pendingMountMeasureRef.current) return;
pendingMountMeasureRef.current = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
performance.mark("perf:mountA:end");
try {
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
} catch {
// Marks cleared mid-run — ignore.
}
});
});
}, [mounted]);
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
const handleStartB = () => {
statsRef.current.sseChunks = 0;
statsRef.current.sseChars = 0;
statsRef.current.streamState = "streaming";
setLiveStreamSettings({
script: liveScript,
chunkIntervalMs: intervalMs,
onProgress: (chunks, chars) => {
statsRef.current.sseChunks = chunks;
statsRef.current.sseChars = chars;
},
onDone: () => {
statsRef.current.streamState = "done";
performance.mark("perf:streamB:end");
try {
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
} catch {
// Start mark missing (e.g. marks cleared) — ignore.
}
},
onAbort: () => {
statsRef.current.streamState = "aborted";
},
});
performance.mark("perf:streamB:start");
keyCounterRef.current += 1;
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
};
const handleUnmount = () => setMounted(null);
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
{/* Left: controls + stats */}
<div
style={{
width: 260,
flex: "0 0 260px",
padding: 12,
borderRight: "1px solid #ccc",
overflowY: "auto",
boxSizing: "border-box",
}}
>
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
<label style={label}>Preset</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as PresetKey)}
style={{ width: "100%" }}
>
<option value="5k">5k tokens</option>
<option value="20k">20k tokens</option>
<option value="50k">50k tokens</option>
</select>
<label style={label}>Chunk interval (scenario B)</label>
<select
value={intervalMs}
onChange={(e) => setIntervalMs(Number(e.target.value))}
style={{ width: "100%" }}
>
<option value={15}>15 ms (normal)</option>
<option value={5}>5 ms (stress)</option>
</select>
<div style={{ marginTop: 12 }}>
<button type="button" style={button} onClick={handleMountA}>
Mount persisted chat (A)
</button>
<button type="button" style={button} onClick={handleStartB}>
Start live stream (B)
</button>
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
Unmount
</button>
</div>
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
<div>
Live turn: {liveScript.totalChars.toLocaleString()} chars {" "}
{liveScript.approxTokens.toLocaleString()} tokens
</div>
{fixtureInfo && <div>{fixtureInfo}</div>}
{mounted && (
<div>
Mounted: scenario {mounted.mode} (key {mounted.key})
</div>
)}
</div>
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
<StatsPanel stats={statsRef} />
</div>
{/* Right: the real ChatThread inside a real-window-sized box */}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f4f4f5",
}}
>
<div
ref={hostRef}
style={{
width: 540,
height: 680,
border: "1px solid #bbb",
borderRadius: 8,
background: "#fff",
padding: 8,
boxSizing: "border-box",
overflow: "hidden",
}}
>
{mounted ? (
<ChatThread
key={mounted.key}
chatId={mounted.chatId}
threadKey={`perf-${mounted.key}`}
initialRows={mounted.rows}
openPage={openPage}
roleId={null}
roles={[]}
onRolePicked={noop}
assistantName="Perf agent"
onTurnFinished={noop}
onServerChatId={noop}
/>
) : (
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
ChatThread unmounted. Use the controls on the left.
</div>
)}
</div>
</div>
</div>
);
}
+517
View File
@@ -0,0 +1,517 @@
/**
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
*
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
* from a size config, and materializes it two ways:
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
*
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
* (strict objects — only the exact field names below are accepted).
*/
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
// ---------------------------------------------------------------------------
// Config / presets
// ---------------------------------------------------------------------------
/** 1 token ~= 4 chars — the approximation used throughout this module. */
const CHARS_PER_TOKEN = 4;
export interface TurnConfig {
/** Number of agent steps; each step = one reasoning block + one tool call. */
steps: number;
/** Approximate reasoning tokens generated per step. */
reasoningTokensPerStep: number;
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
toolOutputBytes: number;
/** Approximate size of the final markdown answer, in tokens. */
answerTokens: number;
}
export type PresetKey = "5k" | "20k" | "50k";
export const PRESETS: Record<PresetKey, TurnConfig> = {
"5k": {
steps: 3,
reasoningTokensPerStep: 500,
toolOutputBytes: 10_000,
answerTokens: 600,
},
"20k": {
steps: 6,
reasoningTokensPerStep: 2500,
toolOutputBytes: 20_000,
answerTokens: 1500,
},
"50k": {
steps: 10,
reasoningTokensPerStep: 4000,
toolOutputBytes: 40_000,
answerTokens: 3000,
},
};
// ---------------------------------------------------------------------------
// Text generators
// ---------------------------------------------------------------------------
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
const REASONING_SENTENCES = [
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
"First I need to inspect the current page content to understand its overall structure.",
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
"The table in section three contains the migration matrix that I should cross-check against the summary.",
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
];
/**
* Build realistic prose of ~`targetChars` characters, inserting a newline
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
*/
function makeProse(targetChars: number): string {
const pieces: string[] = [];
let length = 0;
let sinceNewline = 0;
let i = 0;
while (length < targetChars) {
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
i += 1;
pieces.push(sentence);
length += sentence.length + 1;
sinceNewline += sentence.length + 1;
if (sinceNewline >= 200) {
pieces.push("\n");
sinceNewline = 0;
} else {
pieces.push(" ");
}
}
return pieces.join("").trimEnd();
}
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
function markdownSection(n: number): string {
return [
`## Section ${n}: migration analysis`,
``,
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
``,
`- Update the fetch layer to the v6 transport`,
`- Перенести таблицы соответствия идентификаторов`,
`- Verify citation links after the move`,
`- Проверить отображение длинных ответов в узкой панели`,
``,
`| Область | Страниц | Статус | Риск |`,
`| --- | --- | --- | --- |`,
`| API reference | ${n + 4} | migrated | low |`,
`| Onboarding | ${n + 2} | in progress | medium |`,
`| Release notes | ${n * 3} | pending | high |`,
``,
"```ts",
`export function migrateSection${n}(rows: Row[]): Row[] {`,
` return rows`,
` .filter((row) => row.section === ${n})`,
` .map((row) => ({ ...row, migrated: true }));`,
`}`,
"```",
].join("\n");
}
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
function makeMarkdownAnswer(targetChars: number): string {
const sections: string[] = [];
let length = 0;
let n = 1;
while (length < targetChars) {
const section = markdownSection(n);
sections.push(section);
length += section.length + 2;
n += 1;
}
return sections.join("\n\n");
}
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
function makeFiller(bytes: number): string {
const unit = "Perf filler content for the synthetic getPage tool output. ";
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
}
// ---------------------------------------------------------------------------
// Turn script
// ---------------------------------------------------------------------------
export interface TurnToolCall {
toolCallId: string;
toolName: "getPage";
input: { pageId: string };
output: { id: string; title: string; content: string };
}
export interface TurnStep {
reasoningText: string;
tool: TurnToolCall;
}
export interface TurnScript {
steps: TurnStep[];
answerText: string;
/** Approximate reasoning tokens for the whole turn (chars / 4). */
reasoningTokens: number;
/** Approximate context size after this turn, in tokens. */
contextTokens: number;
maxContextTokens: number;
/** Actual generated visible chars: reasoning + tool outputs + answer. */
totalChars: number;
/** totalChars / 4, rounded. */
approxTokens: number;
}
/**
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
*/
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
const steps: TurnStep[] = [];
let reasoningChars = 0;
let toolChars = 0;
for (let i = 0; i < config.steps; i++) {
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
const content = makeFiller(config.toolOutputBytes);
reasoningChars += reasoningText.length;
toolChars += content.length;
steps.push({
reasoningText,
tool: {
toolCallId: `${idPrefix}-call-${i + 1}`,
toolName: "getPage",
input: { pageId: "page-1" },
output: { id: "page-1", title: "Perf test page", content },
},
});
}
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
const totalChars = reasoningChars + toolChars + answerText.length;
return {
steps,
answerText,
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
maxContextTokens: 200_000,
totalChars,
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
};
}
// ---------------------------------------------------------------------------
// Scenario A: persisted rows
// ---------------------------------------------------------------------------
/** Number of user+assistant pairs the preset is split across for history. */
const HISTORY_TURNS = 3;
const USER_PROMPTS = [
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
"Now cross-check the migration matrix against the summary and list every mismatch.",
"Собери финальный план миграции с оценкой рисков по каждой области.",
];
/** Persisted UIMessage parts for one finished assistant turn. */
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
const parts: unknown[] = [];
for (const step of script.steps) {
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
parts.push({
type: `tool-${step.tool.toolName}`,
toolCallId: step.tool.toolCallId,
state: "output-available",
input: step.tool.input,
output: step.tool.output,
});
}
parts.push({ type: "text", text: script.answerText, state: "done" });
return parts as UIMessage["parts"];
}
export interface PersistedFixture {
rows: IAiChatMessageRow[];
totalChars: number;
approxTokens: number;
}
/**
* Materialize the preset as a finished 3-turn transcript: user row + assistant
* row per turn, with the preset's steps/answer split across the assistant turns.
* Approximate accounting — the actual totals are reported back for display.
*/
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
const rows: IAiChatMessageRow[] = [];
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
let totalChars = 0;
for (let t = 0; t < HISTORY_TURNS; t++) {
// Distribute steps as evenly as possible (earlier turns get the remainder).
const stepsForTurn =
Math.floor(config.steps / HISTORY_TURNS) +
(t < config.steps % HISTORY_TURNS ? 1 : 0);
const turnConfig: TurnConfig = {
steps: Math.max(1, stepsForTurn),
reasoningTokensPerStep: config.reasoningTokensPerStep,
toolOutputBytes: config.toolOutputBytes,
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
};
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
totalChars += script.totalChars;
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
rows.push({
id: `perf-row-u${t + 1}`,
role: "user",
content: userText,
metadata: null,
createdAt: new Date(baseTime + t * 60_000).toISOString(),
});
rows.push({
id: `perf-row-a${t + 1}`,
role: "assistant",
content: script.answerText,
metadata: {
parts: scriptToPersistedParts(script),
usage: { reasoningTokens: script.reasoningTokens },
contextTokens: script.contextTokens,
maxContextTokens: script.maxContextTokens,
finishReason: "stop",
},
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
});
}
return {
rows,
totalChars,
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
};
}
// ---------------------------------------------------------------------------
// Scenario B: SSE stream
// ---------------------------------------------------------------------------
/** Streaming delta size in chars (reasoning/answer text is split into these). */
const DELTA_CHARS = 200;
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
const deltas: string[] = [];
for (let i = 0; i < text.length; i += size) {
deltas.push(text.slice(i, i + size));
}
return deltas;
}
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
interface SseFrame {
data: string;
chars: number;
}
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
}
/**
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
* (excluding the final `data: [DONE]` terminator, appended by the pump).
*/
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
const frames: SseFrame[] = [];
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
script.steps.forEach((step, i) => {
frames.push(frame({ type: "start-step" }));
const reasoningId = `${messageId}-r${i + 1}`;
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
for (const delta of splitDeltas(step.reasoningText)) {
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
}
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
const { toolCallId, toolName, input, output } = step.tool;
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
// The tool result arrives as ONE chunk, like the real server sends it.
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
frames.push(frame({ type: "finish-step" }));
});
// Final step: the markdown answer.
frames.push(frame({ type: "start-step" }));
const textId = `${messageId}-answer`;
frames.push(frame({ type: "text-start", id: textId }));
for (const delta of splitDeltas(script.answerText)) {
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
}
frames.push(frame({ type: "text-end", id: textId }));
frames.push(frame({ type: "finish-step" }));
frames.push(
frame({
type: "finish",
messageMetadata: {
usage: { reasoningTokens: script.reasoningTokens },
contextTokens: script.contextTokens,
maxContextTokens: script.maxContextTokens,
finishReason: "stop",
},
}),
);
return frames;
}
export interface LiveStreamSettings {
script: TurnScript;
/** Delay between SSE chunks (one chunk per tick). */
chunkIntervalMs: number;
/** Progress callback: cumulative emitted chunk count and visible chars. */
onProgress?: (chunks: number, chars: number) => void;
/** Fired once after the `[DONE]` terminator is enqueued. */
onDone?: () => void;
/** Fired if the client aborted the stream (Stop button). */
onAbort?: () => void;
}
/**
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
*/
export function buildSseResponse(
settings: LiveStreamSettings,
signal?: AbortSignal | null,
): Response {
const messageId = `m-live-${Date.now()}`;
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
const encoder = new TextEncoder();
let index = 0;
let emittedChars = 0;
let timer: number | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const stopPump = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
const pump = () => {
timer = undefined;
if (signal?.aborted) {
stopPump();
try {
controller.close();
} catch {
// Already closed/cancelled — nothing to do.
}
return;
}
if (index >= frames.length) {
try {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch {
// Cancelled mid-flight.
}
settings.onDone?.();
return;
}
const next = frames[index];
index += 1;
try {
controller.enqueue(encoder.encode(next.data));
} catch {
stopPump();
return;
}
emittedChars += next.chars;
settings.onProgress?.(index, emittedChars);
timer = window.setTimeout(pump, settings.chunkIntervalMs);
};
signal?.addEventListener(
"abort",
() => {
stopPump();
try {
controller.close();
} catch {
// Reader already cancelled.
}
settings.onAbort?.();
},
{ once: true },
);
timer = window.setTimeout(pump, settings.chunkIntervalMs);
},
cancel() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
},
});
return new Response(stream, {
status: 200,
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"x-vercel-ai-ui-message-stream": "v1",
},
});
}
// ---------------------------------------------------------------------------
// window.fetch patch
// ---------------------------------------------------------------------------
let currentLiveSettings: LiveStreamSettings | null = null;
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
currentLiveSettings = settings;
}
/**
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
* get the synthetic SSE Response; everything else passes through untouched.
*/
export function installAiChatStreamFetchPatch(): void {
const originalFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url.includes("/api/ai-chat/stream")) {
const settings = currentLiveSettings;
if (!settings) {
return Promise.resolve(
new Response("perf harness: no live stream configured", { status: 500 }),
);
}
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
}
return originalFetch(input, init);
};
}
@@ -2,7 +2,8 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai"; import { Provider, createStore } from "jotai";
import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack"; import { AgentAvatarStack } from "./agent-avatar-stack";
import { avatarStyle } from "@/lib/avatar-palette";
import { import {
activeAiChatIdAtom, activeAiChatIdAtom,
aiChatWindowOpenAtom, aiChatWindowOpenAtom,
@@ -13,14 +14,16 @@ import {
type Props = React.ComponentProps<typeof AgentAvatarStack>; type Props = React.ComponentProps<typeof AgentAvatarStack>;
// The DOM normalizes an inline `background: hsl(...)` to `rgb(...)`. Push the // The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
// expected color through the same CSSOM path so the comparison stays exact and // expected color through the same CSSOM path so the comparison stays exact and
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix // non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
// Avatar approach — can never match a real color). // Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
// glyph carries an explicit solid `background-color` we assert on here.
function normalizeColor(value: string): string { function normalizeColor(value: string): string {
const probe = document.createElement("div"); const probe = document.createElement("div");
probe.style.background = value; probe.style.backgroundColor = value;
return probe.style.background; return probe.style.backgroundColor;
} }
function renderStack(props: Props) { function renderStack(props: Props) {
@@ -36,26 +39,6 @@ function renderStack(props: Props) {
return { store, ...utils }; return { store, ...utils };
} }
describe("agentGlyphBackground", () => {
it("is deterministic for a given agent name", () => {
expect(agentGlyphBackground("Researcher")).toBe(
agentGlyphBackground("Researcher"),
);
});
it("gives categorically different colors to different agents", () => {
// The two agents that looked identically violet in the report must differ.
expect(agentGlyphBackground("Структурный редактор")).not.toBe(
agentGlyphBackground("Фактчекер"),
);
expect(agentGlyphBackground("Researcher")).not.toBe(
agentGlyphBackground("Нарратор"),
);
// Every color is a dark hsl circle drawn from the palette.
expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
});
});
describe("AgentAvatarStack", () => { describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => { it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
const { container } = renderStack({ const { container } = renderStack({
@@ -73,8 +56,8 @@ describe("AgentAvatarStack", () => {
expect(screen.getByText("Alice")).toBeDefined(); expect(screen.getByText("Alice")).toBeDefined();
}); });
it("emoji glyph applies its per-agent color as an inline DOM background", () => { it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
// Pins the actual fix: the hashed color must reach the DOM as an inline // Pins the actual fix: the hashed gradient must reach the DOM as an inline
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no // `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
// inline background (Mantine's --avatar-bg overrode it), so this fails there. // inline background (Mantine's --avatar-bg overrode it), so this fails there.
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null }; const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
@@ -88,20 +71,19 @@ describe("AgentAvatarStack", () => {
'[data-testid="agent-glyph"]', '[data-testid="agent-glyph"]',
); );
expect(glyph).not.toBeNull(); expect(glyph).not.toBeNull();
// Non-vacuous: compare against the function output (normalized the same way), const expected = normalizeColor(avatarStyle(agent.name).bg);
// not a frozen literal. Empty against the pre-fix Avatar (no inline bg). // Non-vacuous: the pre-fix Avatar set no inline background at all.
expect(glyph!.style.background).not.toBe(""); expect(expected).not.toBe("");
expect(glyph!.style.background).toBe( expect(glyph!.style.backgroundColor).toBe(expected);
normalizeColor(agentGlyphBackground(agent.name)), // (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
); // not round-trip linear-gradient — so its stops/angle are covered by the
// avatarStyle unit tests above, not asserted on the DOM here.)
}); });
it("agents with distinct hashed colors reach the DOM as distinct backgrounds", () => { it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
// "Researcher" and "Нарратор" hash to different palette entries, so their // "Researcher" and "Нарратор" hash to different palette entries, so their
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM". // applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
expect(agentGlyphBackground("Researcher")).not.toBe( expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
agentGlyphBackground("Нарратор"),
);
const a = renderStack({ const a = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null }, agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
@@ -120,14 +102,9 @@ describe("AgentAvatarStack", () => {
const glyphB = b.container.querySelector<HTMLElement>( const glyphB = b.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]', '[data-testid="agent-glyph"]',
); );
expect(glyphA!.style.background).toBe( expect(glyphA!.style.backgroundColor).not.toBe("");
normalizeColor(agentGlyphBackground("Researcher")), // Different base colors reach the DOM (the serialized rgb values differ).
); expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
expect(glyphB!.style.background).toBe(
normalizeColor(agentGlyphBackground("Нарратор")),
);
// Different colors reach the DOM (the normalized rgb values also differ).
expect(glyphA!.style.background).not.toBe(glyphB!.style.background);
}); });
it("showName=false: renders only the avatars, no inline name label", () => { it("showName=false: renders only the avatars, no inline name label", () => {
@@ -4,6 +4,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
import { import {
activeAiChatIdAtom, activeAiChatIdAtom,
aiChatWindowOpenAtom, aiChatWindowOpenAtom,
@@ -29,54 +30,11 @@ const LAUNCHER_SIZE = 22;
// sits as a small badge over that corner (above the glyph) and stays fully visible. // sits as a small badge over that corner (above the glyph) and stays fully visible.
const LAUNCHER_OVERHANG = 8; const LAUNCHER_OVERHANG = 8;
// Small deterministic string hash (same algorithm as custom-avatar's initials
// hash) used to pick a stable per-agent glyph color.
function hashName(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
// A palette of categorically-DISTINCT dark circle colors for emoji/sparkles agent
// glyphs. Every entry is intentionally dark (low lightness) so a bright emoji or
// the white sparkles icon stays readable on top; the hues are spread across the
// wheel (red → orange → amber → green → teal → cyan → blue → indigo → violet →
// magenta + a neutral slate) so two different agents read as DIFFERENT colors,
// not merely different shades of the same violet.
const GLYPH_COLORS = [
"hsl(355, 60%, 34%)", // red
"hsl(18, 62%, 32%)", // vermilion
"hsl(32, 60%, 30%)", // orange
"hsl(45, 55%, 28%)", // amber
"hsl(75, 45%, 26%)", // olive-green
"hsl(140, 48%, 26%)", // green
"hsl(165, 52%, 26%)", // teal
"hsl(188, 58%, 28%)", // cyan
"hsl(205, 58%, 32%)", // sky blue
"hsl(225, 52%, 36%)", // blue
"hsl(250, 48%, 38%)", // indigo
"hsl(280, 46%, 36%)", // violet
"hsl(312, 48%, 34%)", // magenta
"hsl(210, 12%, 36%)", // slate / neutral
];
/**
* Deterministic dark circle color for an emoji/sparkles agent glyph, picked from
* GLYPH_COLORS by a hash of the agent name so distinct agents get categorically
* distinct colors while every color stays dark enough to keep the glyph readable.
*/
export function agentGlyphBackground(name: string): string {
return GLYPH_COLORS[hashName(name) % GLYPH_COLORS.length];
}
/** /**
* The front avatar. Image-source priority (#300): * The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account). * 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a per-agent dark circle. * 2. agent.emoji -> the role emoji on a per-agent gradient circle.
* 3. otherwise -> the IconSparkles glyph on a per-agent dark circle (fallback). * 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
*/ */
function AgentGlyph({ agent }: { agent: AgentInfo }) { function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) { if (agent.avatarUrl) {
@@ -89,10 +47,13 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
); );
} }
// Emoji/sparkles glyph on a per-agent dark circle (color hashed from the agent // Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
// name). Rendered as a plain Box, NOT a Mantine `Avatar variant="filled"`, so // and split angle all hashed from the agent name via avatarStyle — see
// the background is guaranteed instead of being overridden by Mantine's // @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
// `--avatar-bg` (which was falling back to the theme's violet for every agent). // `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
// (every agent fell back to the theme's violet). The foreground (the sparkles
// icon) uses the ring's WCAG-checked readable text color.
const style = avatarStyle(agent.name);
return ( return (
<Box <Box
data-testid="agent-glyph" data-testid="agent-glyph"
@@ -100,8 +61,14 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
width: GLYPH_SIZE, width: GLYPH_SIZE,
height: GLYPH_SIZE, height: GLYPH_SIZE,
borderRadius: "50%", borderRadius: "50%",
background: agentGlyphBackground(agent.name), // Solid base color is the fallback (and the testable value); the gradient
color: "var(--mantine-color-white)", // paints over it in browsers that support it.
backgroundColor: style.bg,
backgroundImage: avatarBackgroundCss(style),
color:
style.text === "white"
? "var(--mantine-color-white)"
: "var(--mantine-color-black)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -164,8 +164,8 @@
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the /* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
rendered markdown <div> it would turn the newlines between block tags rendered markdown <div> it would turn the newlines between block tags
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the (</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
margins. The plain-text fallback <Text> that needs pre-wrap sets it margins. The streaming plain-text path that needs pre-wrap sets it
inline itself (see reasoning-block.tsx). */ per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
} }
.reasoningText p { .reasoningText p {
@@ -65,6 +65,25 @@ describe("arePropsEqual", () => {
expect(arePropsEqual(props(m), props(m))).toBe(true); expect(arePropsEqual(props(m), props(m))).toBe(true);
}); });
// REGRESSION (stranded reasoning part): a reasoning part is left at
// `state:"streaming"` forever when the turn ends without `reasoning-end`
// (manual Stop during thinking). The signature is EQUAL across that turn-end
// flip (nothing in the message changed), so the comparator must ALSO compare
// `turnStreaming` — otherwise the memo swallows the flip and ReasoningBlock
// never switches from chunked plain text to its one-time markdown parse.
it("returns false when turnStreaming differs despite an equal signature", () => {
const m = msg([
{ type: "reasoning", text: "thinking", state: "streaming" },
{ type: "text", text: "answer" },
]);
expect(
arePropsEqual(
props(m, { turnStreaming: true }),
props(m, { turnStreaming: false }),
),
).toBe(false);
});
it("returns true for the same content in a different message object", () => { it("returns true for the same content in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]); const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer" }]); const b = msg([{ type: "text", text: "answer" }]);
@@ -52,6 +52,20 @@ interface MessageItemProps {
* absent; the public share passes the configured identity (agent role) name. * absent; the public share passes the configured identity (agent role) name.
*/ */
assistantName?: string; assistantName?: string;
/**
* Whether the WHOLE turn is still streaming (MessageList's `isStreaming`).
* A reasoning part may be left `state: "streaming"` forever when the turn
* ends without a `reasoning-end` chunk (manual Stop during the thinking
* phase, or a provider that never emits it) — the AI SDK finalizes reasoning
* state ONLY on `reasoning-end`, not on `finish-step`/`finish`. So part-level
* state alone cannot prove liveness; the reasoning part is treated as live
* only while the whole turn is still streaming. Defaults to false.
*
* The parent passes it as "turn is live AND this is the tail row", so a
* stranded part in an EARLIER row never re-activates when a later turn
* streams.
*/
turnStreaming?: boolean;
} }
/** /**
@@ -105,6 +119,7 @@ function MessageItem({
showCitations = true, showCitations = true,
neutralizeInternalLinks = false, neutralizeInternalLinks = false,
assistantName, assistantName,
turnStreaming = false,
}: MessageItemProps) { }: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the // `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly. // memo key (see arePropsEqual). The render reads `message` directly.
@@ -155,8 +170,23 @@ function MessageItem({
const text = (part as { text?: string }).text ?? ""; const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0)) if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null; return null;
// Absent state (persisted rows) and "done" both mean finalized.
// `messageSignature` already includes each part's `state`, so the
// streaming→done flip changes the row signature and re-renders this
// row — which is what lets ReasoningBlock switch from chunked plain
// text to its one-time markdown parse (see reasoning-block.tsx).
// ALSO require the turn to be live: a part stranded at
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
// the `turnStreaming` prop doc) must still finalize and parse.
const streaming =
turnStreaming && (part as { state?: string }).state === "streaming";
return ( return (
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} /> <ReasoningBlock
key={index}
text={text}
tokens={reasoningTokens}
streaming={streaming}
/>
); );
} }
@@ -245,7 +275,11 @@ export function arePropsEqual(
prev.signature === next.signature && prev.signature === next.signature &&
prev.showCitations === next.showCitations && prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks && prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName prev.assistantName === next.assistantName &&
// The turn-end flip re-renders every row once (cheap, terminal event) —
// that is what converts a stranded `state:"streaming"` reasoning part to
// its one-time markdown parse (see the `turnStreaming` prop doc).
prev.turnStreaming === next.turnStreaming
); );
} }
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { fireEvent, render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react";
@@ -50,8 +50,9 @@ vi.stubGlobal(
// One assistant message wrapping the given `parts`. Reused across renders in the // One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object. // regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage => // Pass an explicit `id` when a test renders several rows at once.
({ id: "m1", role: "assistant", parts }) as UIMessage; const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
({ id, role: "assistant", parts }) as UIMessage;
describe("MessageList", () => { describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => { it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
@@ -116,4 +117,102 @@ describe("MessageList", () => {
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"), renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true); ).toBe(true);
}); });
// REGRESSION (stranded reasoning part): the AI SDK sets a reasoning part's
// state to "done" ONLY on the `reasoning-end` chunk — `finish-step`/`finish`
// do NOT finalize it. A manual Stop during the thinking phase (or a provider
// that never emits `reasoning-end`) therefore leaves the part at
// `state:"streaming"` forever. MessageItem must derive ReasoningBlock's
// `streaming` from part state AND turn liveness (MessageList's `isStreaming`,
// forwarded as `turnStreaming`): while the turn streams the expanded block
// shows chunked plain text (no parse); once the turn ends — even though the
// part is still `state:"streaming"` — the block finalizes and does its
// one-time markdown parse. Note the message signature does NOT change across
// that flip, so this also exercises the `turnStreaming` memo comparison in
// arePropsEqual (without it the row would never re-render).
it("finalizes a reasoning part stranded at state:'streaming' when the turn ends", () => {
renderChatMarkdownSpy.mockClear();
const reasoningText = "**bold** thinking";
// Reasoning part stranded mid-stream + a non-empty answer part (a
// reasoning-only message renders nothing — see message-content.ts).
const message = msg([
{ type: "reasoning", text: reasoningText, state: "streaming" },
{ type: "text", text: "partial answer" },
]);
const parsesOfReasoning = () =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
.length;
const { rerender, getByRole, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Expand the reasoning block (its toggle is the only button in the list).
fireEvent.click(getByRole("button"));
// Turn live + part streaming -> ReasoningBlock received streaming=true:
// the body is chunked plain text (raw markdown syntax), NOT parsed.
expect(queryByText(/bold/)).not.toBeNull();
expect(parsesOfReasoning()).toBe(0);
// The turn ends WITHOUT `reasoning-end`: the part object is untouched
// (still state:"streaming"), only the turn-level flag flips.
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming={false} />
</MantineProvider>,
);
// ReasoningBlock now received streaming=false and did its one-time parse.
expect(parsesOfReasoning()).toBe(1);
});
// REGRESSION (turn-global liveness leaking into earlier rows): `isStreaming`
// is turn-global, so forwarding it to EVERY row would re-mark a reasoning
// part stranded at `state:"streaming"` in a PREVIOUS message (see the test
// above) as live again whenever a LATER turn streams — an expanded stranded
// block would flip markdown -> raw plain text -> markdown across turn
// boundaries, re-parsing each time. MessageList must gate `turnStreaming`
// to the TAIL row only.
it("keeps a stranded reasoning part in an earlier message finalized while a later turn streams", () => {
renderChatMarkdownSpy.mockClear();
const reasoningText = "**bold** thinking";
// First (earlier) assistant message: its turn was stopped during the
// thinking phase, leaving the reasoning part at state:"streaming".
const first = msg(
[
{ type: "reasoning", text: reasoningText, state: "streaming" },
{ type: "text", text: "first answer" },
],
"m1",
);
// Second assistant message: the LATER turn, currently streaming.
const second = msg([{ type: "text", text: "second answer" }], "m2");
const parsesOfReasoning = () =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
.length;
const { rerender, getByRole, queryByText } = render(
<MantineProvider>
<MessageList messages={[first, second]} isStreaming />
</MantineProvider>,
);
// Expand the first row's reasoning block (the only toggle in the list —
// the second message has no reasoning or tool parts).
fireEvent.click(getByRole("button"));
// The turn is live but the first row is NOT the tail: its ReasoningBlock
// received streaming=false, so the stranded part stays finalized and does
// its one-time markdown parse instead of dropping to chunked plain text.
expect(queryByText(/bold/)).not.toBeNull();
expect(parsesOfReasoning()).toBe(1);
// A later-turn delta re-renders the list; the earlier block must neither
// flip back to streaming nor re-parse.
(second.parts[0] as { text: string }).text = "second answer grows";
rerender(
<MantineProvider>
<MessageList messages={[first, second]} isStreaming />
</MantineProvider>,
);
expect(parsesOfReasoning()).toBe(1);
});
}); });
@@ -196,7 +196,7 @@ export default function MessageList({
return ( return (
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll"> <ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs"> <Stack gap={0} pr="xs">
{messages.map((message) => ( {messages.map((message, index) => (
// `signature` is snapshotted HERE (parent render) into an immutable // `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be // string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the // recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
@@ -210,6 +210,13 @@ export default function MessageList({
showCitations={showCitations} showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks} neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName} assistantName={assistantName}
// Turn-level liveness, gated to the TAIL row: only the tail message
// can belong to the in-flight turn, so a reasoning part stranded at
// `state:"streaming"` in an EARLIER message (its turn ended without
// `reasoning-end`) stays finalized and doesn't flip back to plain
// text (and re-parse) whenever a later turn streams — see
// message-item.tsx.
turnStreaming={isStreaming && index === messages.length - 1}
/> />
))} ))}
{typing && ( {typing && (
@@ -28,7 +28,11 @@ import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. // matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBlock(props: { text: string; tokens?: number }) { function renderBlock(props: {
text: string;
tokens?: number;
streaming?: boolean;
}) {
return render( return render(
<MantineProvider> <MantineProvider>
<ReasoningBlock {...props} /> <ReasoningBlock {...props} />
@@ -84,4 +88,54 @@ describe("ReasoningBlock", () => {
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole("button"));
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
}); });
it("does not parse while expanded and STREAMING; shows chunked plain text", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({
text: "первый абзац размышлений\n\nвторой абзац растёт",
tokens: 5,
streaming: true,
});
fireEvent.click(screen.getByRole("button"));
// Expanded + still streaming: NO markdown parse and NO innerHTML swaps per
// delta — the body is chunked plain text (only the tail chunk updates).
// This is the O(n²) hole #302 left open (Safari whole-tab freeze).
expect(renderSpy).not.toHaveBeenCalled();
// Both paragraph chunks' raw text is present in the body.
expect(screen.getByText(/первый абзац размышлений/)).toBeDefined();
expect(screen.getByText(/второй абзац растёт/)).toBeDefined();
});
it("parses exactly once when streaming flips to done while expanded", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
const { rerender } = renderBlock({
text: "**bold** reasoning",
tokens: 5,
streaming: true,
});
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).not.toHaveBeenCalled();
// Finalization: the part's state flips streaming→done, the parent
// re-renders the row (the flip changes the message signature), and the
// block does its ONE markdown parse of the now-stable text.
rerender(
<MantineProvider>
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
</MantineProvider>,
);
expect(renderSpy).toHaveBeenCalledTimes(1);
// The parsed html branch rendered (the mock wraps the input in <p>…</p>).
expect(screen.getByText(/reasoning/)).toBeDefined();
// Further re-renders with unchanged props do not re-parse.
rerender(
<MantineProvider>
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
</MantineProvider>,
);
expect(renderSpy).toHaveBeenCalledTimes(1);
});
}); });
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts"; import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts"; import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { StreamingPlainText } from "@/features/ai-chat/components/streaming-plain-text.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ReasoningBlockProps { interface ReasoningBlockProps {
@@ -15,6 +16,10 @@ interface ReasoningBlockProps {
* step/turn has finished. When absent (or 0) the count is estimated from the * step/turn has finished. When absent (or 0) the count is estimated from the
* text length so it ticks live as the reasoning streams in. */ * text length so it ticks live as the reasoning streams in. */
tokens?: number; tokens?: number;
/** True while the reasoning part is still streaming (part `state ===
* "streaming"`). False means finalized: persisted history or `state ===
* "done"`. Gates the markdown parse — see the invariant on the memo below. */
streaming?: boolean;
} }
/** /**
@@ -27,26 +32,30 @@ interface ReasoningBlockProps {
* Providers that don't stream reasoning TEXT still render this block from the * Providers that don't stream reasoning TEXT still render this block from the
* authoritative count alone (header only, empty body) so the cost is visible. * authoritative count alone (header only, empty body) so the cost is visible.
*/ */
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) { function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Authoritative count wins; otherwise estimate live from the streamed text. // Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text); const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim(); const trimmed = text.trim();
// Parse the reasoning markdown ONLY while the block is expanded. Collapsed is the // Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
// default and the common case during a long "thinking" stream: reasoning text // 1. Collapsed -> never parse (#302): the html is only shown inside
// streams in and grows with every throttled delta (~20Hz), so a `[trimmed]`-only // <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
// memo re-parses the whole, ever-growing text (marked + DOMPurify) on every delta // marked + DOMPurify storm.
// — an O(n²) storm that pins the main thread and freezes the chat, all for a block // 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
// the user isn't even looking at (the html is only shown inside <Collapse in={open}> // renders as chunked plain text (StreamingPlainText) with a memoized
// below). Gating on `open` skips that hidden parsing entirely; expanding parses the // stable prefix, so each delta updates only the tail chunk's text node.
// current text once (an instant, user-initiated click), and further streaming while // This closes the O(n²) hole #302 left open ("expanded while streaming")
// open is the normal per-delta append render, like the answer. // that froze the whole tab in Safari when watching the thinking stream.
// 3. Finalized + expanded -> exactly one parse: `trimmed` and `streaming`
// are stable after the part is done, so this memo runs once per expand.
const html = useMemo( const html = useMemo(
() => () =>
open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "", open && trimmed && !streaming
[open, trimmed], ? renderChatMarkdown(collapseBlankLines(trimmed), {})
: "",
[open, trimmed, streaming],
); );
return ( return (
@@ -83,12 +92,12 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
) : ( ) : (
<Text // Still streaming (or markdown yielded nothing): chunked plain text.
className={classes.reasoningText} // The wrapper carries the reasoningText styling; each chunk sets its
style={{ whiteSpace: "pre-wrap" }} // own pre-wrap inline (NOT on this div — see ai-chat.module.css).
> <div className={classes.reasoningText}>
{trimmed} <StreamingPlainText text={trimmed} />
</Text> </div>
)} )}
</Collapse> </Collapse>
)} )}
@@ -96,7 +105,7 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
); );
} }
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default // Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
// shallow compare), so a parent re-render during streaming of OTHER content does // props, default shallow compare), so a parent re-render during streaming of OTHER
// not re-run the markdown parse for an already-finalized reasoning block. // content does not re-run the markdown parse for an already-finalized reasoning block.
export default memo(ReasoningBlock); export default memo(ReasoningBlock);
@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
splitPlainChunks,
StreamingPlainText,
} from "./streaming-plain-text";
describe("splitPlainChunks", () => {
// THE load-bearing property (see the invariant comment in the module): under
// append-only growth, every chunk except the LAST must be byte-identical
// between successive calls, so the memoized chunk components never re-render
// for the stable prefix and each stream delta touches only the tail chunk.
it("keeps all non-last chunks byte-identical across append-only growth", () => {
// A simulated reasoning stream covering: appends inside the last paragraph,
// appends that ADD new blank lines, growth of a trailing newline run, and a
// trailing separator later followed by text.
const steps = [
"Пер",
"Первый абзац",
"Первый абзац\n",
"Первый абзац\n\n",
"Первый абзац\n\n\n",
"Первый абзац\n\n\nВторой",
"Первый абзац\n\n\nВторой абзац растёт",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\n",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\nЧетвёртый",
];
let prev: string[] = [];
for (const text of steps) {
const next = splitPlainChunks(text);
// Lossless: chunks always reassemble into the exact input.
expect(next.join("")).toBe(text);
// Chunk count never shrinks (boundaries never disappear).
expect(next.length).toBeGreaterThanOrEqual(prev.length);
// Every previously-FINAL chunk (all but prev's last) is unchanged.
for (let i = 0; i < prev.length - 1; i++) {
expect(next[i]).toBe(prev[i]);
}
prev = next;
}
// Guard against a vacuous pass: the final split must be multi-chunk.
expect(prev.length).toBeGreaterThanOrEqual(4);
});
it("attaches the blank-line separator run to the preceding chunk", () => {
expect(splitPlainChunks("a\n\nb")).toEqual(["a\n\n", "b"]);
// A longer run is ONE separator, not several boundaries.
expect(splitPlainChunks("a\n\n\n\nb")).toEqual(["a\n\n\n\n", "b"]);
expect(splitPlainChunks("a\n\nb\n\n\nc")).toEqual(["a\n\n", "b\n\n\n", "c"]);
});
it("single newlines are not boundaries", () => {
expect(splitPlainChunks("a\nb\nc")).toEqual(["a\nb\nc"]);
});
// INTENTIONAL: CRLF blank lines are NOT boundaries (the regex is `\n{2,}`
// only). Supporting `(?:\r?\n){2,}` would break the stable-prefix invariant:
// a lone trailing `\r` is not a boundary, but a later-appended `\n` would
// merge with it into a new separator unit and retroactively create a boundary
// INSIDE previously-emitted text, moving old chunk edges. So CRLF input stays
// in one (still lossless) chunk — only granularity is coarser; LLM output is
// `\n` in practice. See the doc comment on splitPlainChunks.
it("keeps CRLF blank lines inside one chunk", () => {
expect(splitPlainChunks("a\r\n\r\nb")).toEqual(["a\r\n\r\nb"]);
// Mixed input: only pure-`\n` runs split.
expect(splitPlainChunks("a\r\n\r\nb\n\nc")).toEqual(["a\r\n\r\nb\n\n", "c"]);
});
it("never emits empty phantom chunks (multi-blank-line / trailing newlines)", () => {
expect(splitPlainChunks("")).toEqual([]);
// A trailing newline run stays inside the last chunk (it may still grow).
expect(splitPlainChunks("a\n")).toEqual(["a\n"]);
expect(splitPlainChunks("a\n\n")).toEqual(["a\n\n"]);
expect(splitPlainChunks("a\n\nb\n\n")).toEqual(["a\n\n", "b\n\n"]);
// Degenerate all-newlines input is a single deterministic chunk.
expect(splitPlainChunks("\n\n\n")).toEqual(["\n\n\n"]);
for (const text of ["a\n\n\nb\n\n", "x\n\n\n\n\ny\n\nz\n"]) {
for (const chunk of splitPlainChunks(text)) {
expect(chunk.length).toBeGreaterThan(0);
}
}
});
});
describe("StreamingPlainText", () => {
it("renders one block per chunk, stripping trailing separator newlines at display time", () => {
const text = "первый абзац\n\nвторой абзац\n\n\nтретий";
const { container } = render(<StreamingPlainText text={text} />);
const blocks = Array.from(container.querySelectorAll("div"));
// One block element per chunk.
expect(blocks.length).toBe(splitPlainChunks(text).length);
// DISPLAY-ONLY strip: each rendered block drops its chunk's trailing
// separator newlines — rendering them inside a pre-wrap block would add an
// empty line ON TOP of the block break (a doubled gap). The RAW chunks
// keep their separators (losslessness is asserted on splitPlainChunks
// above); multi-blank-line runs collapse to one uniform gap, consistent
// with collapseBlankLines on the finalized markdown path.
expect(blocks.map((b) => b.textContent)).toEqual([
"первый абзац",
"второй абзац",
"третий",
]);
// The uniform paragraph gap comes from the block margin instead (matches
// the `.reasoningText p { margin: 0 0 4px }` rhythm of the markdown path).
for (const block of blocks) {
expect((block as HTMLElement).style.marginBottom).toBe("4px");
}
});
it("keeps interior newlines intact — only the trailing run is stripped", () => {
const text = "строка один\nстрока два\n\nхвост";
const { container } = render(<StreamingPlainText text={text} />);
const blocks = Array.from(container.querySelectorAll("div"));
expect(blocks.map((b) => b.textContent)).toEqual([
"строка один\nстрока два",
"хвост",
]);
});
// SECURITY INVARIANT — the load-bearing property of the streaming path: the
// reasoning text is raw, untrusted model output rendered WITHOUT a sanitizer
// (no marked/DOMPurify, no innerHTML). PlainChunk emits it as a React text
// node, which escapes it, so HTML in the model output is inert. This test
// pins that the path is a TEXT sink, not an HTML sink: a future change to
// `dangerouslySetInnerHTML` (reintroducing XSS) MUST fail here.
//
// The existing tests assert via textContent, which strips tags and so cannot
// distinguish an escaped literal from injected DOM. This one asserts on the
// parsed DOM directly: if the markup were injected as HTML, the <img>/<b>
// would become real elements and querySelector would find them.
it("renders HTML-like reasoning as an escaped literal, never as injected DOM", () => {
const text = "<img src=x onerror=alert(1)>\n\n<b>hi</b>";
const { container } = render(<StreamingPlainText text={text} />);
// No DOM elements were created from the payload — it was NOT parsed as HTML.
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("b")).toBeNull();
// The raw markup survived verbatim as text (proving it is escaped, not
// interpreted). textContent alone can't prove this, but combined with the
// querySelector assertions above it does: the literals are present AND no
// elements exist.
expect(container.textContent).toContain("<b>hi</b>");
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
});
});
@@ -0,0 +1,90 @@
import { memo, useMemo } from "react";
/**
* Split plain text into chunks at blank-line (paragraph) boundaries, keeping
* each separator run attached to the END of the preceding chunk, so the chunks
* always reassemble byte-for-byte into the input.
*
* A boundary is the end of a maximal `\n{2,}` run that is followed by at least
* one more character. A newline run that is a SUFFIX of the text is NOT a
* boundary yet: under append-only growth it may still gain more newlines, and
* cutting there would move the boundary on the next call.
*
* CRITICAL INVARIANT (load-bearing for StreamingPlainText's memoization): for
* APPEND-ONLY growth of `text`, every chunk except the LAST is byte-identical
* between successive calls — previously-emitted boundaries never move. Proof
* sketch: appending never modifies existing characters, so (a) an existing
* boundary's newline run and its following character are untouched and the
* boundary persists at the same offset; (b) no NEW boundary can appear strictly
* inside the old text, because a `\n{2,}` run followed by a character entirely
* within the old text would already have been a boundary. New boundaries can
* only materialize at or after the old text's end, i.e. inside the last chunk.
*
* CRLF is deliberately NOT a boundary: supporting `(?:\r?\n){2,}` would BREAK
* the invariant above — a lone trailing `\r` is not a boundary, but a later-
* appended `\n` would merge with it into a new separator unit and retroactively
* create a boundary INSIDE previously-emitted text, moving old chunk edges.
* With `\n`-only runs, appended characters can never extend a run that is
* already followed by a non-`\n` character, so old boundaries are immutable.
* CRLF blank lines therefore intentionally stay inside one chunk: correctness/
* losslessness are unaffected, only chunk granularity for CRLF input (LLM
* output is `\n` in practice).
*/
export function splitPlainChunks(text: string): string[] {
const chunks: string[] = [];
let start = 0;
for (const match of text.matchAll(/\n{2,}/g)) {
const end = match.index + match[0].length;
// Suffix run: not a stable boundary yet (see the invariant above).
if (end >= text.length) break;
chunks.push(text.slice(start, end));
start = end;
}
if (start < text.length) chunks.push(text.slice(start));
return chunks;
}
/**
* One immutable chunk. Memoized on its string prop: during streaming only the
* TAIL chunk's text changes (see the splitPlainChunks invariant), so React
* skips every stable chunk and the per-delta DOM work is a single text-node
* update. `pre-wrap` is set per chunk (like the old raw-text fallback did), NOT
* on the surrounding markdown-styled container — see the note in
* ai-chat.module.css. Font/size/color are inherited from that container.
*
* DISPLAY-ONLY newline strip: the raw chunk keeps its trailing `\n{2,}`
* separator run attached (the splitPlainChunks invariant, load-bearing for the
* memo), but rendering those newlines inside a pre-wrap block would add an
* empty line ON TOP of the block break — a doubled gap. So the RENDERED string
* drops trailing newlines and the paragraph gap comes from `marginBottom: 4`
* instead, matching the `.reasoningText p { margin: 0 0 4px }` rhythm of the
* finalized markdown. Multi-blank-line runs thus collapse to one uniform gap,
* consistent with `collapseBlankLines` on the markdown path. The last chunk
* usually has no trailing newlines (strip is a no-op); its margin is harmless.
*/
const PlainChunk = memo(function PlainChunk({ text }: { text: string }) {
return (
<div style={{ whiteSpace: "pre-wrap", marginBottom: 4 }}>
{text.replace(/\n+$/, "")}
</div>
);
});
/**
* Renders still-streaming plain text as a list of paragraph chunks where only
* the tail chunk changes per delta. No markdown, no sanitizer, no innerHTML —
* this is the cheap streaming-time stand-in for the one-time markdown parse
* that happens after the part is finalized (see reasoning-block.tsx).
*/
export function StreamingPlainText({ text }: { text: string }) {
const chunks = useMemo(() => splitPlainChunks(text), [text]);
return (
<>
{chunks.map((chunk, index) => (
// Index keys are stable here: chunks are append-only (the invariant),
// so an index never gets a different chunk's content mid-stream.
<PlainChunk key={index} text={chunk} />
))}
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
PALETTE,
avatarStyle,
avatarBackgroundCss,
normalizeName,
minPairwiseDistance,
relativeLuminance,
contrastRatio,
oklchToSrgb,
isInGamut,
} from "./avatar-palette";
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
function hexToRgb01(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
];
}
describe("avatar-palette validation", () => {
it("palette colors stay distinguishable", () => {
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
// RINGS tweak drops this, "almost identical" colors would reappear.
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
expect(PALETTE.length).toBe(20);
});
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
// white text = luminance 1, black text = luminance 0 (per buildPalette).
const textLum = { white: 1, black: 0 } as const;
for (const entry of PALETTE) {
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
// (a) The chosen text color really clears the code's 3:1 threshold on the
// actual background hex — recomputed independently from the hex, not from
// the build-time luminance. A slot that picked the wrong text (or a color
// too dim for either text) would fail here.
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
const chosen = contrastRatio(textLum[entry.text], hexLum);
expect(chosen).toBeGreaterThanOrEqual(3);
// buildPalette prefers white and only falls back to black when white
// fails 3:1. Mirror that decision: black is used *only* when white would
// not clear the threshold — so a mis-assigned "black" on a dark color
// (where white was fine) fails here.
if (entry.text === "black") {
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
}
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
// outside [0,1] and fail here.
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
}
});
});
describe("avatarStyle", () => {
it("name-to-avatar mapping is frozen (golden values)", () => {
// Golden slice: if this breaks, all existing avatars change — make sure
// that is intentional (a config change in avatar-palette.ts).
const s = avatarStyle("Backend Developer");
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
expect(s.text).toBe("white");
});
it("is deterministic and normalizes the name", () => {
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
// Casing, surrounding and repeated whitespace must not change the avatar.
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
expect(avatarStyle("Backend Developer")).toEqual(
avatarStyle("backend developer"),
);
expect(normalizeName(" PM ")).toBe("pm");
});
it("returns a valid base color, angle and matching text", () => {
const s = avatarStyle("Нарратор");
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
expect(idx).toBe(s.paletteIndex);
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
// Text color comes from the chosen palette entry.
expect(s.text).toBe(PALETTE[idx].text);
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
expect(s.angleDeg % 15).toBe(0);
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
expect(s.angleDeg).toBeLessThan(360);
});
it("distinguishes the agents that used to collide as violet", () => {
// "Структурный редактор" and "Фактчекер" looked identically violet before.
expect(avatarStyle("Структурный редактор")).not.toEqual(
avatarStyle("Фактчекер"),
);
});
});
describe("avatarBackgroundCss", () => {
it("renders a two-stop gradient with a soft boundary", () => {
const s = avatarStyle("Backend Developer");
expect(avatarBackgroundCss(s)).toBe(
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
);
});
});
+267
View File
@@ -0,0 +1,267 @@
/**
* Deterministic avatar backgrounds for agent roles.
*
* The palette is generated from scratch at module load in OKLCH (a perceptually
* uniform color space), so every value below is tunable: change the ring
* configuration or the partner shifts and the whole palette regenerates.
*
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
* - base color index (one of the validated palette colors)
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
* or triadic +/-120deg — classic color-wheel schemes; partner is also darker
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
* The same name always yields the same avatar, on any platform, forever.
*/
// ------------------------- Tunable configuration -------------------------
export interface RingConfig {
/** OKLCH lightness, 0..1 */
L: number;
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
C: number;
/** Hue of the first color in the ring, degrees */
hueStart: number;
/** Number of evenly spaced hues in the ring */
count: number;
}
/**
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
* Don't add more hues per ring without re-checking minPairwiseDistance():
* beyond ~20-24 colors humans stop telling them apart reliably.
*/
const RINGS: readonly RingConfig[] = [
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
];
/** Partner color: lightness shifted by this much (negative = darker) */
const PARTNER_L_SHIFT = -0.10;
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
const ANALOG_MIN_SHIFT = 20;
const ANALOG_SHIFT_STEP = 5;
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
/** Complementary scheme: fixed hue shift, degrees */
const COMPLEMENTARY_SHIFT = 180;
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
const TRIADIC_SHIFT = 120;
/** Number of split directions (24 -> 15deg per step) */
const SPLIT_ANGLE_STEPS = 24;
/** Position of the color boundary, percent of the gradient axis */
const SPLIT_PERCENT = 50;
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
const SPLIT_SOFTNESS = 16;
// ------------------------- OKLCH -> sRGB math -------------------------
// Matrices from Bjorn Ottosson's OKLab reference implementation.
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
return [
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
];
}
function gammaEncode(c: number): number {
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
}
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
const h = (hDeg * Math.PI) / 180;
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
}
export function isInGamut(rgb: readonly number[]): boolean {
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
}
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
function clampChroma(L: number, C: number, hDeg: number): number {
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
let lo = 0, hi = C;
for (let i = 0; i < 40; i++) {
const mid = (lo + hi) / 2;
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
else hi = mid;
}
return lo;
}
function toHex(rgb: readonly number[]): string {
return (
"#" +
rgb
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
.join("")
);
}
/** WCAG relative luminance of an sRGB color (components 0..1). */
export function relativeLuminance(rgb: readonly number[]): number {
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
export function contrastRatio(l1: number, l2: number): number {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// ------------------------- Palette generation -------------------------
export interface PaletteEntry {
/** Base background color */
hex: string;
/** OKLCH coordinates of the base color (used to derive partner colors) */
L: number;
C: number;
h: number;
/** Text/icon color with the best WCAG contrast on the base color */
text: "white" | "black";
/** OKLab coordinates of the base color (kept for validation) */
lab: readonly [number, number, number];
}
function buildPalette(): PaletteEntry[] {
const entries: PaletteEntry[] = [];
for (const ring of RINGS) {
const step = 360 / ring.count;
for (let i = 0; i < ring.count; i++) {
const h = (ring.hueStart + i * step) % 360;
const C = clampChroma(ring.L, ring.C, h);
const rgb = oklchToSrgb(ring.L, C, h);
const lum = relativeLuminance(rgb);
entries.push({
hex: toHex(rgb),
L: ring.L,
C,
h,
// White text needs >= 3:1 contrast; otherwise fall back to black.
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
lab: [
ring.L,
C * Math.cos((h * Math.PI) / 180),
C * Math.sin((h * Math.PI) / 180),
],
});
}
}
return entries;
}
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
const h2 = (entry.h + shiftDeg + 360) % 360;
const L2 = entry.L + PARTNER_L_SHIFT;
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
}
/** Generated once at module load; regenerates on every build from the config above. */
export const PALETTE: readonly PaletteEntry[] = buildPalette();
// ------------------------- Name -> avatar style -------------------------
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
export function normalizeName(name: string): string {
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* cyrb53: deterministic 53-bit string hash with good avalanche.
* Pure JS, cross-platform — never use language built-in hashing here.
*/
function cyrb53(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
export interface AvatarStyle {
/** Index of the base color in PALETTE */
paletteIndex: number;
/** Base color hex */
bg: string;
/** Second color hex (split partner) */
bg2: string;
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
hueShift: number;
/** Direction of the split, degrees */
angleDeg: number;
/** Text/icon color for the base color */
text: "white" | "black";
}
/** Pure function: the same (normalized) name always returns the same style. */
export function avatarStyle(agentName: string): AvatarStyle {
const h = cyrb53(normalizeName(agentName));
// Slice the hash into independent fields, like digits of a number:
const paletteIndex = h % PALETTE.length;
let rest = Math.floor(h / PALETTE.length);
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
const scheme = rest % 4;
rest = Math.floor(rest / 4);
let hueShift: number;
if (scheme === 2) {
hueShift = COMPLEMENTARY_SHIFT;
} else if (scheme === 3) {
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
} else {
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
hueShift = scheme === 0 ? -magnitude : magnitude;
}
const entry = PALETTE[paletteIndex];
return {
paletteIndex,
bg: entry.hex,
bg2: partnerHex(entry, hueShift),
hueShift,
angleDeg,
text: entry.text,
};
}
/** CSS background value: two colors with a slightly blurred boundary. */
export function avatarBackgroundCss(style: AvatarStyle): string {
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
}
// ------------------------- Validation -------------------------
/**
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
* look alike. Intended for a unit test or a dev-time assertion.
*/
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
let min = Infinity;
let pair: [string, string] = ["", ""];
for (let i = 0; i < PALETTE.length; i++) {
for (let j = i + 1; j < PALETTE.length; j++) {
const a = PALETTE[i].lab, b = PALETTE[j].lab;
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
if (d < min) {
min = d;
pair = [PALETTE[i].hex, PALETTE[j].hex];
}
}
}
return { distance: min, pair };
}
@@ -27,7 +27,11 @@ const SAFETY_FRAMEWORK = [
'- You can read pages, comments and page history, and modify the workspace:', '- You can read pages, comments and page history, and modify the workspace:',
' create/rename/move pages and make structural edits (text, nodes, tables);', ' create/rename/move pages and make structural edits (text, nodes, tables);',
' manage page history (diff/restore); copy, import and export content; and', ' manage page history (diff/restore); copy, import and export content; and',
' create/resolve comments. Page edits are REVERSIBLE — they keep page', ' create/resolve comments. An inline comment can carry a suggestedText — a',
' proposed replacement for its selected text that the user applies with one',
' click; when you propose a concrete rewording of a specific fragment,',
' attach it as suggestedText instead of only describing the change. Page',
' edits are REVERSIBLE — they keep page',
' history and a trashed page can be restored. One exception to keep in mind:', ' history and a trashed page can be restored. One exception to keep in mind:',
' sharing a page makes it PUBLICLY accessible — do that only when the user', ' sharing a page makes it PUBLICLY accessible — do that only when the user',
' asked.', ' asked.',
+1 -1
View File
@@ -27,7 +27,7 @@ const VERSION = packageJson.version;
// --- Modern McpServer Implementation --- // --- Modern McpServer Implementation ---
// Editing guide surfaced to MCP clients in the initialize result so they can // Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents. // pick the right tool by intent and avoid resending whole documents.
const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " + const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " + "Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " + "Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown."; "Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
+1 -1
View File
@@ -38,7 +38,7 @@ const VERSION = packageJson.version;
// Editing guide surfaced to MCP clients in the initialize result so they can // Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents. // pick the right tool by intent and avoid resending whole documents.
const SERVER_INSTRUCTIONS = const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " + "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " + "Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " + "Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown."; "Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";