openpencil/crates/op-host-web/src/codegen_web.rs

745 lines
30 KiB
Rust

//! Web code-generation session — drives the pull-based `CodegenPipeline`
//! over async XHR callbacks instead of a worker thread.
//!
//! The desktop host (`op-host-desktop::codegen_session`) runs the same
//! `CodegenPipeline` on a worker thread, draining each request's blocking
//! `ChatProvider::send` iterator off the UI thread and streaming progress
//! back over an `mpsc` channel. The browser can't block or spawn threads,
//! so this module drives the SAME pipeline differently:
//!
//! * Each model request is fired via [`crate::web_ai_transport::post_ai_stream`]
//! (an SSE-over-XHR POST to the daemon's `/api/ai/stream` proxy). The
//! streamed `Delta` / `Done` / `Error` events fire LATER (not re-entrantly)
//! on `onprogress` / `onloadend`, so the driver is a chain of callbacks
//! rather than a loop.
//! * A `VecDeque<WebCodegenDelta>` queue stands in for the desktop `mpsc`
//! channel; a `requestAnimationFrame` pump (`crate::raf_pump`) drains it
//! into `editor_state.codegen` ~once per frame and repaints.
//!
//! Sequential driving: a `Dispatch(reqs)` batch is processed one request at
//! a time (`reqs[0]` fired, on its `Done` the next is fired, …). Once the
//! whole batch settles, the driver re-steps the pipeline, which yields the
//! next `Dispatch` (or a terminal `Done` / `Failed`). At most one HTTP
//! request is in flight at any moment.
//!
//! Desktop-parity surfaces (TS `code-panel.tsx` semantics via the desktop
//! runner, which is canonical):
//! * **Cancel really aborts** — the active run is parked (`cancelled`), its
//! in-flight XHR is aborted, `CodegenPipeline::cancel` ends the machine,
//! and the pump drops every queued/late delta so a stale run can never
//! resurrect the panel (TS `abortRef.current?.abort()`).
//! * **Whole-page fallback** — an empty selection generates from the active
//! page's children (TS `getTargetNodes`, code-panel.tsx:137-142).
//! * **Assets are kept** — the terminal `Done`'s asset bytes are parked in a
//! host-side slot (the wasm-clean `editor_state` carries only `AssetMeta`),
//! and Download produces `component.zip` (code + `assets/*`) when assets
//! exist — the same zip layout as the desktop `codegen_export`.
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use op_codegen::ai::types::{AssetFile, PendingRequest, PipelineStep, RequestId};
use op_codegen::ai::CodegenPipeline;
use op_editor_core::codegen::{CodeGenProgress, CodegenPhase};
use op_editor_core::AgentProvider;
use op_editor_host_core::codegen::{
build_codegen_input_value as build_codegen_input, framework_ext,
};
use crate::repaint_ctx::RepaintContext;
use crate::web_ai_transport::{post_ai_stream, AiEvent, AiStreamHandle};
/// A delta drained by the rAF pump into `editor_state.codegen`. Mirrors the
/// desktop `CodegenDelta`; the asset BYTES are parked in [`LAST_RESULT`] when
/// the terminal `Done` is queued (only the metas travel into the wasm-clean
/// `editor_state`).
pub enum WebCodegenDelta {
Progress(CodeGenProgress),
Done {
code: String,
degraded: bool,
assets: Vec<op_editor_core::codegen::AssetMeta>,
},
Failed(String),
}
/// The in-flight pipeline run. Shared (`Rc<RefCell<…>>`) between the chain of
/// request callbacks, the rAF pump's tick, and the [`ACTIVE_RUN`] slot.
struct CodegenRun {
pipe: CodegenPipeline,
/// Streamed text for the in-flight request, fed back via `on_delta`.
buf: String,
/// The request currently awaiting its SSE stream (None between requests).
in_flight: Option<RequestId>,
/// Abort handle for the in-flight request's XHR (None between requests).
handle: Option<AiStreamHandle>,
/// Remaining requests in the current `Dispatch` batch, processed
/// sequentially: the chain fires the front request, and on its terminal
/// event pops the next. When empty, the driver re-steps the pipeline.
batch: VecDeque<PendingRequest>,
/// Set once a terminal `Done` / `Failed` delta has been queued, so the
/// rAF pump can stop after draining it.
terminal: bool,
/// Raised by [`cancel_active_run`]: every later event/delta from this run
/// is dropped (desktop parity — the canceled UI state survives).
cancelled: bool,
/// The model id (wire `value`) to send with each request; "default" lets
/// the proxy pick the configured provider.
model: String,
/// Exact provider selected alongside `model`, when one is available.
provider: Option<AgentProvider>,
/// Target framework captured at launch — the Download file extension must
/// match what was GENERATED even if the user switches tabs afterwards
/// (desktop `CodegenSession.framework` parity).
framework: op_editor_core::codegen::Framework,
/// Browser-local credential for the selected built-in provider. Captured
/// once at launch and sent only with this run's model requests.
credential: Option<serde_json::Value>,
}
/// Shared state threaded through the async driver: the run + the delta queue.
type Shared = Rc<RefCell<(CodegenRun, VecDeque<WebCodegenDelta>)>>;
/// The completed result kept HOST-SIDE for Download — asset bytes are not
/// carried in the wasm-clean `editor_state`. Mirror of the desktop
/// `CodegenResult` (wasm is single-threaded, so a thread_local slot is the
/// natural owner).
pub(crate) struct WebCodegenResult {
pub(crate) code: String,
pub(crate) framework_ext: &'static str,
pub(crate) assets: Vec<AssetFile>,
}
thread_local! {
/// The live (or just-canceled) run. A LIVE run blocks a new launch; a
/// canceled one does not (desktop `launch_codegen_if_pending` parity:
/// cancel + regenerate is immediate).
static ACTIVE_RUN: RefCell<Option<Shared>> = const { RefCell::new(None) };
/// Completed result for the Download action (code + asset bytes).
static LAST_RESULT: RefCell<Option<WebCodegenResult>> = const { RefCell::new(None) };
}
/// True while a non-canceled, non-terminal run is in flight.
fn has_live_run() -> bool {
ACTIVE_RUN.with(|slot| {
slot.borrow().as_ref().is_some_and(|shared| {
let s = shared.borrow();
!s.0.cancelled && !s.0.terminal
})
})
}
/// Park the active run: raise `cancelled`, abort its in-flight XHR, end the
/// pipeline machine, and drop everything it already queued. The rAF pump
/// observes `cancelled` on its next frame and stops without applying anything
/// (TS `abortRef.current?.abort()`; desktop `CodegenSession::cancel` +
/// canceled-pump parity).
pub(crate) fn cancel_active_run() {
let run = ACTIVE_RUN.with(|slot| slot.borrow_mut().take());
if let Some(shared) = run {
let mut s = shared.borrow_mut();
s.0.cancelled = true;
s.0.pipe.cancel();
s.0.credential = None;
if let Some(handle) = s.0.handle.take() {
// Aborting fires the XHR's onloadend, which synthesizes an Error
// event — the event callback drops it via the `cancelled` flag.
handle.abort();
}
s.1.clear();
}
}
/// Drain the Code-panel flags raised during `apply_press` (Cancel first, then
/// Generate / Regenerate — the desktop event-loop drain order). Called from
/// the DOM mousedown listener AFTER its `inner` borrow is released, because
/// `start_codegen` re-borrows `inner` (and the rAF pump it starts borrows it
/// again on later frames).
pub(crate) fn drain_codegen_flags<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>) {
let (cancel, generate) = {
let mut bm = inner.borrow_mut();
let cg = &mut bm.host_mut().editor_state_mut().codegen;
let cancel = std::mem::take(&mut cg.pending_cancel);
let generate = cg.pending_generate || cg.pending_regenerate;
// Clear the launch flags before launching so a failed launch (e.g.
// the empty-document error path, which surfaces inline) isn't
// re-triggered on the next press.
cg.pending_generate = false;
cg.pending_regenerate = false;
(cancel, generate)
};
if cancel {
cancel_active_run();
}
if generate {
// A LIVE run swallows the press (desktop parity: the pending launch
// never proceeds while a run is active; the panel only shows
// Generate / Regenerate outside the Generating state anyway).
if has_live_run() {
return;
}
start_codegen(inner.clone(), crate::daemon_base::daemon_base());
}
}
/// Launch a web codegen run: build input, create the pipeline, drive it over
/// async XHR callbacks, and pump deltas into `editor_state.codegen` via rAF.
///
/// `base` is the daemon origin (e.g. `http://127.0.0.1:3100`) — the same
/// origin `live_sync` polls. Returns immediately; the model turns stream in
/// later via the request callbacks.
pub fn start_codegen<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, base: String) {
// 1. Build input from the live editor state. Nothing to generate from
// (empty page + no selection) surfaces an inline error (desktop
// `launch_codegen_if_pending` parity).
let (input, provider, model, credential, framework) = {
let b = inner.borrow();
let state = b.host().editor_state();
let Some(input) = build_codegen_input(state) else {
drop(b);
let mut bm = inner.borrow_mut();
let cg = &mut bm.host_mut().editor_state_mut().codegen;
cg.error = Some("Select nodes to generate code".into());
cg.phase = CodegenPhase::Error;
cg.pending_generate = false;
cg.pending_regenerate = false;
bm.host_mut().mark_editor_state_dirty();
let _ = bm.repaint();
return;
};
// Model id: the selected chat model's wire value, else "default" (the
// proxy then picks the configured provider).
let selected = state.chat.selected_model_entry();
if selected.is_some_and(|entry| entry.builtin_provider_id.is_none()) {
drop(b);
let mut bm = inner.borrow_mut();
let cg = &mut bm.host_mut().editor_state_mut().codegen;
cg.error = Some("Select a browser AI provider to generate code".into());
cg.phase = CodegenPhase::Error;
cg.pending_generate = false;
cg.pending_regenerate = false;
bm.host_mut().mark_editor_state_dirty();
let _ = bm.repaint();
return;
}
let (model, credential) = crate::web_ai_credentials::selected_target(state);
// The browser surface is built-in-only. Structured built-in catalog
// entries still carry provider identity, but a transient CLI entry
// must never opt this request into daemon-side CLI routing.
let provider = selected
.filter(|entry| entry.builtin_provider_id.is_some())
.map(|entry| entry.provider);
(input, provider, model, credential, state.codegen.framework)
};
// Reset the panel into the Generating state before the first turn, and
// record the targets this run generates against (TS `lastSelectionRef` —
// drives the panel's "Selection changed" notice).
{
let mut bm = inner.borrow_mut();
let selection_snapshot: Vec<String> = bm
.host()
.editor_state()
.selection
.set
.iter()
.map(|id| id.as_str().to_string())
.collect();
let cg = &mut bm.host_mut().editor_state_mut().codegen;
cg.progress = Default::default();
cg.error = None;
cg.phase = CodegenPhase::Generating;
cg.selection_snapshot = selection_snapshot;
bm.host_mut().mark_editor_state_dirty();
let _ = bm.repaint();
}
// 2. Shared run-state + delta queue; register as the active run.
let shared: Shared = Rc::new(RefCell::new((
CodegenRun {
pipe: CodegenPipeline::new(input),
buf: String::new(),
in_flight: None,
handle: None,
batch: VecDeque::new(),
terminal: false,
cancelled: false,
model,
provider,
framework,
credential,
},
VecDeque::new(),
)));
ACTIVE_RUN.with(|slot| {
*slot.borrow_mut() = Some(shared.clone());
});
// 3. Start the rAF pump that drains the queue into editor_state.codegen.
start_pump(inner.clone(), shared.clone());
// 4. Kick the driver — it steps the pipeline and fires the first request.
drive(inner, base, shared);
}
/// Step the pipeline (when no batch is pending) and dispatch the next request,
/// or queue a terminal delta. Re-entered from each request's terminal event.
fn drive<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, base: String, shared: Shared) {
// If a batch is still draining, fire its front request instead of stepping.
let next_req = {
let mut s = shared.borrow_mut();
if s.0.cancelled {
return; // parked — nothing further is dispatched
}
if s.0.batch.is_empty() {
// No batch pending — step the pipeline for the next instruction.
match s.0.pipe.step() {
PipelineStep::Dispatch(reqs) => {
s.0.batch = reqs.into();
}
PipelineStep::Waiting => {
// Sequential driving never parks on Waiting (at most one
// request is ever in flight, and we only re-step after it
// settles). Treat as terminal-safe: nothing to do.
return;
}
PipelineStep::Done {
code,
degraded,
assets,
} => {
// Park the bytes host-side for Download; only the metas
// travel into the wasm-clean editor_state.
let metas = assets
.iter()
.map(|a| op_editor_core::codegen::AssetMeta {
relative_path: a.relative_path.clone(),
byte_len: a.bytes.len(),
})
.collect();
LAST_RESULT.with(|slot| {
*slot.borrow_mut() = Some(WebCodegenResult {
code: code.clone(),
framework_ext: framework_ext(s.0.framework),
assets,
});
});
s.0.terminal = true;
s.0.credential = None;
s.1.push_back(WebCodegenDelta::Done {
code,
degraded,
assets: metas,
});
return;
}
PipelineStep::Failed { message } => {
s.0.terminal = true;
s.0.credential = None;
s.1.push_back(WebCodegenDelta::Failed(message));
return;
}
}
}
// Pop the front request of the (possibly just-filled) batch.
s.0.batch.pop_front()
};
let Some(req) = next_req else {
// Empty dispatch — re-step to advance (the pipeline may emit Waiting
// or a terminal next).
drive(inner, base, shared);
return;
};
fire_request(inner, base, shared, req);
}
/// Fire one model request over the SSE proxy. The `on_event` callback
/// accumulates deltas into `run.buf` and, on the terminal event, feeds the
/// pipeline + re-drives. Borrows are taken tightly inside each callback and
/// dropped before the next async hop is scheduled.
fn fire_request<C: RepaintContext + 'static>(
inner: Rc<RefCell<C>>,
base: String,
shared: Shared,
req: PendingRequest,
) {
let id = req.id;
{
let mut s = shared.borrow_mut();
s.0.in_flight = Some(id);
s.0.buf.clear();
}
let body_json = {
let run = shared.borrow();
build_body_json(
&req,
run.0.provider,
&run.0.model,
run.0.credential.as_ref(),
)
};
// Cloned handles moved into the streaming callback.
let inner_cb = inner.clone();
let base_cb = base.clone();
let shared_cb = shared.clone();
let on_event: Rc<dyn Fn(AiEvent)> = Rc::new(move |evt: AiEvent| {
// A canceled run ignores everything its stale stream still emits
// (including the Error the aborted XHR's onloadend synthesizes).
if shared_cb.borrow().0.cancelled {
return;
}
match evt {
AiEvent::AgentIdentity { .. } => {}
AiEvent::Delta(t) => {
// Tight borrow: append + drop before returning to the browser.
shared_cb.borrow_mut().0.buf.push_str(&t);
}
// Reasoning fragments are a chat-transcript affordance; the codegen
// pipeline consumes only the answer text.
AiEvent::Thinking(_) => {}
AiEvent::Done => {
// Feed the buffered text into the pipeline, mark complete, queue a
// progress delta, then re-drive (next batch req or re-step).
{
let mut s = shared_cb.borrow_mut();
let buf = std::mem::take(&mut s.0.buf);
s.0.pipe.on_delta(id, &buf);
s.0.pipe.on_complete(id);
s.0.in_flight = None;
s.0.handle = None;
let progress = s.0.pipe.progress();
s.1.push_back(WebCodegenDelta::Progress(progress));
}
drive(inner_cb.clone(), base_cb.clone(), shared_cb.clone());
}
AiEvent::Error(e) => {
{
let mut s = shared_cb.borrow_mut();
s.0.pipe.on_error(id, e);
s.0.in_flight = None;
s.0.handle = None;
}
drive(inner_cb.clone(), base_cb.clone(), shared_cb.clone());
}
}
});
match post_ai_stream(&base, body_json, on_event) {
Ok(handle) => {
// Keep the abort handle so Cancel can kill the in-flight stream.
shared.borrow_mut().0.handle = Some(handle);
}
Err(_e) => {
// Transport refused to even start the request (e.g. XHR open
// failed). Report the error to the pipeline so the run
// terminates cleanly.
{
let mut s = shared.borrow_mut();
s.0.pipe
.on_error(id, "AI stream request failed to start".to_string());
s.0.in_flight = None;
}
drive(inner, base, shared);
}
}
}
/// Build the JSON request body for the proxy. Skill NAMES (not expanded
/// prompts) are forwarded; the daemon proxy composes the final system prompt
/// (the same `op_ai_skills::compose_system_prompt` the desktop host runs
/// in-process). Hand-rolled to avoid a serde derive for this tiny payload.
fn build_body_json(
req: &PendingRequest,
provider: Option<AgentProvider>,
model: &str,
credential: Option<&serde_json::Value>,
) -> String {
let skills_json = req
.skills
.iter()
.map(|s| serde_json::Value::String((*s).to_string()))
.collect::<Vec<_>>();
let body = serde_json::json!({
"provider": provider.map(AgentProvider::wire_id),
"model": model,
"skills": skills_json,
"user": req.user_message,
"max_output_tokens": req.max_output_tokens,
"thinking": req.thinking.as_str(),
"effort": req.effort.as_str(),
"credential": credential,
});
serde_json::to_string(&body).unwrap_or_else(|_| "{}".to_string())
}
/// Start the rAF pump that drains queued deltas into `editor_state.codegen`
/// and repaints. Stops once a terminal delta (Done / Failed) has been applied,
/// or immediately when the run was canceled (every queued/late delta is
/// dropped so the canceled UI state survives — desktop pump parity).
fn start_pump<C: RepaintContext + 'static>(inner: Rc<RefCell<C>>, shared: Shared) {
let tick: Rc<dyn Fn() -> bool> = Rc::new(move || {
// Canceled: drop everything and stop. The Cancel action already
// flipped the painted phase; nothing here may overwrite it.
if shared.borrow().0.cancelled {
shared.borrow_mut().1.clear();
return false;
}
let mut applied_terminal = false;
let mut changed = false;
// Drain every queued delta this frame.
loop {
// Pop one delta under a tight borrow so the apply below doesn't
// hold the shared borrow across a repaint.
let delta = {
let mut s = shared.borrow_mut();
s.1.pop_front()
};
let Some(delta) = delta else { break };
let mut bm = inner.borrow_mut();
let cg = &mut bm.host_mut().editor_state_mut().codegen;
match delta {
WebCodegenDelta::Progress(p) => {
cg.progress = p;
cg.phase = CodegenPhase::Generating;
}
WebCodegenDelta::Done {
code,
degraded,
assets,
} => {
cg.code = code;
cg.code_scroll.offset = 0.0;
cg.code_selection = None;
cg.degraded = degraded;
cg.assets = assets;
cg.phase = CodegenPhase::Complete;
cg.pending_generate = false;
cg.pending_regenerate = false;
applied_terminal = true;
}
WebCodegenDelta::Failed(e) => {
cg.error = Some(e);
cg.phase = CodegenPhase::Error;
cg.pending_generate = false;
cg.pending_regenerate = false;
applied_terminal = true;
}
}
bm.host_mut().mark_editor_state_dirty();
changed = true;
}
if changed {
// Repaint outside the delta loop so a multi-delta frame paints once.
let _ = inner.borrow_mut().repaint();
}
if applied_terminal {
// Retire this run from the active slot (unless a newer run
// already replaced it).
ACTIVE_RUN.with(|slot| {
let mut s = slot.borrow_mut();
if s.as_ref().is_some_and(|r| Rc::ptr_eq(r, &shared)) {
*s = None;
}
});
return false;
}
true
});
crate::raf_pump::start(tick);
}
// ---------------------------------------------------------------------
// Download (Code panel) — code file, or component.zip when assets exist
// ---------------------------------------------------------------------
/// Download the completed generation: `component.<ext>` (single file), or a
/// `component.zip` carrying the code + `assets/*` when image assets came back
/// (desktop `codegen_export::handle_download` layout; TS gates the zip the
/// same way on `assets.length > 0`). Falls back to the painted code buffer
/// when no parked result exists (defensive — Download is only reachable from
/// the Complete state).
pub(crate) fn download_generated(state: &op_editor_core::EditorState) {
LAST_RESULT.with(|slot| {
let slot = slot.borrow();
match slot.as_ref() {
Some(result) if result.code.is_empty() => {}
Some(result) if !result.assets.is_empty() => {
let bytes = build_code_zip(&result.code, result.framework_ext, &result.assets);
let _ = crate::web_clipboard::download_bytes(
"component.zip",
"application/zip",
&bytes,
);
}
Some(result) => {
let _ = crate::web_clipboard::download_bytes(
&format!("component.{}", result.framework_ext),
"text/plain",
result.code.as_bytes(),
);
}
None if !state.codegen.code.is_empty() => {
let ext = framework_ext(state.codegen.framework);
let _ = crate::web_clipboard::download_bytes(
&format!("component.{ext}"),
"text/plain",
state.codegen.code.as_bytes(),
);
}
None => {}
}
});
}
/// Build the Download zip: `component.<ext>` + one `assets/<...>` entry per
/// asset (each asset's `zip_path` already lives under `assets/`). STORED
/// encoding via the dependency-free encoder — same entry layout as the
/// desktop `codegen_export::build_code_zip`.
fn build_code_zip(code: &str, ext: &str, assets: &[AssetFile]) -> Vec<u8> {
let mut files: Vec<(String, Vec<u8>)> = Vec::with_capacity(1 + assets.len());
files.push((format!("component.{ext}"), code.as_bytes().to_vec()));
for asset in assets {
files.push((asset.zip_path.clone(), asset.bytes.clone()));
}
op_codegen::ai::stored_zip::build_stored_zip(&files)
}
#[cfg(test)]
mod tests {
use super::*;
use op_editor_core::{EditorState, NodeId};
#[test]
fn codegen_lifecycle_explicitly_clears_request_credentials() {
let source = include_str!("codegen_web.rs");
let implementation = source
.split("#[cfg(test)]")
.next()
.expect("codegen implementation");
assert!(
implementation.matches("s.0.credential = None;").count() >= 2,
"cancel and terminal completion must both clear the captured credential"
);
}
fn two_rect_state() -> EditorState {
let doc = jian_ops_schema::load_str(
r#"{"version":"1.0.0","children":[
{"type":"rectangle","id":"n1","name":"A","x":0,"y":0,"width":10,"height":10},
{"type":"rectangle","id":"n2","name":"B","x":20,"y":0,"width":10,"height":10}
]}"#,
)
.expect("fixture parses")
.value;
EditorState::from_document(doc)
}
#[test]
fn build_input_resolves_the_selection_subtrees() {
let mut state = two_rect_state();
state.set_single_selection(NodeId::new("n1"));
let input = build_codegen_input(&state).expect("input");
assert!(input.nodes_json.contains("n1"));
assert!(!input.nodes_json.contains("n2"));
assert_eq!(input.framework, state.codegen.framework);
}
#[test]
fn empty_selection_falls_back_to_active_page_children() {
// TS getTargetNodes (code-panel.tsx:137-142): no selection → ALL
// active-page children. Desktop codegen_input parity.
let mut state = two_rect_state();
state.clear_selection();
let input = build_codegen_input(&state).expect("page fallback");
assert!(input.nodes_json.contains("n1"));
assert!(input.nodes_json.contains("n2"));
}
#[test]
fn empty_page_and_unresolvable_selection_are_none() {
let empty = EditorState::new();
assert!(build_codegen_input(&empty).is_none());
let mut ghost = two_rect_state();
ghost.set_single_selection(NodeId::new("ghost"));
assert!(build_codegen_input(&ghost).is_none());
}
#[test]
fn framework_ext_maps_every_framework() {
use op_editor_core::codegen::Framework;
assert_eq!(framework_ext(Framework::React), "tsx");
assert_eq!(framework_ext(Framework::ReactNative), "tsx");
assert_eq!(framework_ext(Framework::Vue), "vue");
assert_eq!(framework_ext(Framework::Svelte), "svelte");
assert_eq!(framework_ext(Framework::Html), "html");
assert_eq!(framework_ext(Framework::Flutter), "dart");
assert_eq!(framework_ext(Framework::SwiftUi), "swift");
assert_eq!(framework_ext(Framework::Compose), "kt");
}
#[test]
fn codegen_proxy_body_carries_the_selected_request_scoped_credential() {
let req = PendingRequest {
id: RequestId(1),
kind: op_codegen::ai::types::RequestKind::Planning,
skills: vec!["codegen-plan"],
user_message: "plan".into(),
max_output_tokens: 1024,
thinking: op_ai::chat_provider::ThinkingMode::Disabled,
effort: op_ai::chat_provider::EffortLevel::Low,
};
let credential = serde_json::json!({"api_key":"sk-codegen"});
let body: serde_json::Value = serde_json::from_str(&build_body_json(
&req,
Some(AgentProvider::CodexCli),
"private-model",
Some(&credential),
))
.unwrap();
assert_eq!(body["provider"], "codex-cli");
assert_eq!(body["model"], "private-model");
assert_eq!(body["credential"]["api_key"], "sk-codegen");
}
#[test]
fn code_zip_carries_component_and_each_asset() {
let assets = vec![
AssetFile {
id: "a1".into(),
relative_path: "./assets/img-1.png".into(),
zip_path: "assets/img-1.png".into(),
mime_type: "image/png".into(),
bytes: vec![1, 2, 3],
source_node_id: "n1".into(),
},
AssetFile {
id: "a2".into(),
relative_path: "./assets/img-2.png".into(),
zip_path: "assets/img-2.png".into(),
mime_type: "image/png".into(),
bytes: vec![4, 5, 6],
source_node_id: "n2".into(),
},
];
let bytes = build_code_zip("export default function X(){}", "vue", &assets);
// STORED zip magic + every entry name present.
assert_eq!(&bytes[0..4], &[0x50, 0x4B, 0x03, 0x04]);
let hay = String::from_utf8_lossy(&bytes);
assert!(hay.contains("component.vue"));
assert!(hay.contains("assets/img-1.png"));
assert!(hay.contains("assets/img-2.png"));
}
}