File size: 11,131 Bytes
a86b867
 
 
 
 
 
 
 
344419c
a86b867
 
 
 
 
 
 
 
 
344419c
a86b867
 
 
 
 
 
 
 
344419c
 
a86b867
344419c
a86b867
344419c
 
a86b867
 
 
 
 
 
 
344419c
a86b867
 
 
 
344419c
a86b867
 
 
 
 
 
 
 
344419c
 
 
 
 
 
 
a86b867
344419c
a86b867
344419c
a86b867
344419c
 
 
a86b867
 
 
 
 
 
344419c
 
 
 
 
 
 
a86b867
344419c
a86b867
 
344419c
 
a86b867
 
 
 
 
 
 
 
344419c
 
 
 
 
a86b867
344419c
 
a86b867
344419c
a86b867
344419c
 
 
 
 
 
 
 
 
a86b867
344419c
a86b867
 
 
344419c
a86b867
 
344419c
 
a86b867
 
 
 
 
 
 
 
 
 
 
344419c
a86b867
 
 
 
 
 
344419c
a86b867
344419c
 
a86b867
344419c
a86b867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344419c
a86b867
 
 
 
344419c
a86b867
 
 
344419c
 
 
a86b867
344419c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a86b867
 
 
 
 
 
 
344419c
 
 
 
 
 
 
 
 
 
 
a86b867
 
 
 
 
344419c
 
 
a86b867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import json
import logging
from typing import Generator

logger = logging.getLogger(__name__)


def _ev(event: str, **kw) -> dict:
    return {"event": event, **kw}


def stream_turn(user_message: str, state: dict) -> Generator[dict, None, None]:
    """
    Streaming agent turn. Yields event dicts consumed by app.py on_chat().

    Event types:
      {"event": "step",     "step": str,  "label": str}
      {"event": "thinking", "text": str}
      {"event": "result",   "state": dict, "reply": str, "code": str, "spec": dict|None}
      {"event": "error",    "state": dict, "message": str}
    """
    import copy
    from agent.intent   import classify
    from agent.healer   import heal
    from agent.memory   import append_user, append_assistant
    from agent.patcher  import patch as patch_spec
    from core.templates import render_page, render_app
    from core.sandbox   import safe_exec
    from core.validator import validate_page_spec
    from storage.db     import save_component
    from core.generator import MODEL_ID, get_device, _generate_messages, parse_json, _split_thinking
    from agent.planner  import _load_agent_system, _ensure_page_schema

    state = copy.deepcopy(state)
    state = append_user(state, user_message)

    current_spec = state.get("current_spec")
    messages     = state["messages"]

    # ── classify ─────────────────────────────────────────────────────────────
    yield _ev("step", step="classifying", label="classifying intent…")
    intent = classify(user_message, current_spec)
    logger.info("intent=%s", intent)

    # ── explain shortcut ──────────────────────────────────────────────────────
    if intent == "EXPLAIN":
        yield _ev("step", step="planning", label="generating explanation…")
        reply = _explain(user_message, current_spec, messages)
        state = append_assistant(state, reply)
        state["_reply"] = reply
        yield _ev("result", state=state, reply=reply, code="", spec=current_spec)
        return

    # ── plan or patch ─────────────────────────────────────────────────────────
    if intent in ("NEW",) or current_spec is None:
        step_label = "planning page…"
    elif intent == "RESTYLE":
        step_label = "restyling CSS…"
    else:
        step_label = f"patching page ({intent})…"

    yield _ev("step", step="planning", label=step_label)

    try:
        if intent == "NEW" or current_spec is None:
            system  = _load_agent_system()
            history = [m for m in messages if m["role"] != "system"]

            prompt_msgs = (
                [{"role": "system", "content": system}]
                + history
                + [{"role": "user", "content": user_message}]
            )

            raw = _generate_messages(prompt_msgs, max_tokens=2048)

            thinking, json_raw = _split_thinking(raw)
            if not json_raw.strip():
                json_raw = raw
            if thinking:
                yield _ev("thinking", text=thinking)

            spec = _ensure_page_schema(parse_json(json_raw))

        else:
            # EDIT / ADD / REMOVE / REPLACE / WIRE / FIX / RESTYLE
            spec = patch_spec(current_spec, user_message, intent, messages)

    except Exception as exc:
        logger.exception("plan/patch failed")
        err_msg = f"I couldn't understand that request ({exc}). Try rephrasing."
        state = _fail(state, err_msg)
        yield _ev("error", state=state, message=err_msg)
        return

    # ── validate page spec ────────────────────────────────────────────────────
    ok_spec, val_errors = validate_page_spec(spec)
    if not ok_spec:
        logger.warning("Page spec validation errors: %s", val_errors)
        # non-fatal β€” still attempt render; healer may fix exec errors

    # ── render ────────────────────────────────────────────────────────────────
    yield _ev("step", step="rendering", label="rendering page…")
    try:
        code = render_page(spec)
    except Exception as exc:
        logger.exception("render_page failed")
        # Try legacy render_app as fallback
        try:
            code = render_app(spec)
        except Exception as exc2:
            err_msg = f"Render error: {exc2}"
            state = _fail(state, err_msg)
            yield _ev("error", state=state, message=err_msg)
            return

    # ── sandbox ───────────────────────────────────────────────────────────────
    yield _ev("step", step="validating", label="validating in sandbox…")
    exec_ok, exec_err = safe_exec(code)

    # ── heal ──────────────────────────────────────────────────────────────────
    heal_count = 0
    if not exec_ok:
        yield _ev("step", step="healing", label="auto-healing error…")
        healed_code, healed_ok = heal(code, exec_err, messages, spec=spec)
        heal_count = 1
        if healed_ok:
            code    = healed_code
            exec_ok = True
        else:
            _trace(user_message, code, spec, False, False, exec_err, get_device(), MODEL_ID)
            err_msg = _friendly_error(exec_err)
            state   = _fail(state, err_msg, spec=spec, code=code)
            yield _ev("error", state=state, message=err_msg)
            return

    # ── save ──────────────────────────────────────────────────────────────────
    yield _ev("step", step="saving", label="saving…")
    try:
        save_component(user_message, code, spec)
    except Exception:
        logger.exception("save_component failed")

    _trace(user_message, code, spec, ok_spec, True, None, get_device(), MODEL_ID)

    # ── commit ────────────────────────────────────────────────────────────────
    page      = spec.get("page", spec)
    reasoning = spec.get("reasoning", "")
    reply     = _format_reply(page, reasoning, intent)

    state = append_assistant(state, json.dumps(spec))
    state["current_spec"]  = spec
    state["current_code"]  = code
    state["iteration"]    += 1
    state["heal_attempts"] = heal_count
    state["last_error"]    = None
    state["_reply"]        = reply

    yield _ev("result", state=state, reply=reply, code=code, spec=spec)


def run_turn(user_message: str, state: dict) -> dict:
    """Non-streaming wrapper β€” consumes stream_turn and returns final state."""
    final_state = state
    for event in stream_turn(user_message, state):
        if event["event"] in ("result", "error"):
            final_state = event["state"]
    return final_state


def _explain(user_message: str, current_spec, messages: list[dict]) -> str:
    if current_spec is None:
        return "There's no page built yet. Describe an app and I'll build it."
    try:
        from agent.planner import _call_model, _load_agent_system
        system = _load_agent_system()
        prompt = (
            f"The current page spec is:\n{json.dumps(current_spec, indent=2)}\n\n"
            f"User question: {user_message}\n\n"
            "Answer clearly and concisely. No JSON output needed."
        )
        return _call_model(
            [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
        )
    except Exception as exc:
        return f"I can see the current page but couldn't generate an explanation ({exc})."


def _format_reply(page: dict, reasoning: str, intent: str = "") -> str:
    sections   = page.get("sections",  [])
    title      = page.get("title",     "App")
    layout     = page.get("layout",    "")
    n_sections = len(sections)
    n_comps    = sum(len(s.get("components", [])) for s in sections)
    action     = intent.lower() or "built"

    lines = [
        f"**{action.capitalize()}**: {title} Β· {layout} layout Β· "
        f"{n_sections} section{'s' if n_sections != 1 else ''} Β· "
        f"{n_comps} component{'s' if n_comps != 1 else ''}"
    ]
    if reasoning:
        lines.append(f"_{reasoning}_")
    return "\n\n".join(lines)


def _friendly_error(raw_error: str) -> str:
    lower = raw_error.lower()
    if "blocked import" in lower:
        # Extract which module was blocked for a more helpful message
        import re
        m = re.search(r"blocked import[^:]*:\s*(.+)", raw_error, re.IGNORECASE)
        detail = m.group(1).strip() if m else ""
        suffix = f" ({detail})" if detail else ""
        return (
            f"The generated code used a library that isn't available in the sandbox{suffix}. "
            "Try rephrasing β€” e.g. 'show a plotly scatter chart' instead of 'use sklearn KMeans'."
        )
    if "nameerror"  in lower:
        return "The generated code referenced something that doesn't exist. I tried to fix it but couldn't."
    if "syntaxerror" in lower:
        return "The generated code had a syntax problem I couldn't auto-repair."
    if "importerror" in lower or "modulenot" in lower:
        return "The generated code tried to import something unavailable."
    if "timeout"    in lower or "timed out" in lower:
        return "The generated page took too long to start. Try a simpler request."
    return "The generated page had an error I couldn't fix automatically. Please try rephrasing."


def _fail(state: dict, message: str, spec=None, code: str = "") -> dict:
    from agent.memory import append_assistant
    state = append_assistant(state, message)
    state["_reply"]     = message
    state["last_error"] = message
    if spec is not None:
        state["current_spec"] = spec
    if code:
        state["current_code"] = code
    return state


def _trace(prompt, code, spec, valid, exec_ok, exec_err, device, model_id):
    try:
        from storage.trace_logger import log_turn
        log_turn(
            prompt       = prompt,
            raw_output   = code,
            parsed_spec  = spec,
            valid        = valid,
            exec_success = exec_ok,
            exec_error   = exec_err,
            device       = device,
            model_id     = model_id,
        )
    except Exception:
        logger.exception("trace logging failed")