File size: 15,511 Bytes
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d4a48e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d4a48e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
"""Pure helpers for the per-browser state owned by Gradio."""

from __future__ import annotations

import copy
import secrets
import time
from typing import Any, Dict, List, Mapping, Tuple


def _id(prefix: str) -> str:
    return f"{prefix}-{secrets.token_hex(10)}"


#: The title a conversation carries until its first request names it.
#: It used to be "New conversation", the same words as the button directly
#: above the list, so an untitled conversation and the control that creates
#: one sat adjacent and read identically.
UNTITLED = "Untitled"


def _new_conversation() -> Dict[str, Any]:
    return {
        "title": UNTITLED,
        "job_ids": [],
        "prompts": [],
        # The browser owns the transcript.  The server erases a prompt the
        # moment its run reaches a terminal state, and drops the answer once
        # this session has read it, so anything still on screen afterwards is
        # held here and nowhere else.  Keyed by job id.
        "answers": {},
    }


def new_session() -> Dict[str, Any]:
    conversation_id = _id("conversation")
    return {
        "session_id": _id("session"),
        "active_conversation_id": conversation_id,
        "conversations": {conversation_id: _new_conversation()},
        "created_at": time.time(),
    }


#: The longest identifier the control plane will accept. Anything longer is
#: refused there with a `ValidationError`, and a browser holding one would
#: turn every page load into a five-hundred.
_MAX_ID = 128


def _identifier(value: Any) -> str:
    """``value`` if it can be a session or conversation id, else empty."""

    if not isinstance(value, str):
        return ""
    text = value.strip()
    if not text or len(text) > _MAX_ID or any(c in text for c in "\r\n"):
        return ""
    return text


def _string_list(value: Any, *, limit: int) -> List[str]:
    if not isinstance(value, (list, tuple)):
        return []
    return [str(item) for item in value if isinstance(item, str)][:limit]


def _mapping_list(value: Any, *, limit: int) -> List[Dict[str, Any]]:
    if not isinstance(value, (list, tuple)):
        return []
    return [dict(item) for item in value if isinstance(item, Mapping)][:limit]


#: How much of one browser's history is worth carrying forward. A transcript
#: is per-browser and unbounded otherwise, and the whole of it is re-rendered
#: on a two-second timer.
_MAX_CONVERSATIONS = 128
_MAX_TURNS = 512


def _number(value: Any) -> float | None:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return None
    return float(value)


def _adopt_answer(value: Any) -> Dict[str, Any] | None:
    """One kept answer, rebuilt to the shape `DistinctUI._answer_payload` writes.

    Every field is here, and each is the type the renderer indexes into: the
    receipt walks `trace` and `steps` as mappings, the transcript walks `made`
    as mappings, and the estimator reads `duration_seconds` as a number. An
    older build that spelled any of them differently turned the transcript
    into a `TypeError` on every render β€” which is to say on every page load
    and every timer tick. Anything not listed here is dropped, because a key
    nothing reads is a key nothing can be broken by.
    """

    if not isinstance(value, Mapping):
        return None
    answer: Dict[str, Any] = {
        "output": str(value.get("output", "")),
        "agent": str(value.get("agent", "")),
        "model": str(value.get("model", "")),
        "mode": str(value.get("mode", "")),
        "tools": [item for item in _mapping_list(value.get("tools"), limit=64) if "id" in item],
        "steps": _mapping_list(value.get("steps"), limit=64),
        "trace": _mapping_list(value.get("trace"), limit=64),
        "made": _mapping_list(value.get("made"), limit=32),
        "artifacts": _string_list(value.get("artifacts"), limit=64),
        "energy": dict(value["energy"]) if isinstance(value.get("energy"), Mapping) else {},
        "guard": dict(value["guard"]) if isinstance(value.get("guard"), Mapping) else {},
        "tool_calls": int(_number(value.get("tool_calls")) or 0),
    }
    duration = _number(value.get("duration_seconds"))
    if duration is not None:
        answer["duration_seconds"] = duration
    return answer


def adopt_saved_state(saved: Any) -> Dict[str, Any] | None:
    """Today's shape, rebuilt from whatever an older build left in the browser.

    THE BROWSER'S COPY IS INPUT, NOT STATE.

    It is JSON written by some earlier version of this file, kept in
    localStorage across every deploy since, and editable by anyone with a
    developer console. `load_session` used to check four facts about the top
    level of it β€” an id, a conversations mapping, an active id inside that
    mapping β€” and then hand the rest straight to the renderer. Everything one
    level down was trusted: a conversation that was a string, a conversation
    with no `title`, an answer that was a string, an answer whose `made` was
    not a list. Each of those raised out of `load_session`, which runs on
    every single page load, so the app opened on a red error toast and stayed
    that way until the person cleared their site data β€” and they had no way
    to know that was the fix.

    So the saved copy is rebuilt here field by field instead of inspected.
    What cannot be read is dropped rather than carried, and a transcript with
    nothing readable left in it returns None, which the caller reads as "start
    fresh". Dropping an unreadable turn loses something; refusing to load the
    page loses everything.
    """

    if not isinstance(saved, Mapping):
        return None
    conversations: Dict[str, Any] = {}
    raw = saved.get("conversations")
    if not isinstance(raw, Mapping):
        return None
    for key, value in list(raw.items())[:_MAX_CONVERSATIONS]:
        conversation_id = _identifier(key)
        if not conversation_id or not isinstance(value, Mapping):
            continue
        job_ids = _string_list(value.get("job_ids"), limit=_MAX_TURNS)
        prompts = _string_list(value.get("prompts"), limit=_MAX_TURNS)
        # Padded rather than trimmed to the shorter of the two. A prompt list
        # that fell behind its job list is an older bug's leftovers, and the
        # turn is still worth showing with an empty question attached.
        prompts += [""] * max(0, len(job_ids) - len(prompts))
        answers: Dict[str, Any] = {}
        raw_answers = value.get("answers")
        if isinstance(raw_answers, Mapping):
            for job_id, answer in raw_answers.items():
                if not isinstance(job_id, str) or job_id not in job_ids:
                    continue
                adopted = _adopt_answer(answer)
                if adopted is not None:
                    answers[job_id] = adopted
        title = value.get("title")
        conversations[conversation_id] = {
            "title": str(title) if isinstance(title, str) and title.strip() else UNTITLED,
            "job_ids": job_ids,
            "prompts": prompts[: len(job_ids)],
            "answers": answers,
        }
    if not conversations:
        return None
    active = _identifier(saved.get("active_conversation_id"))
    if active not in conversations:
        active = next(iter(conversations))
    state: Dict[str, Any] = {
        # A session id this server would refuse is worse than no session id:
        # it fails at the control plane, inside the handler, rather than here.
        "session_id": _identifier(saved.get("session_id")) or _id("session"),
        "active_conversation_id": active,
        "conversations": conversations,
        "created_at": saved.get("created_at")
        if isinstance(saved.get("created_at"), (int, float))
        else time.time(),
    }
    defaults = _string_list(saved.get("library_defaults"), limit=256)
    if defaults:
        state["library_defaults"] = defaults
    # The pairing code is browser-owned and worth keeping: dropping it makes
    # the next open of the setup panel mint another, and a person only gets
    # eight live codes before the control plane refuses.
    code = saved.get("pairing_code")
    expires = saved.get("pairing_expires")
    if isinstance(code, str) and code and isinstance(expires, (int, float)):
        state["pairing_code"] = code
        state["pairing_expires"] = float(expires)
    return state


def _validated_copy(state: Mapping[str, Any]) -> Dict[str, Any]:
    value = copy.deepcopy(dict(state))
    if not isinstance(value.get("session_id"), str):
        raise ValueError("session state is missing its id")
    conversations = value.get("conversations")
    if not isinstance(conversations, dict) or not conversations:
        raise ValueError("session state has no conversations")
    if value.get("active_conversation_id") not in conversations:
        raise ValueError("active conversation is unavailable")
    return value


def add_conversation(state: Mapping[str, Any]) -> Tuple[Dict[str, Any], str]:
    value = _validated_copy(state)
    conversation_id = _id("conversation")
    value["conversations"][conversation_id] = _new_conversation()
    value["active_conversation_id"] = conversation_id
    return value, conversation_id


def select_conversation(state: Mapping[str, Any], conversation_id: str) -> Dict[str, Any]:
    value = _validated_copy(state)
    if conversation_id not in value["conversations"]:
        raise ValueError("conversation does not belong to this session")
    value["active_conversation_id"] = conversation_id
    return value


def remove_conversation(state: Mapping[str, Any], conversation_id: str) -> Dict[str, Any]:
    """Drop a conversation and everything it held, choosing the next active one.

    THE LAST ONE IS EMPTIED RATHER THAN REMOVED. A session with no conversation
    has no valid ``active_conversation_id``, and every renderer downstream reads
    that key; deleting the only one would leave the state in a shape the rest of
    this module is entitled to assume cannot happen. Emptying it gives the
    person what they asked for β€” the transcript is gone β€” without inventing a
    state nothing else can render.

    The jobs are dropped from this state, which is the browser's copy and the
    only durable one: the server erases a prompt when its run ends and the
    answer once it has been collected. The caller is responsible for telling
    the control plane, which owns anything still in flight.
    """

    value = _validated_copy(state)
    if conversation_id not in value["conversations"]:
        raise ValueError("conversation does not belong to this session")

    if len(value["conversations"]) == 1:
        fresh = _new_conversation()
        value["conversations"] = {conversation_id: fresh}
        value["active_conversation_id"] = conversation_id
        return value

    order = list(value["conversations"])
    position = order.index(conversation_id)
    del value["conversations"][conversation_id]
    if value["active_conversation_id"] == conversation_id:
        # The neighbour, preferring the one above, which is where the eye
        # already is after a row disappears.
        remaining = list(value["conversations"])
        value["active_conversation_id"] = remaining[max(0, position - 1)]
    return value


def append_job(
    state: Mapping[str, Any],
    *,
    job_id: str,
    prompt: str,
    conversation_id: str | None = None,
) -> Dict[str, Any]:
    value = _validated_copy(state)
    selected = conversation_id or value["active_conversation_id"]
    if selected not in value["conversations"]:
        raise ValueError("conversation does not belong to this session")
    conversation = value["conversations"][selected]
    if job_id in conversation["job_ids"]:
        raise ValueError("job is already present in the conversation")
    conversation["job_ids"].append(job_id)
    conversation["prompts"].append(prompt)
    if conversation["title"] == UNTITLED:
        title = " ".join(prompt.split())
        conversation["title"] = title[:48] + ("…" if len(title) > 48 else "")
    return value


def record_answer(
    state: Mapping[str, Any],
    *,
    job_id: str,
    answer: Mapping[str, Any],
    conversation_id: str | None = None,
) -> Dict[str, Any]:
    """Store a delivered answer in browser-owned state.

    This is the counterpart to ``ControlPlane.claim_result``: the server hands
    the answer over exactly once and then erases it, so if this does not
    persist it, the reply is gone.
    """

    value = _validated_copy(state)
    selected = conversation_id or value["active_conversation_id"]
    if selected not in value["conversations"]:
        raise ValueError("conversation does not belong to this session")
    conversation = value["conversations"][selected]
    if job_id not in conversation["job_ids"]:
        raise ValueError("job does not belong to this conversation")
    conversation.setdefault("answers", {})[job_id] = dict(answer)
    return value


def conversation_turns(state: Mapping[str, Any]) -> List[Dict[str, Any]]:
    """The browser's own transcript: one entry per submitted run, in order."""

    value = _validated_copy(state)
    conversation = value["conversations"][value["active_conversation_id"]]
    answers = conversation.get("answers") or {}
    prompts = conversation.get("prompts") or []
    turns: List[Dict[str, Any]] = []
    for index, job_id in enumerate(conversation.get("job_ids") or []):
        turns.append(
            {
                "job_id": job_id,
                "prompt": prompts[index] if index < len(prompts) else "",
                "answer": answers.get(job_id),
            }
        )
    return turns


def active_conversation(state: Mapping[str, Any]) -> Dict[str, Any]:
    value = _validated_copy(state)
    return value["conversations"][value["active_conversation_id"]]


#: How each conversation state is marked in the rail. The glyph is real text
#: in the option's own label, so the state is carried by a shape and a word
#: and never by the tone alone: the tones are pastel by design and pastels are
#: the first thing to go on a poor screen or to a reader who cannot separate
#: them. The word is omitted for the two resting states, where it would appear
#: on almost every row and say nothing.
CONVERSATION_STATES: Dict[str, tuple[str, str]] = {
    "idle": ("β—‡", ""),
    "waiting": ("β—‹", "waiting"),
    "generating": ("◐", "working"),
    "completed": ("●", ""),
    "failed": ("β– ", "failed"),
}


def conversation_choices(
    state: Mapping[str, Any], states: Mapping[str, str] | None = None
) -> List[tuple[str, str]]:
    """The rail's options, each marked with the state of its conversation.

    ``states`` maps a conversation id to one of :data:`CONVERSATION_STATES`.
    It is optional because several call sites rebuild the list after an event
    that cannot have changed any run's state, and an unmarked option is a
    correct option rather than a broken one.
    """

    value = _validated_copy(state)
    marks = states or {}
    options: List[tuple[str, str]] = []
    for conversation_id, conversation in value["conversations"].items():
        glyph, word = CONVERSATION_STATES.get(
            str(marks.get(conversation_id, "idle")), CONVERSATION_STATES["idle"]
        )
        title = str(conversation["title"])
        suffix = f" Β· {word}" if word else ""
        options.append((f"{glyph} {title}{suffix}", conversation_id))
    return options