969 strings, an instance default and a per-person choice, and no half-done corner: the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations. KEYED BY THE ENGLISH SENTENCE A missing entry renders the key, which is the English -- so an untranslated string looks as it always did, an English instance is byte-for-byte 1.6.0, and a half-finished catalogue is a half-translated page rather than a page of dotted key names. The cost is that editing an English sentence orphans its translation silently, which is what tests/test_translations.py asserts in both directions. No gettext: .po -> .mo is a build step and this project does not have one. A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR `t()` is a global for the reason `brand` already documents -- render() is bypassed by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being the SSE frames, which have no Request at all. A global is bound once at import and the language is per person, so the active language is a ContextVar set per request. 🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a threadpool, and anyio copies the context in and discards it on the way out -- so the language was set where nothing could see it and every page rendered in the instance's language whatever anybody had chosen, with no error anywhere. NOT TRANSLATED, ON PURPOSE Everything a model reads: the 60 prompt fragments, and the dates in harness.py, schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and only where a person reads it -- with the *format* translatable as well as the words, because "26. septembra 2026" is a different pattern rather than the same one with different words in it. A process locale is not an option: global, not thread-safe, two people's pages at once. A second fragment telling models to answer in the reader's language was written during this work and removed: `core.style` has said it since long before, and test_an_empty_override_turns_a_fragment_off caught the duplicate. THE BULK PASS 1213 sites wrapped by a one-off script that only touched patterns it could not misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"` where the sentence held an apostrophe, closing the Jinja string and 500ing two pages whose partials no test renders. tests/test_translations.py now compiles all 110 templates. It also wrapped the product's own name, an SSH key header, a keystroke hint and an example URL, all taken back out: a string is not translatable just because it is a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
8.5 KiB
HTML
194 lines
8.5 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
What the model did before answering.
|
|
|
|
Rendered both live (streamed as a whole block, like the reasoning and the
|
|
answer) and from the stored message afterwards, so the sources behind an
|
|
answer stay in the transcript rather than vanishing when the stream ends.
|
|
|
|
EVERYTHING in here is third-party text and is untrusted, exactly as much as
|
|
model output is -- a search provider's results, an administrator's HTTP tool
|
|
relaying whatever it was pointed at, an MCP server's reply. Jinja autoescaping
|
|
covers it. Two things are handled separately: the URL, because `is_linkable`
|
|
is the only thing standing between a result carrying a javascript: URL and an
|
|
anchor pointing at it, and `event.text`, which is rendered as preformatted
|
|
text and deliberately NOT through services/markdown.py -- markdown is the one
|
|
path allowed to emit HTML, and this is the last content that should be given
|
|
it.
|
|
|
|
`kind` says how to label the event. Rows written before it existed have none,
|
|
so web_search reads as a search and everything else falls to the generic
|
|
branch: an old notes_search event used to claim it had searched the web.
|
|
|
|
What a tool is *called* is not decided here. `tool_label` and `tool_icon` are
|
|
Jinja globals over services/tool_labels.py, which is the one table the status
|
|
line and the approval card read too. It deliberately ignores an `event.label`
|
|
it recognises the name of: rows already on disk carry the SSH profile's name,
|
|
so a resolver that preferred the stored value would leave every existing
|
|
transcript saying "homeserver" where it means "Bash".
|
|
#}
|
|
{#
|
|
`message_id` and `step_index` place this block in the reply, and an id is
|
|
emitted only when the caller gave one. That is not defensiveness: an id is
|
|
what carries an *opened* block across a swap, and a caller with no message to
|
|
key on would emit the same id in every bubble on the page, which is worse than
|
|
emitting none. `_step.html` always passes one.
|
|
|
|
`steps.js` records which of these are open before a swap and puts them back
|
|
after. It works because the id is stable -- the step index comes from a mark,
|
|
the marks are append-only, and the finished bubble emits exactly what the live
|
|
one did, so a block opened at round three is still open after the `done` frame
|
|
replaces the whole article.
|
|
#}
|
|
{% for event in tool_events %}
|
|
{% set kind = event.kind or ('search' if event.name == 'web_search' else 'tool') %}
|
|
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}"
|
|
{% if message_id | default('') %}id="tool-{{ message_id }}-{{ step_index | default(0) }}-{{ loop.index0 }}"{% endif %}>
|
|
<summary class="tool-activity__summary">
|
|
{{ icon(tool_icon(event), "icon--sm tool-activity__icon") }}
|
|
|
|
<span class="tool-activity__label">
|
|
{% if kind == 'search' %}
|
|
{# Prose, not "Web search · mallorn". A search is the one thing here
|
|
common enough to be worth a sentence. #}
|
|
{% if event.status == "error" %}
|
|
Web search failed
|
|
{% elif event.query %}
|
|
Searched the web for “{{ event.query }}”
|
|
{% else %}
|
|
Searched the web
|
|
{% endif %}
|
|
{% else %}
|
|
{% set label = tool_label(event) %}
|
|
{% if event.status == "error" %}
|
|
{{ label }} failed
|
|
{% else %}
|
|
{{ label }}
|
|
{% endif %}
|
|
{% if event.query %}
|
|
<span class="tool-activity__count">· {{ event.query }}</span>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% if event.results %}
|
|
<span class="tool-activity__count">
|
|
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
|
|
</span>
|
|
{% endif %}
|
|
|
|
{% if event.edited %}
|
|
{# Corrected on the approval card before it ran, so what is shown above is
|
|
the reader's command and not the model's. Said out loud rather than
|
|
left to be inferred: attributing somebody's own typing to a model is
|
|
the same misattribution as the other way round, and a transcript read
|
|
back a week later has nothing else to go on. #}
|
|
<span class="badge">{{ t("edited by you") }}</span>
|
|
{% endif %}
|
|
|
|
{% if event.why %}
|
|
{# What the model said this call was for. In the summary rather than the
|
|
body because the body is collapsed: in Auto mode nothing stops for
|
|
approval, so without this a reader watches a list of commands with no
|
|
account of what any of them is for until the reply ends.
|
|
|
|
Model text, escaped like everything else here, and styled as a quieter
|
|
second line so it reads as the model's account rather than ours. #}
|
|
<span class="tool-activity__why">{{ event.why }}</span>
|
|
{% endif %}
|
|
</span>
|
|
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
|
</summary>
|
|
|
|
<div class="tool-activity__body">
|
|
{% if event.detail and kind != 'search' %}
|
|
<p class="tool-result__host">{{ event.detail }}</p>
|
|
{% endif %}
|
|
|
|
{% if event.error %}
|
|
<p class="tool-activity__error">{{ event.error }}</p>
|
|
{% elif not event.results and not event.text and not event.image %}
|
|
<p class="tool-activity__error">{{ t("Nothing was found.") }}</p>
|
|
{% endif %}
|
|
|
|
{% if event.image and event.image.id %}
|
|
{#
|
|
What was drawn, in the block that drew it. The picture is also an
|
|
attachment on this bubble, so the reader sees it without opening
|
|
anything; this copy is what makes the block make sense on its own once
|
|
it is expanded, beside the seed and the attempts.
|
|
|
|
The address is built here from an id and is never taken from the event.
|
|
That is the same rule the result links a few lines down follow, and it
|
|
matters more here: an event is a dict a runner wrote, and a `src` taken
|
|
from one would be a model-supplied URL fetched by the reader's browser.
|
|
`/api/files/{id}/content` is owner-checked on the way out.
|
|
#}
|
|
<a class="tool-image" href="/api/files/{{ event.image.id }}/content"
|
|
target="_blank" rel="noopener">
|
|
<img src="/api/files/{{ event.image.id }}/content" alt="{{ t('The generated image') }}"
|
|
loading="lazy"
|
|
{% if event.image.width %}width="{{ event.image.width }}"{% endif %}
|
|
{% if event.image.height %}height="{{ event.image.height }}"{% endif %}>
|
|
</a>
|
|
{% endif %}
|
|
|
|
{% if event.text %}
|
|
<pre class="tool-result__text">{{ event.text }}</pre>
|
|
{% endif %}
|
|
|
|
{% if event.diff %}
|
|
{#
|
|
What a write or an update changed, in the shape everybody already reads a
|
|
change in.
|
|
|
|
Every line is text off somebody's machine and is escaped exactly like the
|
|
rest of this file. The classification reads the first character and never
|
|
interprets the rest — a line is coloured, never parsed.
|
|
|
|
Header lines are checked BEFORE the bare +/- ones, or `+++ b/x.py` renders
|
|
as an addition and `--- a/x.py` as a removal at the top of every diff. The
|
|
test is against the `a/` and `b/` prefixes rather than against three
|
|
dashes, because a removed line whose own text begins with `--` produces
|
|
exactly three dashes too.
|
|
|
|
No whitespace between the spans: they are `display: block` inside a <pre>,
|
|
so a newline between two of them is a blank line on screen.
|
|
#}
|
|
<pre class="diff"><code>
|
|
{%- for line in event.diff.split("\n") -%}
|
|
{%- if line.startswith('@@') or line.startswith('--- a/')
|
|
or line.startswith('+++ b/') or line.startswith('diff ') -%}
|
|
{%- set cls = 'meta' -%}
|
|
{%- elif line.startswith('+') -%}
|
|
{%- set cls = 'add' -%}
|
|
{%- elif line.startswith('-') -%}
|
|
{%- set cls = 'del' -%}
|
|
{%- else -%}
|
|
{%- set cls = 'ctx' -%}
|
|
{%- endif -%}
|
|
<span class="diff__line diff__line--{{ cls }}">{{ line }}</span>
|
|
{%- endfor -%}
|
|
</code></pre>
|
|
{% endif %}
|
|
|
|
{% for result in event.results %}
|
|
<div class="tool-result">
|
|
{% set scheme = (result.url or "").split(":")[0] | lower %}
|
|
{% if scheme in ("http", "https") %}
|
|
<a class="tool-result__title" href="{{ result.url }}"
|
|
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
|
|
{% else %}
|
|
{# Not a link. A result is third-party text and its URL is not trusted to
|
|
be safe to click. #}
|
|
<span class="tool-result__title">{{ result.title }}</span>
|
|
{% endif %}
|
|
<span class="tool-result__host">{{ result.host }}</span>
|
|
{% if result.snippet %}
|
|
<p class="tool-result__snippet">{{ result.snippet }}</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</details>
|
|
{% endfor %}
|