Compare commits

...
Author SHA1 Message Date
teahubxyz d4915e39b2 havre: the update pill, in house style
ci / test (pull_request) Successful in 4s
2026-10-21 19:34:42 +00:00
teahubxyz f1247b5afd havre: load update.js 2026-10-21 19:34:35 +00:00
teahubxyz 02409a0365 havre: update pill — notice a new version and offer a reload (as gandora does) 2026-10-21 19:34:30 +00:00
teahubxyz 0c0feec53b Havre: /whisper — ASMR voice notes (#214) 2026-10-21 19:30:07 +00:00
teahubxyz 26265ee0cf mdecks README: opening a .md file 2026-10-21 17:30:23 +00:00
teahubxyz 97610b03ec mdecks: Open .md button, drop target styling, copy
Adds the Open .md button beside the editor label, a hidden file input, a page-wide drop highlight, and mentions dropping a file in the hint, How it works and the Free tier.
2026-10-21 17:30:12 +00:00
teahubxyz 23fde14eda mdecks: open a .md file by drop or button
A .md (or any text) file dropped anywhere on the page, or picked with the new Open .md button, replaces the editor text and clears the hash, so the file is the deck. Nothing is uploaded: FileReader reads it in the tab.
2026-10-21 17:29:56 +00:00
time 79635906e3 ventures: daily signup counts 2026-10-21 00:28:45 +00:00
github-actions[bot] e82c7e791f ventures: refresh roll-credits 2026-10-20 22:47:37 +00:00
teahubxyzandClaude Fable 5.1 9841186f80 ventures: roll-credits card image
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:42:28 +00:00
teahubxyzandClaude Fable 5.1 0a263c782b ventures: roll-credits gets a tools/build.sh that renders its shots/desktop.png (it launched without a card image)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:42:15 +00:00
teahubxyzandClaude Fable 5.1 f00e5156dc ventures: gandora moved to gandora/site (served at gandora.yolopreneur.lol from its own repo)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:37:40 +00:00
teahubxyzandClaude Fable 5.1 67b91770a7 gandora: 120 s tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:35:37 +00:00
teahubxyzandClaude Fable 5.1 1666f0200c ventures: gandora lives in gandora/site now; its card image comes from there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:34:47 +00:00
teahubxyzandClaude Fable 5.1 ffc0e85999 gandora: player order — cover, transport, trail, title, bar, lyrics/keep/share, then dj line / next / tags
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:33:42 +00:00
teahubxyzandClaude Fable 5.1 3324fcc226 gandora: trail — scroll back through what this station has played, right on the player (pandora-style), tap to jump
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:15:17 +00:00
teahubxyzandClaude Fable 5.1 7769502e0b gandora: hide the shared-budget / add-key button for now (still reachable with ?key)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:09:35 +00:00
teahubxyzandClaude Fable 5.1 4ebed492bd gandora: render through the worker's one-call /v1/render (Runware when the factory has a key), real cost + seed + renderer kept on the track
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:08:16 +00:00
teahubxyzandClaude Fable 5.1 982d1af057 gandora: DJ line · NEXT · then bpm · key · TAGS ▾, with the tags opening on a line of their own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:00:35 +00:00
teahubxyzandClaude Fable 5.1 bf2ecbc6b3 gandora: a tap outside any sheet (station, emoji picker, tape picker) closes it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ac/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:59:02 +00:00
teahubxyzandClaude Fable 5.1 1c57e075ff gandora: tags fold to one line (TAGS ▾ · bpm · key); palette dial per station — mono, duo, tri
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:57:02 +00:00
teahubxyzandClaude Fable 5.1 f733a66fb6 gandora: station name above the cover, centred, in the wordmark style
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:54:12 +00:00
teahubxyzandClaude Fable 5.1 c0aea23067 gandora: + tag stays visible when the tag row is folded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:53:55 +00:00
teahubxyzandClaude Fable 5.1 b976041118 gandora: new flow — cover, title, progress, DJ line, tags (folded to four, bpm and key as chips), NEXT, controls, pills
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:53:45 +00:00
teahubxyzandClaude Fable 5.1 538b93cf75 gandora: chord look fills in — slower damping, more cycles, a detuned second trace
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:34:24 +00:00
teahubxyzandClaude Fable 5.1 7b66e4dd35 gandora: looks — rings, chord (a harmonograph of the key's interval), attractor (a Clifford attractor from the song); per station, set in the station sheet
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:31:33 +00:00
teahubxyzandClaude Fable 5.1 0a22d29dd4 gandora: a station sheet — rename, reset taste, delete (kept tracks stay); from the top bar name or ⋯ on a station tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:30:04 +00:00
teahubxyzandClaude Fable 5.1 271243a334 gandora: refreshing a station link resumes the station rather than refilling the seed box
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:29:28 +00:00
teahubxyzandClaude Fable 5.1 60f374527f gandora: tags sit with the reactions, above the emoji row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:26:52 +00:00
teahubxyzandClaude Fable 5.1 0c6fd2d1f1 gandora: a shared tape queues its tracks before the station opens; switching station ends a tape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:16:42 +00:00
teahubxyzandClaude Fable 5.1 a7f342b196 gandora: mixtapes
A tape is a named, ordered list of tracks: start one from the library's TAPES strip or add any tile with its +;
the tape screen plays through, repeats all, reorders (▲▼), removes, renames, deletes; SHARE copies a link that
carries every track's sheet music, and a browser opening it gets a tape of its own, rendering each in order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:16:21 +00:00
teahubxyzandClaude Fable 5.1 abbcceba53 gandora: controls reordered (back · shuffle · replay · play · repeat · remix · next), cleaner shuffle glyph, first remix
Remix hands the current track's sheet music back to the DJ with the listener's chip edits and words, asks for a
variation with the same bones, and puts it next; a track already cooking plays after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:14:45 +00:00
teahubxyzandClaude Fable 5.1 2631dd6f6c gandora: shuffle mixes the station's own library in, weighted by the taste net
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:09:21 +00:00
teahubxyzandClaude Fable 5.1 4add8c4484 gandora: no KEPT label inside the KEPT section
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:08:16 +00:00
teahubxyzandClaude Fable 5.1 c866d70f5c gandora: library tiles — emoji on the cover, KEPT as a label not a heart, one-line meta
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:05:19 +00:00
teahubxyzandClaude Fable 5.1 70a856db39 gandora: an emoji picker from +, and the reaction row is the listener's eight most-used emoji
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:03:32 +00:00
teahubxyzandClaude Fable 5.1 b3ff0f9672 gandora: tag taps train the net as word-level samples; ▲▼ counts on the taste map
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:59:25 +00:00
teahubxyzandClaude Fable 5.1 5a56c56a07 gandora: tag taps can be let go (~word); tag tokens stay out of the emoji rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:59:06 +00:00
teahubxyzandClaude Fable 5.1 3a0df54646 gandora: tags are controls (more / less / let go, + your own); the DJ's line sits above them and taps into the reply box
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:58:48 +00:00
teahubxyzandClaude Fable 5.1 c435a7be1f gandora: the net hears the words — tags, title and lyrics as hashed bag-of-words inputs
5 numbers + 48 signed-hash word slots → 12 neurons → like. The DJ's taste paragraph now names the words the net
warms to and cools on; the heat map and the grid are scored with the station's own wording so the words block is
live. Checked: with identical tempo/energy/key, "wobble bass" vs "harmonica" separate cleanly on words alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:55:05 +00:00
teahubxyzandClaude Fable 5.1 ce2eead558 gandora: STORAGE.md, the contract between the page and whatever stores its tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:37:35 +00:00
teahubxyzandClaude Fable 5.1 a78d6db949 gandora: the URL follows the station; SHARE links carry a track's sheet music so it opens and renders for anyone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:34:00 +00:00
teahubxyzandClaude Fable 5.1 cd8744c64e gandora: next sits to the right of repeat
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:32:23 +00:00
teahubxyzandClaude Fable 5.1 55a580d0c6 gandora: wire in the taste brain (brain.js), the net and palette on TASTE; rows keep their play counts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:23:58 +00:00
teahubxyzandClaude Fable 5.1 9e9fdc0a70 gandora: the taste brain — a tiny net as the recommendation engine
brain.js: 5 inputs (tempo, energy, key on the circle, minor) → 8 neurons → like, retrained from scratch on
every reaction and play count (a looped track is a loud 🔥). Its picture of the listener rides in the DJ's prompt
(most and least likely regions in words); the NEXT line shows the net's guess for the track in the oven. TASTE
draws the net with neurons firing for the current track, the prediction heat map, emoji and play counts on each
dot, the cooking track as a hollow mark, and THE FIVE tracks that define the station.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:23:41 +00:00
teahubxyzandClaude Fable 5.1 6716c81106 gandora: taste map shades gold only where warm outweighs cold; heard-but-unreacted is neutral grey
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:16:19 +00:00
teahubxyzandClaude Fable 5.1 802ef8233f gandora: ❤️ is a reaction only; KEEP is the one way to pin a track
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:14:53 +00:00
teahubxyzandClaude Fable 5.1 a5720e6b3c gandora: resuming a station replays its latest track at once (free) while the next one cooks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:12:49 +00:00
teahubxyzandClaude Fable 5.1 a2a505a511 gandora: LIBRARY and TASTE reachable from the seed screen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:11:24 +00:00
teahubxyzandClaude Fable 5.1 538fa96aa6 gandora: five balanced controls (back, replay, play, forward, loop); LYRICS and KEEP pills side by side
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:09:02 +00:00
teahubxyzandClaude Fable 5.1 df2299a0f7 gandora: a TASTE map, per station and overall
Every track is a point (tempo across, energy up, colour = key, ring warm or cold by its reactions, size by how
much was said); the background is a kernel estimate of liking from the reactions, lit where warm outweighs cold.
Tabs per station with their seed marks, plus ALL. Reads the library's own rows; no new storage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:06:28 +00:00
teahubxyzandClaude Fable 5.1 a93ca1ec95 gandora: stepping back through the session doesn't re-enter history or count as a new play
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:05:36 +00:00
teahubxyzandClaude Fable 5.1 57d448443f gandora: a real previous-track button; skip walks forward through the session before cooking new tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:05:22 +00:00
teahubxyzandClaude Fable 5.1 9c9a0dd377 gandora: library grouped by station (channel), each with its seed mark and count; kept tracks on top
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:00:52 +00:00
teahubxyzandClaude Fable 5.1 f967f739e4 gandora: only wire the analyser once the audio context is running (a suspended one would mute iOS)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:00:20 +00:00
teahubxyzandClaude Fable 5.1 a8e8d0efbd gandora: the cover is a visualizer while music plays; reactions show on library tiles
Analyser on the player: bass breathes the rings and the seed mark, highs sharpen the lines, slow drift on the
phases; ground and grain are cached per song so the frame loop only redraws rings and core. Reactions are saved
with the track on each tap (they were only in memory before), and the tiles show them (💬 for words, ⏭ for a skip).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:59:57 +00:00
teahubxyzandClaude Fable 5.1 af7fe043bd ventures: gandora lives at gandora.lol
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:51:40 +00:00
teahubxyzandClaude Fable 5.1 c54522678b gandora: lyrics on the back of the cover (tap the cover or LYRICS to flip)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:48:31 +00:00
teahubxyzandClaude Fable 5.1 df98581056 gandora: lyrics under the title (tap LYRICS or the cover); section markers styled; stays open across tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:47:50 +00:00
teahubxyzandClaude Fable 5.1 8806f56907 gandora: a repeat button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:46:56 +00:00
teahubxyzandClaude Fable 5.1 86a9ec10d3 gandora: say why a track didn't render (a budget cap reads differently from a Venice hiccup)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:43:51 +00:00
teahubxyzandClaude Fable 5.1 81805e1a25 gandora: stations and an explicit KEEP
Stations: a seed, its name, taste and recent history are saved (IndexedDB v2) and listed on the seed screen as
seed marks to resume; a new seed opens a new station, picking a station resumes it with its taste intact.
KEEP: a labelled pill under the controls pins the current track (toggle), beside the ❤️ reaction which still keeps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:38:18 +00:00
teahubxyzandClaude Fable 5.1 8d21bf11ff gandora: every played track is kept in the library (last 40), hearts pin for good; KEPT and PLAYED sections
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:27:42 +00:00
teahubxyzandClaude Fable 5.1 64fa4fc0b7 gandora: the now-playing strip follows track changes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:22:04 +00:00
teahubxyzandClaude Fable 5.1 6bdb300adb gandora: leaving the player keeps the station playing; a now-playing strip on the seed screen leads back
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:21:48 +00:00
teahubxyzandClaude Fable 5.1 050aa296a5 gandora: hedge a stalled render after 60 s with a second queue, take whichever lands first
Three timed renders through the worker took 8-24 s; the one that sat for 2+ min was Venice's queue stalling that
job. A second copy after 60 s bounds the wait at about normal + 60 s for an extra 3 cents on a bad day.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:18:48 +00:00
teahubxyzandClaude Fable 5.1 52edca2774 gandora: run on the shared inference worker by default, a personal Venice key optional
No key in the page and none needed: calls go to inference.yolopreneur.lol with an x-app header, quota'd per person
per day; a saved personal key still goes straight to api.venice.ai. DJ defaults to claude-haiku-5-5 (llama-3.3-70b
is gone from Venice's list). The ?demo key path is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:09:18 +00:00
github-actions[bot] 790fa26b73 ventures: refresh havre 2026-10-20 18:52:20 +00:00
teahubxyzandClaude Opus 5.5 b3ec97387f havre: the nav is just studio, light/dark and reload (the reading is in the footer)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQqGFv54JJTw7p3rgGjP7C
2026-10-20 18:48:53 +00:00
teahubxyzandClaude Fable 5.1 1b4641c1a4 gandora: honour a shared demo key from demo.js on ?demo links
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 18:48:44 +00:00
github-actions[bot] 00cbf4f4eb ventures: refresh havre from havre-live 2026-10-20 18:22:41 +00:00
teahubxyz fbf54971af ventures: gandora in the ledger
Claude-Session: gandora-poc
2026-10-20 18:16:27 +00:00
teahubxyz 3a8bdf5f56 ventures: gandora, a generative station (PoC on Venice ACE-Step)
Type a seed → a DJ (Venice chat, JSON) writes tags, lyrics, bpm, key, energy for each track → ACE-Step 1.5 on
Venice renders 60 s → Pandora-style player with render-ahead. Covers are deterministic: hue from key, rings from
bpm, grain from energy, symmetry and core mark from the seed. Emoji + text reactions edit the station's taste;
❤️ keeps a track in an IndexedDB library (free replays). Venice key stays in the browser (CSP: api.venice.ai only).

Claude-Session: gandora-poc
2026-10-20 18:16:17 +00:00
github-actions[bot] 95ea8ca9a1 ventures: refresh havre from havre-open 2026-10-20 17:39:26 +00:00
teahubxyzandClaude Fable 5.1 8a8985c58b havre: a taller conversation on phones
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 06:42:56 +00:00
teahubxyzandClaude Fable 5.1 b977d7fee4 havre: the chat follows new messages only when you're at the end; scrolled up to read, it stays put (what you send, or open, still goes to the end)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 06:42:48 +00:00
teahubxyzandClaude Fable 5.1 dde0df7718 havre: the big picture has no focus ring or frame around it (it read as a blue background); links keep one colour once visited
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 05:46:48 +00:00
teahubxyzandClaude Fable 5.1 4be19bb845 havre smoke: a sent photo is in their turn
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 05:38:17 +00:00
teahubxyzandClaude Fable 5.1 567c187125 havre tests: a sent photo is in their turn
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 05:37:26 +00:00
teahubxyzandClaude Fable 5.1 c7d6f427f8 havre: a photo or clip they sent is told to the model in their own turn, as a stage direction, not in yours (characters took "you sent me a photo" as yours and got confused); a reply that narrates sending a photo makes one likely
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 05:37:15 +00:00
teahubxyzandClaude ba441f8d2d Havre openvenice README: workflows as Actions files, the Step node, the venture loop
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: open-workflows-venture-loop
2026-10-20 05:26:16 +00:00
github-actions[bot] b64af5c68d ventures: refresh havre 2026-10-20 05:24:54 +00:00
teahubxyzandClaude e6621649c3 Havre /open: a Step node, and any Actions workflow as a canvas workflow and back
A Step node is one Gitea Actions step as it is (run, uses, with, env): inert in the browser, verbatim in the
export. Import now takes any Actions workflow file, not only one exported from here: its steps become a chain of
Step nodes and the rest of the file (on, permissions, concurrency, the job's runs-on and env) is kept as the
workflow's frame, so it exports back as it came in, with any Venice nodes spliced in as runner steps. A file
exported from a framed workflow re-reads each step's YAML from the file on import, so an edit made in the file wins.
Proof: .github/workflows/venture.yml imports as three Step nodes and exports back identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: open-workflows-venture-loop
2026-10-20 05:20:50 +00:00
teahubxyzandClaude Fable 5.1 349d4016b9 havre: every round, a voice pick with a voice note already sent becomes a photo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 05:16:03 +00:00
teahubxyzandClaude Fable 5.1 9d959c1d99 havre: a reply that came with a voice note no longer skips what they'd send on their own; only a second voice note is held back
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 05:15:53 +00:00
teahubxyzandClaude Fable 5.1 82a3a694a6 havre: every round means every round: when the decision says nothing, write a shot and send a photo anyway; a photo that can't be made (no model, allowance spent) says why instead of nothing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 04:36:02 +00:00
teahubxyzandClaude Fable 5.1 519bb3ff05 Havre: export a character with memory, chats and pictures, or everything; import restores a bundle by merging
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 04:17:15 +00:00
teahubxyzandClaude Fable 5.1 fc11e6e141 Havre: export with memory and chats, or the whole harbor; a backup file restores through import
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 04:16:46 +00:00
teahubxyzandClaude Fable 5.1 6324a25aad Havre: backup.js — a character with their memory, chats and pictures as one file, or the whole harbor; read back and merged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 04:16:34 +00:00
teahubxyzandClaude Fable 5.1 63b754133e Havre: the on-their-own decision goes to a model that keeps to JSON, and a decision that goes wrong is said once (every round) instead of swallowed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 04:05:00 +00:00
teahubxyzandClaude Fable 5.1 223a5561f5 Havre: on a phone the three tabs sit on one line, smaller, with a dot between them (again: a refresh run had reverted it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 04:00:23 +00:00
github-actions[bot] 157f874e15 ventures: refresh havre 2026-10-20 03:56:54 +00:00
teahubxyzandClaude Fable 5.1 792bae027f Havre: the card's "new character" link opens the new-character form, from any card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:52:22 +00:00
teahubxyzandClaude Fable 5.1 c34dc5c6c4 Havre: a "new character" link on the card's heading, so there's a way back to writing someone from any card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:52:12 +00:00
teahubxyzandClaude Fable 5.1 72e8f47989 Havre: the reload link reloads, again (a refresh run had reverted it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:51:53 +00:00
teahubxyzandClaude Fable 5.1 cd6cd44813 Havre: the reload link in the nav, again (a refresh run had reverted it)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:50:36 +00:00
teahubxyzandClaude Fable 5.1 6b9c42eaa5 Havre: how long you're away before characters text first is a setting — a minute to a day (abilities, on their own); with "every round", the move is asked eagerly; no allowance left is said once
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:50:27 +00:00
teahubxyzandClaude Fable 5.1 b37c885f41 abilities.js: with "every round" the move is asked for eagerly — a photo unless it would make no sense, not "nothing, most of the time"
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:49:45 +00:00
github-actions[bot] 3b5cf0fce0 ventures: refresh havre 2026-10-20 03:45:38 +00:00
teahubxyzandClaude Fable 5.1 0f8d120d7e Havre: the reload link reloads, bypassing the cache
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:43:40 +00:00
teahubxyzandClaude Fable 5.1 f34467232f Havre: a reload link in the nav, for the app installed on a phone (no address bar, no pull to refresh)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:43:16 +00:00
github-actions[bot] 412037aefd ventures: refresh havre 2026-10-20 03:41:45 +00:00
teahubxyzandClaude Fable 5.1 f1ce82e287 Havre: how long you're away before characters text first is a setting (under abilities, on their own)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:40:49 +00:00
teahubxyzandClaude Fable 5.1 67a96bd60d smoke: a Venice character's hello carries what they can send here beside Venice's own prompt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:36:47 +00:00
teahubxyzandClaude Fable 5.1 e395865f87 Havre: the no-picture word is said once per chat
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:36:10 +00:00
teahubxyzandClaude Fable 5.1 1dce9b4174 Havre: a character knows what they can send here; one who wants to send a photo with no picture on their card says so in the chat
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:35:57 +00:00
teahubxyzandClaude Fable 5.1 a2f98c5436 card.js: the prompt tells a character what they can send here (photos and clips, or not yet, or not at all), so they don't deny it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:35:43 +00:00
github-actions[bot] 7db1184abc ventures: refresh havre 2026-10-20 03:30:37 +00:00
teahubxyzandClaude Fable 5.1 4dfb672f74 Havre: on a phone the three tabs sit on one line, smaller and without the slashes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:28:23 +00:00
teahubxyzandClaude Fable 5.1 76a05d37a3 Havre: a chat opened on a phone scrolls to the tabs above it, so the nav is in view
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:26:02 +00:00
teahubxyzandClaude Fable 5.1 2e8ff6b11a smoke: on a phone the tabs stay above the chat; no back link
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:24:19 +00:00
teahubxyzandClaude Fable 5.1 216abc8fa6 Havre: on a phone, a chat keeps the CHATS / CHARACTERS / HARBOR tabs above it instead of a back link
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:23:53 +00:00
github-actions[bot] 5bb59edec0 ventures: refresh havre 2026-10-20 03:20:03 +00:00
teahubxyzandClaude Fable 5.1 6e09057107 havre.py: the exported file's shell lines as block scalars, so a colon in them doesn't break the YAML
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:15:57 +00:00
teahubxyzandClaude Fable 5.1 dd2bbfb85e Havre /open: Export a workflow as a Gitea Actions file and Import one back; run.mjs, the headless runner the file uses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAvUH72XsdCitHdh3t7mjr
2026-10-20 03:14:18 +00:00
teahubxyz 00ce855e89 Merge pull request 'Havre: memory in three tiers — deep, long, short — with remember, remind and /recall' (#208) from havre-memory into gh-pages 2026-10-20 03:08:45 +00:00
31 changed files with 1584 additions and 101 deletions

No files matched your search

+15 -9
View File
@@ -270,25 +270,27 @@ export function clipOptions(model, last = {}) {
/**
* A conversation with pictures and voice notes in it, as plain messages for a text model. A photo or clip the
* character sent is mentioned in the next thing the person says ("(You sent me a photo: …)"), so the model knows
* about it without learning to write "[photo]" itself. Your photo is "[a photo: what it shows]", your voice note is
* the words you said.
* character sent is in their own turn, as a stage direction ("*sends a photo: …*"), the way characters narrate what
* they do, so the model knows what it sent and who sent it (told in the person's turn, "you sent me a photo" read as
* the person's, and characters got confused about whose photo it was). Your photo is "[a photo: what it shows]", your
* voice note is the words you said.
*/
export function apiHistory(history) {
const out = [];
let notes = [];
for (const m of history || []) {
if (!m || m.demo) continue;
const media = m.media || null;
if (m.role === "assistant") {
if (media && (media.kind === "photo" || media.kind === "clip")) {
const shot = clip(str(media.shot).trim(), 300).replace(/[.!?…\s]+$/, "");
notes.push(`(You sent me ${media.kind === "clip" ? "a short video" : "a photo"}${shot ? ": " + shot : ""}.)`);
const sent = `*sends ${media.kind === "clip" ? "a short video" : "a photo"}${shot ? ": " + shot : ""}*`;
const last = out.at(-1);
if (last && last.role === "assistant") last.content += "\n\n" + sent; // with the words it came after
else out.push({ role: "assistant", content: sent });
} else if (str(m.content).trim()) out.push({ role: "assistant", content: str(m.content) });
} else if (m.role === "user") {
let content = str(m.content);
if (media && media.kind === "photo-in") content = `[a photo${media.saw ? ": " + media.saw : ""}]` + (content ? " " + content : "");
if (notes.length) { content = notes.join(" ") + (content ? "\n\n" + content : ""); notes = []; }
out.push({ role: "user", content, ...(m.hidden ? { hidden: true } : {}) });
}
}
@@ -334,10 +336,14 @@ export const MOVE_SCHEMA = {
};
/** the messages for a text model to decide the character's next move (adult: their pictures can be adult, adultCheck) */
export function moveMessages(card, history, { adult = false } = {}) {
/** the move prompt as it stands, or, for someone set to send things every round, told to lean in rather than hold back */
export const EAGER = " This character is set to send things every round: choose \"photo\" (or \"voice\" or \"clip\" when that fits better) " +
"whenever a picture of them could go with what was just said, however small the reason; \"nothing\" only when a photo would make no sense at all.";
export function moveMessages(card, history, { adult = false, eager = false } = {}) {
const lines = transcript(card, history, 12);
const look = card.look && card.look.description;
return [{ role: "system", content: adult ? MOVE_SYSTEM_ADULT : MOVE_SYSTEM }, { role: "user", content: [
const system = (adult ? MOVE_SYSTEM_ADULT : MOVE_SYSTEM) + (eager ? EAGER : "");
return [{ role: "system", content: system }, { role: "user", content: [
`The character: ${card.name}${card.tagline ? ". " + card.tagline : ""}`,
look ? `How they look: ${look}` : "",
"The conversation, latest last (their reply just now is the last line):\n" + lines.join("\n"),
@@ -353,7 +359,7 @@ export function normalizeMove(o) {
// words that make a move likely: asking to see or hear them, or a reply about where they are and what they're doing
const ASKS = /\b(pics?|picture|photo|selfie|snap|show me|see you|see it|what (?:do )?you look like|look like|wearing|outfit|send (?:me )?(?:a|one|it)|video|clip|film|hear you|your voice|say it|sing|whisper|out loud|aloud)\b/i;
const SHOWS = /\b(look at (?:this|my|the)|wearing|outfit|dress|view from|sunset|sunrise|my (?:room|place|desk|garden|kitchen|car|boat|bed|window)|just got (?:back|a|my)|i'?m (?:at|in|on) the|picture|photo|selfie|whisper)\b/i;
const SHOWS = /\b(sends? (?:you )?(?:a |another )?(?:photo|pic|selfie|snap)|look at (?:this|my|the)|wearing|outfit|dress|view from|sunset|sunrise|my (?:room|place|desk|garden|kitchen|car|boat|bed|window)|just got (?:back|a|my)|i'?m (?:at|in|on) the|picture|photo|selfie|whisper)\b/i;
/**
* Whether to think about a move after this reply: when the person asked to see or hear them, when the reply talks
+230 -29
View File
@@ -6,6 +6,7 @@ import * as M from "./media.js";
import * as T from "./threads.js";
import * as R from "./memory.js";
import * as W from "./workflows.js";
import * as B from "./backup.js";
import { Venice, VeniceError } from "./venice.js";
import CAST from "./cast/index.js";
@@ -83,11 +84,14 @@ const S = {
// or clip being asked for, and a voice note being recorded
lists: { inpaint: null, image: null, tts: null, asr: null, video: null }, listErr: {},
ab: { photo: "", clip: {}, hear: false, hearModel: "", see: false, seeModel: "", own: true, allowance: 0.25, memory: true,
adult: true, adultPhoto: "", adultClip: "" }, voiceOn: {}, jobs: [], ask: null,
adult: true, adultPhoto: "", adultClip: "", awayMin: 360 }, voiceOn: {}, jobs: [], ask: null,
rec: null, moving: new Set(), inflight: 0,
};
// what characters do on their own (photos, voice notes) comes out of a daily allowance, counted here by local day
const ALLOWANCES = [0.1, 0.25, 1, 5];
/** how long you've been away before a character texts first: minutes, as the setting shows them */
const AWAYS = [[1, "a minute"], [5, "5 minutes"], [15, "15 minutes"], [30, "half an hour"], [60, "an hour"], [180, "3 hours"], [360, "6 hours"], [1440, "a day"]];
const backAfter = () => (S.ab.awayMin || 360) * 60e3;
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
const spentToday = () => A.spentToday(local.get(K.spent, null), localDay());
const canAfford = (usd) => A.canSpend(local.get(K.spent, null), localDay(), S.ab.allowance, usd);
@@ -138,6 +142,7 @@ function boot() {
S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) };
S.ab.clip = S.ab.clip && typeof S.ab.clip === "object" ? S.ab.clip : {};
if (!ALLOWANCES.includes(S.ab.allowance)) S.ab.allowance = 0.25;
if (!AWAYS.some(([m]) => m === S.ab.awayMin)) S.ab.awayMin = 360;
S.voiceOn = local.get(K.voiceOn, {}) || {};
S.jobs = (local.get(K.jobs, []) || []).filter((j) => j && j.queueId && j.model && Date.now() - (j.started || 0) < 23 * 3600e3);
pruneEmpty();
@@ -394,7 +399,7 @@ function setPane(which) {
const st = $("#studio");
st.classList.toggle("pane-chat", S.pane === "chat");
st.classList.toggle("pane-list", S.pane === "list");
if (S.pane === "chat") scrollLog(); // drawn while hidden, it couldn't scroll: now it can
if (S.pane === "chat") scrollLog(true); // drawn while hidden, it couldn't scroll: now it can
}
function renderLeft() {
for (const [w, tab, panel] of [["chats", "#lt-chats", "#lp-chats"], ["chars", "#lt-chars", "#lp-chars"], ["harbor", "#lt-harbor", "#lp-harbor"]]) {
@@ -652,15 +657,22 @@ function syncAdult(list) {
function renderRoster() {
const tb = $("#roster");
tb.textContent = "";
$("#harbor-empty").hidden = S.cards.length > 0;
$("#harbor .tablewrap").hidden = !S.cards.length;
$("#harbor-empty").hidden = true;
$("#harbor .tablewrap").hidden = false;
const cur = card();
// the first row is always someone new: the way back to a blank card from anyone's
const fresh = () => { S.fresh = true; syncNew(); $("#card").scrollIntoView({ behavior: motion(), block: "start" }); $("#nc-name").focus({ preventScroll: true }); };
tb.append(h("tr", { class: "opens newrow", onclick: (e) => { if (!e.target.closest("button, a")) fresh(); } },
h("th", { scope: "row" }, linkish("+ new character", fresh, { class: "linkish rowname" })),
h("td", { text: "write one here" }), h("td", { text: "" }),
h("td", { class: "files" }, linkish("import a card", () => $("#import").click()))));
queueMicrotask(syncNew);
for (const c of S.cards) {
const on = cur && c.id === cur.id;
const from = c.engine.venice_character ? "venice, as a link with credit" : c.lineage[0] ? C.creditLine(c.lineage[0]) : "written here";
const go = (tab) => () => { selectCard(c.id); openCard(tab); };
// the whole row opens their card; the links in it do what they say
tb.append(h("tr", { "aria-current": on ? "true" : null, class: "opens", onclick: (e) => { if (!e.target.closest("button, a")) go("edit")(); } },
tb.append(h("tr", { "data-id": c.id, class: "opens", onclick: (e) => { if (!e.target.closest("button, a")) go("edit")(); } },
h("th", { scope: "row" }, linkish(c.name, go("edit"), { class: "linkish rowname" }), c.adult ? h("span", { class: "x18", title: "for grown-ups only", text: "18+" }) : null,
on ? h("span", { class: "now", text: "talking" }) : null),
h("td", { text: from }),
@@ -694,7 +706,7 @@ function selectCard(id, { talk = false, scroll = false, open } = {}) {
if (was !== id) renderCard();
if (!talk) return;
setPane("chat");
if (scroll || narrow()) (narrow() ? $("#chat") : $("#studio")).scrollIntoView({ behavior: motion(), block: "start" });
if (scroll || narrow()) $("#studio").scrollIntoView({ behavior: motion(), block: "start" }); // on a phone: the tabs, then the chat under them
if (!narrow()) $("#say").focus({ preventScroll: true });
speakFirst();
}
@@ -803,7 +815,7 @@ function comeBack(t) {
if (T.byLatest(threadsOf(c.id))[0] !== t) return Promise.resolve(); // only in their latest chat with you
const go = () => {
const tried = local.get(K.tried, {}) || {};
if (!S.threads[t.id] || S.busy.has(t.id) || !T.readyToTextFirst(t.msgs, { now: Date.now(), after: A.BACK_AFTER, tried: tried[t.id] })) return;
if (!S.threads[t.id] || S.busy.has(t.id) || !T.readyToTextFirst(t.msgs, { now: Date.now(), after: backAfter(), tried: tried[t.id] })) return;
const kept = Object.fromEntries(Object.entries(tried).filter(([id]) => S.threads[id]));
if (!local.set(K.tried, { ...kept, [t.id]: Date.now() })) return; // can't write it down: don't risk asking twice
return send(A.comeBack(A.since(t.msgs, Date.now()), here(t)), { hidden: true, to: t, own: true });
@@ -818,9 +830,9 @@ function comeBack(t) {
async function comingBack() {
if (!S.key || !S.ab.own || !seen()) return;
comeBack(thread());
if (Date.now() - (Number(local.get(K.came, 0)) || 0) < A.BACK_AFTER) return;
if (Date.now() - (Number(local.get(K.came, 0)) || 0) < backAfter()) return;
const others = T.textFirst(Object.values(S.threads).filter((t) => t.card !== S.sel && cardOf(t)), (t) => t.msgs,
{ now: Date.now(), after: A.BACK_AFTER, tried: local.get(K.tried, {}) || {}, max: 3 });
{ now: Date.now(), after: backAfter(), tried: local.get(K.tried, {}) || {}, max: 3 });
if (!others.length || !local.set(K.came, Date.now())) return;
for (const t of others) {
await sleep(1500 + Math.random() * 2500);
@@ -902,9 +914,18 @@ function put(el) {
log.append(el);
return el;
}
/** to the latest message: now, after the next paint, and once more when late layout (a pane shown, a font) has settled */
function scrollLog() {
const l = $("#log"), go = () => { l.scrollTop = l.scrollHeight; };
/**
* To the latest message: now, after the next paint, and once more when late layout (a pane shown, a font) has settled.
* Only if you were at the end already: scrolled up to read, the chat stays where you are while they talk. force: it
* was you (you sent something, opened the chat), so to the end whatever.
*/
let pinned = true; // at the end of the chat, as of the last scroll
const nearEnd = (l) => l.scrollHeight - l.scrollTop - l.clientHeight < 160;
function scrollLog(force = false) {
const l = $("#log");
if (!force && !pinned) return;
const go = () => { l.scrollTop = l.scrollHeight; };
pinned = true;
go(); requestAnimationFrame(go); setTimeout(go, 250);
}
/** a photo or clip that loads after the chat is drawn pushes the bottom down: follow it, if you were at the bottom */
@@ -1095,7 +1116,7 @@ function renderLog() {
if (t && S.doing.has(t.id)) put(typingLine(t, S.doing.get(t.id)));
renderJobs();
followMedia(log);
scrollLog();
scrollLog(true);
}
/** a character texts in short bubbles; a plain chat answers in one piece */
@@ -1103,7 +1124,7 @@ const bubblesOf = (c, text) => (c.plain ? [C.stripThinking(text)].filter(Boolean
/** what the model reads: the conversation, with photos and voice notes as words (see abilities.js), and what they remember */
function messagesFor(c, hist) {
const api = A.apiHistory(hist);
return c.plain ? C.alternate(api.slice(-40).map(({ role, content }) => ({ role, content }))) : C.chatMessages(c, api, { memory: memoryFor(c) });
return c.plain ? C.alternate(api.slice(-40).map(({ role, content }) => ({ role, content }))) : C.chatMessages(c, api, { memory: memoryFor(c), can: canOf(c) });
}
// ---------- what they remember ----------
@@ -1116,6 +1137,8 @@ function saveMemory(id, m) {
}
/** what goes into their prompt: nothing with memory switched off; for a Venice character, beside Venice's own prompt */
const memoryFor = (c) => (S.ab.memory && c && !c.plain ? R.lines(R.select(memoryOf(c.id).notes)) : []);
/** what they can send in this chat, for their prompt (card.js canBlock): photos and clips need a key, "on their own", and a picture of them */
const canOf = (c) => (c && !c.plain ? { photos: !!S.key && S.ab.own !== false, pictured: refsOf(c).length > 0 } : null);
const remembering = new Set(), memoryMissed = new Map();
/**
* After every dozen messages in a chat (or a few, when you leave it), its character writes down what they remember now:
@@ -1206,7 +1229,7 @@ async function send(text, { hidden = false, media = null, picture = "", to = nul
saveThread(t);
if (here(t)) {
for (const el of $$("#log .suggest, #log .note")) el.remove();
if (!hidden) put(media ? mediaLine(c, said) : line(text, "me"));
if (!hidden) { put(media ? mediaLine(c, said) : line(text, "me")); scrollLog(true); }
}
const ctrl = new AbortController();
S.busy.set(t.id, ctrl);
@@ -1275,7 +1298,7 @@ async function send(text, { hidden = false, media = null, picture = "", to = nul
}
arrived(t, msg);
await play(t, bubblesOf(c, reply), ctrl.signal, c, msg, drawn);
if (!ctrl.signal.aborted && !demo && !c.plain && !hidden && !msg.voice) onTheirOwn(t);
if (!ctrl.signal.aborted && !demo && !c.plain && !hidden) onTheirOwn(t, { voiced: !!msg.voice });
} catch (e) {
if (e && e.name === "AbortError") return;
done(t);
@@ -1484,6 +1507,11 @@ function renderCard() {
function syncNew() {
const c = card(), real = !!c && !c.plain, fresh = S.left === "harbor" && (S.fresh || !real);
$("#card-new").hidden = !fresh;
// one row marked: the one whose card is open below
for (const tr of document.querySelectorAll("#roster tr")) {
const on = tr.classList.contains("newrow") ? fresh : !fresh && real && tr.dataset.id === c.id;
if (on) tr.setAttribute("aria-current", "true"); else tr.removeAttribute("aria-current");
}
$("#card-body").hidden = fresh || !real;
$("#card-empty").hidden = fresh || real;
$("#card-h").textContent = fresh ? "NEW CHARACTER" : real ? "CARD: " + c.name.toUpperCase() : "CARD";
@@ -1773,7 +1801,7 @@ function renderAbilities() {
allowance.addEventListener("change", () => { S.ab.allowance = Number(allowance.value); saveAb(); renderAbilities(); renderTools(); });
tb.append(
row("ON THEIR OWN", tick("characters text first when you come back, and send photos and voice notes when they like", S.ab.own, (on) => setOwn(on)),
["up to ", allowance, " a day · how often: ", tempoSelect(), ` · ${A.usd(spentToday())} spent today · clips are offered with venice's price, and made only if you say yes`]),
["up to ", allowance, " a day · how often: ", tempoSelect(), " · text first after you've been away: ", awaySelect(), ` · ${A.usd(spentToday())} spent today · clips are offered with venice's price, and made only if you say yes`]),
row("MEMORY", tick("characters remember your chats with them", S.ab.memory, (on) => {
S.ab.memory = on;
saveAb();
@@ -1825,7 +1853,7 @@ function askFor(kind) {
if (refsOf(c).length) writeShot();
if (narrow()) $("#ask").scrollIntoView({ behavior: motion(), block: "nearest" });
else $("#studio").scrollIntoView({ behavior: motion(), block: "start" });
scrollLog();
scrollLog(true);
}
function closeAsk() { if (S.ask && S.ask.busy) return; S.ask = null; renderAsk(); }
function renderAsk() {
@@ -2172,6 +2200,7 @@ function lightbox(src, alt) {
d.append(h("img", { src, alt: alt || "" }));
d.showModal();
}
$("#log").addEventListener("scroll", () => { pinned = nearEnd($("#log")); }, { passive: true });
document.addEventListener("click", (e) => {
const img = e.target && e.target.closest && e.target.closest(".log .pic img");
if (img && img.src) { e.preventDefault(); lightbox(img.src, img.alt); }
@@ -2262,6 +2291,7 @@ function runWorkflow(id, { door = "chat", idea = "", name = "" } = {}) {
if (id === "new") return writeFromIdea(idea, { door, name });
if (id === "photoshoot") return photoshoot(idea, { door });
if (id === "recall") return recall(idea);
if (id === "whisper") return whisper(idea);
}
/** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */
function threadFor(c) {
@@ -2375,6 +2405,96 @@ async function photoshoot(arg, { door = "chat" } = {}) {
balanceSoon();
}
}
/**
* /whisper: a short ASMR voice note. One call writes the script in their voice from the chat (and what it's about,
* if given); a whisper preset speaks it (W.WHISPER_PRESETS: luna on Inworld, eve on Grok, picked from the
* asmr.gandora.lol bake-off); it's mixed over soft rain made here, and lands in their chat as a voice note.
*/
let whispering = false;
async function whisper(arg) {
const c = card();
if (!c || c.plain) { err("/whisper needs a character: pick one in the harbor first."); return; }
if (whispering) { toast("A whisper is already on its way."); return; }
const { preset: named, brief } = W.parseWhisper(arg);
const key = named || (c.whisper && W.WHISPER_PRESETS[c.whisper] ? c.whisper : W.WHISPER_DEFAULT), p = W.WHISPER_PRESETS[key];
await loadList("tts");
const vm = (S.lists.tts || []).find((m) => m.id === p.model);
const guess = vm ? A.voicePrice(vm, 600) : null;
if (!confirm(`A whispered voice note from ${c.name} (${p.voice})${brief ? " about " + brief : ""}? About ${guess != null ? A.usd(guess) : "a cent"} on your Venice key, plus the words.`)) return;
const t = threadFor(c);
whispering = true;
S.inflight++;
doing(t, "is writing something to whisper…");
try {
const lines = t.msgs.filter((m) => !m.hidden && (m.role === "user" || m.role === "assistant")).slice(-10)
.map((m) => `${m.role === "user" ? "Them" : c.name}: ${String(m.content || "").replace(/\s+/g, " ").trim().slice(0, 300)}`);
const o = await askJson(await jsonModel(c), { messages: W.whisperMessages(c, { brief, lines }), schema: W.WHISPER_SCHEMA, maxTokens: 400, temperature: 0.9, what: "the whisper" });
const script = W.cleanWhisper(o);
if (!script) throw new Error("The model sent back nothing to whisper. Try again, or pick another model on the card.");
doing(t, "is whispering…");
const input = W.whisperInput(script, p);
const voice = await new Venice(S.key).speak({ model: p.model, voice: p.voice, input, speed: p.speed, format: p.format });
let blob = voice;
try { blob = await overRain(voice); } catch { /* the dry take is still a whisper */ }
const price = vm ? A.voicePrice(vm, input.length) : null;
done(t);
deliver(t, { role: "assistant", content: script, voice: { id: await M.put(blob), model: vm ? vm.name : p.model, voice: p.voice, price, whisper: true } });
if (named) { const now = S.cards.find((x) => x.id === c.id); if (now && now.whisper !== named) replaceCard({ ...now, whisper: named }); }
} catch (e) {
done(t);
err(e.message || String(e));
} finally {
whispering = false;
S.inflight--;
balanceSoon();
}
}
/** the voice, close-mic'd (a little low end, gentle compression), over soft rain made here; a wav back */
async function overRain(blob) {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const probe = new (window.AudioContext || window.webkitAudioContext)();
const v = await probe.decodeAudioData(await blob.arrayBuffer());
probe.close && probe.close();
const rate = 44100, pad = 1.5, len = Math.ceil((v.duration + pad * 2) * rate);
const ctx = new AC(2, len, rate);
// rain: pink-ish hiss with a slow flutter, over a low brown rumble
const noise = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = noise.getChannelData(ch); let b0 = 0, b1 = 0, b2 = 0, br = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.997 * b0 + 0.029 * w; b1 = 0.985 * b1 + 0.032 * w; b2 = 0.95 * b2 + 0.048 * w;
br = (br + 0.02 * w) / 1.02;
d[i] = (b0 + b1 + b2 + w * 0.05) * 0.6 + br * 2.5;
}
}
const rain = ctx.createBufferSource(); rain.buffer = noise;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 120;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 6500;
const rg = ctx.createGain(); const end = len / rate;
rg.gain.setValueAtTime(0, 0); rg.gain.linearRampToValueAtTime(0.09, 1.2); rg.gain.setValueAtTime(0.09, end - 1.2); rg.gain.linearRampToValueAtTime(0, end);
rain.connect(hp); hp.connect(lp); lp.connect(rg); rg.connect(ctx.destination);
const src = ctx.createBufferSource(); src.buffer = v;
const lo = ctx.createBiquadFilter(); lo.type = "lowshelf"; lo.frequency.value = 180; lo.gain.value = 4;
const cmp = ctx.createDynamicsCompressor(); cmp.threshold.value = -30; cmp.ratio.value = 4; cmp.attack.value = 0.01; cmp.release.value = 0.25;
const vg = ctx.createGain(); vg.gain.value = 1.6;
src.connect(lo); lo.connect(cmp); cmp.connect(vg); vg.connect(ctx.destination);
rain.start(0); src.start(pad);
return wavBlob(await ctx.startRendering());
}
/** an AudioBuffer as a 16-bit wav */
function wavBlob(buf) {
const n = buf.numberOfChannels, len = buf.length, out = new DataView(new ArrayBuffer(44 + len * n * 2));
const s = (o, t) => { for (let i = 0; i < t.length; i++) out.setUint8(o + i, t.charCodeAt(i)); };
s(0, "RIFF"); out.setUint32(4, 36 + len * n * 2, true); s(8, "WAVE"); s(12, "fmt "); out.setUint32(16, 16, true);
out.setUint16(20, 1, true); out.setUint16(22, n, true); out.setUint32(24, buf.sampleRate, true);
out.setUint32(28, buf.sampleRate * n * 2, true); out.setUint16(32, n * 2, true); out.setUint16(34, 16, true);
s(36, "data"); out.setUint32(40, len * n * 2, true);
const chans = []; for (let c = 0; c < n; c++) chans.push(buf.getChannelData(c));
let o = 44;
for (let i = 0; i < len; i++) for (let c = 0; c < n; c++) { const x = Math.max(-1, Math.min(1, chans[c][i])); out.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2; }
return new Blob([out], { type: "audio/wav" });
}
/**
* A photo of them from the chat, kept on their card: as their portrait (as = "portrait"; the look it was drawn from goes
* on the card too if it had none), or as one of the pictures their photos and clips are made from (as = "").
@@ -2531,26 +2651,41 @@ async function startClip(c, cm, shot, opts, price, where, { adult = false } = {}
* showing, or now and then), the character's model decides: a photo or a voice note, sent out of the day's
* allowance, or a clip, offered with Venice's price for you to take or leave.
*/
async function onTheirOwn(t) {
const noPicSaid = new Set(), moveSaid = new Set();
let allowanceSaid = "";
async function onTheirOwn(t, { voiced = false } = {}) { // voiced: the reply came with a voice note already, so not another
const c = cardOf(t);
if (!c || c.plain || !S.key || !S.ab.own || S.moving.has(t.id) || !A.moveLikely(t.msgs, { every: tempoEvery() })) return;
// the call that decides is paid for too: set aside its most, from what's left today, and settle after
const model = (S.models || []).find((m) => m.id === (c.model || S.model)) || null;
const messages = A.moveMessages(c, A.apiHistory(t.msgs), { adult: adultFor(c, t).ok }), most = A.callEstimate(messages, 300, model);
if (!reserve(most)) return;
const messages = A.moveMessages(c, A.apiHistory(t.msgs), { adult: adultFor(c, t).ok, eager: tempoEvery() === 1 }), most = A.callEstimate(messages, 300, model);
if (!reserve(most)) { // today's allowance is spent: said once a day, not every round
const day = localDay();
if (allowanceSaid !== day && here(t)) { allowanceSaid = day; err(`nothing on their own today: the allowance (${A.usd(S.ab.allowance)} a day, under abilities) is spent.`); }
return;
}
S.moving.add(t.id); // one move at a time in a chat
let held = most;
try {
const r = await new Venice(S.key).chat({ model: c.model || S.model, messages, schema: A.MOVE_SCHEMA, maxTokens: 300, temperature: 0.7 });
const r = await new Venice(S.key).chat({ model: await jsonModel(c), messages, schema: A.MOVE_SCHEMA, maxTokens: 300, temperature: 0.7 });
settle(held, A.callCost(r.usage, model));
held = 0;
if (!S.threads[t.id] || !cardOf(t)) return; // the chat, or they, went meanwhile
const { move, shot } = A.normalizeMove(C.parseJsonLoose(r.text));
let { move, shot } = A.normalizeMove(C.parseJsonLoose(r.text));
const lately = A.sentLately(t.msgs, tempoEvery() === 1 ? 1 : 4); // never two photos in a row
if (move === "photo" && !lately) await ownPhoto(t, shot);
else if (move === "voice") await ownVoice(t);
if (tempoEvery() === 1 && (move === "nothing" || (move === "voice" && voiced)) && !lately && refsOf(c).length) { // every round means every round: a photo, then
const sr = await new Venice(S.key).chat({ model: c.model || S.model, messages: A.shotMessages(c, A.apiHistory(t.msgs), "", "photo", { adult: adultFor(c, t).ok }), maxTokens: 260, temperature: 0.8 });
shot = A.cleanText(sr.text);
if (shot) move = "photo";
}
if ((move === "photo" || move === "clip") && !refsOf(c).length) { // nothing to make one from: say so, once per chat
if (!noPicSaid.has(t.id) && here(t)) { noPicSaid.add(t.id); err(`${c.name} wanted to send a ${move}, but has no picture yet to make one from: /portraits, or add one on their card (card → look).`); }
} else if (move === "photo" && !lately) await ownPhoto(t, shot);
else if (move === "voice" && !voiced) await ownVoice(t);
else if (move === "clip" && !lately) await offerClip(t, shot);
} catch { /* then they just text */ } finally { refund(held); S.moving.delete(t.id); }
} catch (e) { // then they just text; set to every round, the reason is shown once
if (tempoEvery() === 1 && !moveSaid.has(t.id) && here(t)) { moveSaid.add(t.id); err(`nothing on their own this time: ${e && e.message ? e.message : e}`); }
} finally { refund(held); S.moving.delete(t.id); }
}
/** something they started on their own that didn't come (a photo, a voice note): said in its chat, or in a word elsewhere */
function missed(t, what, e) {
@@ -2571,9 +2706,10 @@ async function ownPhoto(t, shot) {
const adult = adultFor(c, t, shot).ok; // an adult character's can be adult (abilities.js adultCheck)
const refs = refsOf(c), tried = [];
let pm = adult ? adultPhotoModel() : photoModel(), why = null;
if (!pm) why = new Error("no photo model is available (abilities → photos).");
while (pm && refs.length && tried.length < 2) { // no picture of them yet: nothing to send
const pics = refs.slice(0, Math.max(1, pm.maxIn)), price = A.photoPrice(pm, pics.length);
if (!reserve(price)) break; // set aside first: nothing else can spend it meanwhile
if (!reserve(price)) { why = new Error(`it doesn't fit in what's left of today's allowance (${A.usd(price)} for the photo; the allowance is under abilities).`); break; }
tried.push(pm.id);
doing(t, "is taking a photo…");
try {
@@ -2800,7 +2936,7 @@ async function hearVoice(blob, to) {
if (blob.size < 600) { toast("That voice note was too short to hear."); return; }
const id = await M.put(blob);
const note = put(line("hearing your voice note…", "havre", "typing"));
scrollLog();
scrollLog(true);
try {
await loadList("asr");
const hm = hearModel(), ext = /mp4/.test(blob.type) ? "m4a" : /ogg/.test(blob.type) ? "ogg" : "webm";
@@ -2837,7 +2973,7 @@ async function showPhoto(file) {
const sm = seeModel();
if (!sm) { err("No Venice model can see photos right now."); return; }
const note = put(line(`${sm.name} is looking at your photo…`, "havre", "typing"));
scrollLog();
scrollLog(true);
try {
const r = await new Venice(S.key).chat({ model: sm.id, maxTokens: 220, temperature: 0.2,
messages: [{ role: "user", content: [{ type: "text", text: A.SEE_PROMPT }, { type: "image_url", image_url: { url: pic } }] }] });
@@ -3008,6 +3144,12 @@ function setTempo(v) {
if ($("#abilitydlg").open) renderAbilities();
toast(`Photos and voice notes on their own: ${TEMPOS.find(([k]) => k === v)[1]}.`);
}
function awaySelect() {
const s = h("select", { "aria-label": "How long you've been away before they text first" }, AWAYS.map(([v, t]) => h("option", { value: String(v), text: t })));
s.value = String(S.ab.awayMin || 360);
s.addEventListener("change", () => { S.ab.awayMin = Number(s.value); saveAb(); toast(`They text first after you've been away ${AWAYS.find(([v]) => v === S.ab.awayMin)[1]}.`); });
return s;
}
function tempoSelect() {
const s = h("select", { "aria-label": "How often they send things on their own" }, TEMPOS.map(([v, t]) => h("option", { value: v, text: t })));
s.value = S.ab.tempo || "sometimes";
@@ -3223,9 +3365,50 @@ function forgetKey() {
}
// ---------- import ----------
/** the pictures and clips with these ids, as data: URLs (the ones this browser still has) */
async function mediaAsData(ids) {
const out = {};
for (const id of ids) {
const blob = await M.get(id);
if (!blob) continue;
out[id] = await new Promise((resolve) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => resolve(""); r.readAsDataURL(blob); });
if (!out[id]) delete out[id];
}
return out;
}
/**
* A bundle (one character with memory and chats, or everything) put back: cards not already here are added, notes are
* appended to what's remembered, chats by id are added if missing, pictures are put back by id. Never replaces.
*/
async function restoreBundle(bundle) {
const m = B.merge(bundle, { cards: S.cards, memory: local.get(K.memory, {}) || {}, threads: S.threads });
for (const c of m.addCards) addCard(c, { select: false });
for (const [id, mem] of Object.entries(m.memory)) if (S.cards.some((c) => c.id === id)) saveMemory(id, mem);
let pics = 0;
for (const [id, d] of Object.entries(bundle.media)) { try { await M.put(dataBlob(d), id); pics++; } catch { /* that one's gone */ } }
for (const t of m.addThreads) { if (S.cards.some((c) => c.id === t.card) || t.card === PLAIN) { S.threads[t.id] = t; saveThread(t); } }
if (bundle.settings) {
if (bundle.settings.abilities && typeof bundle.settings.abilities === "object") { S.ab = { ...S.ab, ...bundle.settings.abilities, clip: { ...S.ab.clip, ...(bundle.settings.abilities.clip || {}) } }; saveAb(); }
if (bundle.settings.voiceOn && typeof bundle.settings.voiceOn === "object") { S.voiceOn = { ...S.voiceOn, ...bundle.settings.voiceOn }; local.set(K.voiceOn, S.voiceOn); }
}
renderAll();
const bits = [`${m.addCards.length} character${m.addCards.length === 1 ? "" : "s"}`, `${m.addThreads.length} chat${m.addThreads.length === 1 ? "" : "s"}`, `${pics} picture${pics === 1 ? "" : "s"}`];
const skipped = [m.skippedCards && `${m.skippedCards} character${m.skippedCards === 1 ? "" : "s"}`, m.skippedThreads && `${m.skippedThreads} chat${m.skippedThreads === 1 ? "" : "s"}`].filter(Boolean);
toast(`Restored ${bits.join(", ")}${skipped.length ? "; already here: " + skipped.join(", ") : ""}.`);
}
async function importFile(file) {
const st = $("#impstatus");
try {
if (/\.json$/i.test(file.name) && file.size <= 200 * 1024 * 1024) {
let parsed = null;
try { parsed = JSON.parse(await file.text()); } catch { /* not a bundle, then */ }
const bundle = B.readBundle(parsed);
if (bundle) {
if ($("#importdlg").open) $("#importdlg").close();
await restoreBundle(bundle);
return;
}
}
if (file.size > 12 * 1024 * 1024) throw new Error("That file is too big for a character card.");
const bytes = new Uint8Array(await file.arrayBuffer());
const { card: c, kind } = C.fromAny(bytes);
@@ -3536,6 +3719,17 @@ async function exportAs(kind) {
else if (kind === "v3") download(C.fileName(c, "card.json"), JSON.stringify(C.toCharacterCardV3(abs), null, 1) + "\n", "application/json");
else if (kind === "prompt") download(C.fileName(c, "txt"), C.systemPrompt(c) + "\n", "text/plain");
else if (kind === "png") download(C.fileName(c, "png"), new Blob([C.toPngCard(await cardPng(c), abs)], { type: "image/png" }), "image/png");
else if (kind === "full" || kind === "all") {
if (c.plain && kind === "full") throw new Error("A plain chat has no card to export.");
const threads = Object.values(S.threads).map((t) => ({ ...t }));
const mine = kind === "all" ? threads : threads.filter((t) => t.card === c.id);
toast(`Gathering ${kind === "all" ? "everything" : c.name + "'s pictures"}…`);
const media = await mediaAsData(B.mediaIds(mine));
const bundle = kind === "all"
? B.bundleAll({ cards: S.cards.map(absolute), memory: local.get(K.memory, {}) || {}, threads, media, settings: { abilities: S.ab, model: S.model, voiceOn: S.voiceOn } })
: B.bundleOne(abs, { memory: memoryOf(c.id), threads: mine, media });
download(B.bundleName(kind === "all" ? null : c), JSON.stringify(bundle) + "\n", "application/json");
}
toast("Made in your browser. Nothing was uploaded.");
} catch (e) { toast(e.message); }
}
@@ -3672,6 +3866,12 @@ function bind() {
$("#vprev").addEventListener("click", () => loadVenice(Math.max(0, S.v.offset - PAGE), { paging: true }));
$("#vnext").addEventListener("click", () => loadVenice(S.v.offset + PAGE, { paging: true }));
$("#card-fresh").addEventListener("click", () => {
S.fresh = true;
if (S.left !== "harbor") setLeft("harbor"); else syncNew();
$("#card").scrollIntoView({ behavior: motion(), block: "start" });
$("#nc-name").focus({ preventScroll: true });
});
$("#new").addEventListener("click", () => {
addCard({ name: "New character", persona: "", greeting: "", voice: { short_form: true }, model: S.model, license: "remix-with-credit" });
openCard("edit");
@@ -3687,6 +3887,7 @@ function bind() {
openCard("edit");
});
$("#nc-import").addEventListener("click", () => $("#import").click());
$("#restore").addEventListener("click", () => $("#import").click());
$("#import").addEventListener("click", () => { $("#impstatus").textContent = ""; $("#impstatus").classList.remove("bad"); $("#importdlg").showModal(); });
$("#impcancel").addEventListener("click", () => $("#importdlg").close());
$("#imp-paste").addEventListener("click", importText);
+77
View File
@@ -0,0 +1,77 @@
// backup.js: a character with everything this browser keeps about them, or the whole harbor, as one file, and back.
// Pure functions, no DOM: the page imports this module, and so do the tests.
//
// a bundle { havre_backup: 1, at, cards: [card], memory: { <card id>: memory }, threads: [thread + msgs],
// media: { <id>: data: URL }, settings?: { abilities, model, voiceOn } }
//
// A card file (.havre.json) carries the character and nothing else, by design: it's the thing you share. A bundle
// is the thing you keep: the card, what they remember, your chats with them and the pictures in those chats. "Full"
// is every character, every chat, every picture, and your settings (never your key). Restoring merges: a card already
// in the harbor is left as it is, notes are appended, chats by id are added if missing, pictures by id are put back.
import { normalizeCard } from "./card.js";
import { readMemory, appended } from "./memory.js";
export const BACKUP = 1;
const str = (v, max = 20000) => (typeof v === "string" ? v : v == null ? "" : String(v)).slice(0, max);
const arr = (v) => (Array.isArray(v) ? v : []);
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
/** the media ids a thread's messages point at */
export function mediaIds(threads) {
const ids = new Set();
for (const t of arr(threads)) for (const m of arr(t.msgs)) if (m && m.media && typeof m.media.id === "string" && m.media.id) ids.add(m.media.id);
return [...ids];
}
/** one character's bundle: the card, their memory, the chats with them (with messages), the pictures those chats hold */
export function bundleOne(card, { memory = null, threads = [], media = {}, at = Date.now() } = {}) {
const mine = arr(threads).filter((t) => t && t.card === card.id);
const ids = new Set(mediaIds(mine));
return { havre_backup: BACKUP, at, cards: [card], memory: memory ? { [card.id]: memory } : {}, threads: mine,
media: Object.fromEntries(Object.entries(obj(media)).filter(([id]) => ids.has(id))) };
}
/** the whole harbor: every card, memory, chat and picture, and the settings worth carrying (never the key) */
export function bundleAll({ cards = [], memory = {}, threads = [], media = {}, settings = null, at = Date.now() } = {}) {
return { havre_backup: BACKUP, at, cards: arr(cards), memory: obj(memory), threads: arr(threads), media: obj(media),
...(settings ? { settings } : {}) };
}
/** a bundle as read from a file, made safe; null if the text isn't one */
export function readBundle(o) {
if (!o || typeof o !== "object" || Number(o.havre_backup) !== BACKUP) return null;
const cards = arr(o.cards).filter((c) => c && typeof c === "object" && c.name).map((c) => normalizeCard(c));
const memory = {};
for (const [id, m] of Object.entries(obj(o.memory))) { const r = readMemory(m); if (r.notes.length || Object.keys(r.upTo).length) memory[str(id, 80)] = r; }
const threads = arr(o.threads).filter((t) => t && typeof t === "object" && typeof t.id === "string" && typeof t.card === "string")
.map((t) => ({ ...t, id: str(t.id, 80), card: str(t.card, 80), msgs: arr(t.msgs).filter((m) => m && typeof m === "object" && (m.role === "user" || m.role === "assistant")) }));
const media = {};
for (const [id, d] of Object.entries(obj(o.media))) if (typeof d === "string" && /^data:(image|audio|video)\//.test(d) && d.length < 20_000_000) media[str(id, 80)] = d;
const settings = obj(o.settings);
return { at: Number(o.at) || 0, cards, memory, threads, media, settings: Object.keys(settings).length ? settings : null };
}
/**
* What restoring a bundle into a harbor comes to, without touching anything: cards to add (not already there, by
* id), memories merged (notes appended to what's there), threads to add (by id, not already there). Pictures are
* put back as they are. The counts say what happened.
*/
export function merge(bundle, { cards = [], memory = {}, threads = {} } = {}) {
const have = new Set(arr(cards).map((c) => c.id));
const addCards = bundle.cards.filter((c) => !have.has(c.id));
const mergedMemory = {};
for (const [id, m] of Object.entries(bundle.memory)) {
const now = readMemory(obj(memory)[id]);
mergedMemory[id] = { notes: appended(now.notes, m.notes) || now.notes, upTo: { ...m.upTo, ...now.upTo }, at: Math.max(now.at, m.at) };
}
const addThreads = bundle.threads.filter((t) => !obj(threads)[t.id]);
return { addCards, memory: mergedMemory, addThreads, skippedCards: bundle.cards.length - addCards.length, skippedThreads: bundle.threads.length - addThreads.length };
}
/** a file name for a bundle: one character's, or the harbor's, with the day */
export function bundleName(card, at = Date.now()) {
const day = new Date(at).toISOString().slice(0, 10);
return card ? `${card.id}.havre-full.json` : `havre-backup-${day}.json`;
}
+21 -5
View File
@@ -172,7 +172,20 @@ const quoted = (s, max = 600) => `"${str(s).replace(/\s+/g, " ").trim().slice(0,
* The system prompt: who they are, how they talk (with their examples, which teach the voice), their flow, what they
* remember of your chats (memory: notes from memory.js, never part of the card), their name, and the house rules.
*/
export function systemPrompt(card, { memory = [] } = {}) {
/**
* What they can send in this chat, for the prompt: photos and clips (the app makes them from their pictures when they
* decide to), not yet (no picture on their card), or not at all (no key, or switched off). "" when nothing is known.
*/
export function canBlock(can) {
if (!can || typeof can !== "object") return "";
if (!can.photos) return "In this chat you can't send pictures, clips or voice notes: if they ask for one, say so plainly, in your own voice.";
if (!can.pictured) return "You could send photos of yourself here, but there's no picture of you on your card yet. If they ask for one, say so: they can " +
"make your portrait on your card (or type /portraits in this chat), and after that you can send photos.";
return "In this chat you can send photos of yourself and short clips: when you decide to, they are made from your pictures and arrive " +
"with your message. Decide the way a person would, and never say you can't send pictures. Never describe a photo you didn't send.";
}
export function systemPrompt(card, { memory = [], can = null } = {}) {
const parts = [`You are ${card.name}.${card.tagline ? " " + card.tagline : ""}`];
if (card.persona) parts.push(fill(card.persona, card));
const voice = [card.voice.notes && fill(card.voice.notes, card), card.voice.short_form && SHORT_FORM].filter(Boolean);
@@ -189,6 +202,8 @@ export function systemPrompt(card, { memory = [] } = {}) {
}
const mem = memoryBlock(memory);
if (mem) parts.push(mem);
const able = canBlock(can);
if (able) parts.push(able);
if (card.adult) parts.push(ADULT_RULE);
parts.push(identityRule(card), HOUSE_RULES);
return parts.join("\n\n");
@@ -206,11 +221,12 @@ export function memoryBlock(memory = []) {
* of Havre's own (not a Venice character, who brings their own prompt), the last thing said ends with a reminder of
* who is answering. memory: what they remember, for the system prompt.
*/
export function chatMessages(card, history, { memory = [] } = {}) {
export function chatMessages(card, history, { memory = [], can = null } = {}) {
const own = !card.engine.venice_character;
// a Venice character brings its own prompt: beside it go only what they remember and, if they're adult, the 18+ rule
const extra = own ? "" : [memoryBlock(memory), card.adult ? ADULT_RULE : ""].filter(Boolean).join("\n\n");
const msgs = own ? [{ role: "system", content: systemPrompt(card, { memory }) }] : extra ? [{ role: "system", content: extra }] : [];
// a Venice character brings its own prompt: beside it go only what they remember, what they can send here and, if
// they're adult, the 18+ rule
const extra = own ? "" : [memoryBlock(memory), canBlock(can), card.adult ? ADULT_RULE : ""].filter(Boolean).join("\n\n");
const msgs = own ? [{ role: "system", content: systemPrompt(card, { memory, can }) }] : extra ? [{ role: "system", content: extra }] : [];
// the greeting starts the conversation, so it goes once the conversation's start is out of the window
if (card.greeting && own && history.length <= 40) msgs.push({ role: "assistant", content: fill(card.greeting, card) });
for (const m of history.slice(-40)) if (m.role === "user" || m.role === "assistant") msgs.push({ role: m.role, content: str(m.content, 8000) });
+6 -3
View File
@@ -23,6 +23,7 @@
<link rel="alternate" type="text/markdown" href="skill.md" title="Skill">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
<script src="update.js" defer></script>
</head>
<body>
<a class="skip" href="#chat">skip to the chat</a>
@@ -30,7 +31,7 @@
<header class="mast" id="top">
<h1>HAVRE</h1>
<p class="motto">a harbor for characters. <br>talk to venice characters on your own key. <br>no account. no server. nothing kept but what you keep.</p>
<nav class="nav" aria-label="On this page"><a href="#how">how it works</a> / <a href="#agents">for agents</a> / <a href="#costs">costs</a> / <a href="#plans">early access</a> / <a href="open/">open studio (beta)</a> / <button type="button" class="linkish" id="theme">dark mode</button></nav>
<nav class="nav" aria-label="Havre"><a href="open/">studio</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
</header>
<form class="keybar" id="keyform" autocomplete="off" aria-label="Your Venice API key">
@@ -123,7 +124,7 @@
<section class="block card" id="card" aria-labelledby="card-h">
<div class="sechead">
<h2 id="card-h">CARD</h2>
<span class="meta">the character as a file: edit it, remix it, export it.</span>
<span class="meta">the character as a file: edit it, remix it, export it. / <button type="button" class="linkish" id="card-fresh">new character</button></span>
</div>
<p id="card-empty" class="mono pad">no character yet. the card shows up here once someone is in your harbor.</p>
<form id="card-new" class="pad newcard" autocomplete="off" hidden>
@@ -247,8 +248,10 @@
<li><button type="button" class="out" data-out="v3"><b>CHARACTER CARD V3</b><span>.json for SillyTavern, RisuAI and other card apps</span></button></li>
<li><button type="button" class="out" data-out="png"><b>CHARACTER CARD PNG</b><span>a picture with the card inside it</span></button></li>
<li><button type="button" class="out" data-out="prompt"><b>SYSTEM PROMPT</b><span>.txt for any OpenAI-compatible API</span></button></li>
<li><button type="button" class="out" data-out="full"><b>WITH MEMORY AND CHATS</b><span>.havre-full.json: the card, what they remember, your chats with them, the pictures in them</span></button></li>
<li><button type="button" class="out" data-out="all"><b>EVERYTHING</b><span>havre-backup.json: every character, memory, chat and picture, and your settings (never your key)</span></button></li>
</ul>
<p class="mono">files are made here, in your browser. nothing is uploaded.</p>
<p class="mono">files are made here, in your browser. nothing is uploaded. a .havre-full or backup file restores through <button type="button" class="linkish" id="restore">import a card</button>: what's already here stays, the rest is added.</p>
</div>
</div>
</section>
@@ -0,0 +1 @@
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:"Inter", "InterVariable", system-ui, sans-serif;--font-mono:"JetBrains Mono", "SF Mono", monospace;--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-3xl:48rem;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--leading-loose:2;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg-base:#0a0a0c;--color-bg-raised:#111114;--color-text-primary:rgba(var(--fg), .92);--color-accent:#6ee7d3;--color-accent-soft:#6ee7d329}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:Line truncated
@@ -1,4 +1,4 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/workflows-view-7tPx_mLe.js","assets/workflow-engine-lSJFuHzZ.js","assets/workflow-engine-BZV40eAE.css","assets/playground-view-urTIiexa.js"])))=>i.map(i=>d[i]);
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/workflows-view-C9DNDVch.js","assets/workflow-engine-BuXGtBGn.js","assets/workflow-engine-BZV40eAE.css","assets/playground-view-B6WRI_IZ.js"])))=>i.map(i=>d[i]);
(function(){const l=document.createElement("link").relList;if(l&&l.supports&&l.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))u(s);new MutationObserver(s=>{for(const c of s)if(c.type==="childList")for(const f of c.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&u(f)}).observe(document,{childList:!0,subtree:!0});function a(s){const c={};return s.integrity&&(c.integrity=s.integrity),s.referrerPolicy&&(c.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?c.credentials="include":s.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function u(s){if(s.ep)return;s.ep=!0;const c=a(s);fetch(s.href,c)}})();function lf(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var sc={exports:{}},Ha={};var Tp;function N1(){if(Tp)return Ha;Tp=1;var n=Symbol.for("react.transitional.element"),l=Symbol.for("react.fragment");function a(u,s,c){var f=null;if(c!==void 0&&(f=""+c),s.key!==void 0&&(f=""+s.key),"key"in s){c={};for(var d in s)d!=="key"&&(c[d]=s[d])}else c=s;return s=c.ref,{$$typeof:n,type:u,key:f,ref:s!==void 0?s:null,props:c}}return Ha.Fragment=l,Ha.jsx=a,Ha.jsxs=a,Ha}var Ap;function O1(){return Ap||(Ap=1,sc.exports=N1()),sc.exports}var p=O1(),oc={exports:{}},we={};var _p;function z1(){if(_p)return we;_p=1;var n=Symbol.for("react.transitional.element"),l=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),f=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),m=Symbol.for("react.memo"),x=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),b=Symbol.iterator;function w(_){return _===null||typeof _!="object"?null:(_=b&&_[b]||_["@@iterator"],typeof _=="function"?_:null)}var k={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},N=Object.assign,A={};function E(_,Y,C){this.props=_,this.context=Y,this.refs=A,this.updater=C||k}E.prototype.isReactComponent={},E.prototype.setState=function(_,Y){if(typeof _!="object"&&typeof _!="function"&&_!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,_,Y,"setState")},E.prototype.forceUpdate=function(_){this.updater.enqueueForceUpdate(this,_,"forceUpdate")};function O(){}O.prototype=E.prototype;function z(_,Y,C){this.props=_,this.context=Y,this.refs=A,this.updater=C||k}var X=z.prototype=new O;X.constructor=z,N(X,E.prototype),X.isPureReactComponent=!0;var V=Array.isArray;function R(){}var ee={H:null,A:null,T:null,S:null},W=Object.prototype.hasOwnProperty;function ue(_,Y,C){var le=C.ref;return{$$typeof:n,type:_,key:Y,ref:le!==void 0?le:null,props:C}}function U(_,Y){return ue(_.type,Y,_.props)}function ne(_){return typeof _=="object"&&_!==null&&_.$$typeof===n}function ie(_){var Y={"=":"=0",":":"=2"};return"$"+_.replace(/[=:]/g,function(C){return Y[C]})}var me=/\/+/g;function re(_,Y){return typeof _=="object"&&_!==null&&_.key!=null?ie(""+_.key):Y.toString(36)}function $(_){switch(_.status){case"fulfilled":return _.value;case"rejected":throw _.reason;default:switch(typeof _.status=="string"?_.then(R,R):(_.status="pending",_.then(function(Y){_.status==="pending"&&(_.status="fulfilled",_.value=Y)},function(Y){_.status==="pending"&&(_.status="rejected",_.reason=Y)})),_.status){case"fulfilled":return _.value;case"rejected":throw _.reason}}throw _}function L(_,Y,C,le,de){var oe=typeof _;(oe==="undefined"||oe==="boolean")&&(_=null);var Se=!1;if(_===null)Se=!0;else switch(oe){case"bigint":case"string":case"number":Se=!0;break;case"object":switch(_.$$typeof){case n:case l:Se=!0;break;case x:return Se=_._init,L(Se(_._payload),Y,C,le,de)}}if(Se)return de=de(_),Se=le===""?"."+re(_,0):le,V(de)?(C="",Se!=null&&(C=Se.replace(me,"$&/")+"/"),L(de,Y,C,"",function($e){return $e})):de!=null&&(ne(de)&&(de=U(de,C+(de.key==null||_&&_.key===de.key?"":(""+de.key).replace(me,"$&/")+"/")+Se)),Y.push(de)),1;Se=0;var fe=le===""?".":le+":";if(V(_))for(var ve=0;ve<_.length;ve++)le=_[ve],oe=fe+re(le,ve),Se+=L(le,Y,C,oe,de);else if(ve=w(_),typeof ve=="function")for(_=ve.call(_),ve=0;!(le=_.next()).done;)le=le.value,oe=fe+re(le,ve++),Se+=L(le,Y,C,oe,de);else if(oe==="object"){if(typeof _.then=="function")return L($(_),Y,C,le,de);throw Y=String(_),Error("Objects are not valid as a React child (found: "+(Y==="[object Object]"?"object with keys {"+Object.keys(_).join(", ")+"}":Y)+"). If you meant to render a collection of children, use an array instead.")}return Se}function P(_,Y,C){if(_==null)return _;var le=[],de=0;return L(_,le,"","",function(oe){return Y.call(C,oe,de++)}),le}function F(_){if(_._status===-1){var Y=_._result;Y=Y(),Y.then(function(C){(_._status===0||_._status===-1)&&(_._status=1,_._result=C)},function(C){(_._status===0||_._status===-1)&&(_._status=2,Line truncated
at`)?" (<anonymous>)":-1<i.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Ht+e+Tt}var In=!1;function be(e,t){if(!e||In)return"";In=!0;var i=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var r={DetermineComponentFrameRoot:function(){try{if(t){var J=function(){throw Error()};if(Object.defineProperty(J.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(J,[])}catch(G){var Q=G}Reflect.construct(e,[],J)}else{try{J.call()}catch(G){Q=G}e.call(J.prototype)}}else{try{throw Error()}catch(G){Q=G}(J=e())&&typeof J.catch=="function"&&J.catch(function(){})}}catch(G){if(G&&Q&&typeof G.stack=="string")return[G.stack,Q.stack]}return[null,null]}};r.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var o=Object.getOwnPropertyDescriptor(r.DetermineComponentFrameRoot,"name");o&&o.configurable&&Object.defineProperty(r.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var h=r.DetermineComponentFrameRoot(),v=h[0],S=h[1];if(v&&S){var T=v.split(`
@@ -49,4 +49,4 @@ Error generating stack: `+r.message+`
`))}function g(w,k,N,A){const E=N.enter("tableCell"),O=N.enter("phrasing"),z=N.containerPhrasing(w,{...A,before:c,after:c});return O(),E(),z}function m(w,k){return hC(w,{align:k,alignDelimiters:u,padding:a,stringLength:s})}function x(w,k,N){const A=w.children;let E=-1;const O=[],z=k.enter("table");for(;++E<A.length;)O[E]=y(A[E],k,N);return z(),O}function y(w,k,N){const A=w.children;let E=-1;const O=[],z=k.enter("tableRow");for(;++E<A.length;)O[E]=g(A[E],w,k,N);return z(),O}function b(w,k,N){let A=rx.inlineCode(w,k,N);return N.stack.includes("tableCell")&&(A=A.replace(/\|/g,"\\$&")),A}}function WC(){return{exit:{taskListCheckValueChecked:qg,taskListCheckValueUnchecked:qg,paragraph:tE}}}function eE(){return{unsafe:[{atBreak:!0,character:"-",after:"[:|-]"}],handlers:{listItem:nE}}}function qg(n){const l=this.stack[this.stack.length-2];l.type,l.checked=n.type==="taskListCheckValueChecked"}function tE(n){const l=this.stack[this.stack.length-2];if(l&&l.type==="listItem"&&typeof l.checked=="boolean"){const a=this.stack[this.stack.length-1];a.type;const u=a.children[0];if(u&&u.type==="text"){const s=l.children;let c=-1,f;for(;++c<s.length;){const d=s[c];if(d.type==="paragraph"){f=d;break}}f===a&&(u.value=u.value.slice(1),u.value.length===0?a.children.shift():a.position&&u.position&&typeof u.position.start.offset=="number"&&(u.position.start.column++,u.position.start.offset++,a.position.start=Object.assign({},u.position.start)))}}this.exit(n)}function nE(n,l,a,u){const s=n.children[0],c=typeof n.checked=="boolean"&&s&&s.type==="paragraph",f="["+(n.checked?"x":" ")+"] ",d=a.createTracker(u);c&&d.move(f);let g=rx.listItem(n,l,a,{...u,...d.current()});return c&&(g=g.replace(/^(?:[*+-]|\d+\.)([\r\n]| {1,3})/,m)),g;function m(x){return x+f}}function iE(){return[Rk(),nC(),rC(),IC(),WC()]}function lE(n){return{extensions:[Lk(),iC(n),uC(),$C(n),eE()]}}const aE={tokenize:fE,partial:!0},ux={tokenize:hE,partial:!0},sx={tokenize:dE,partial:!0},ox={tokenize:mE,partial:!0},rE={tokenize:pE,partial:!0},cx={name:"wwwAutolink",tokenize:oE,previous:hx},fx={name:"protocolAutolink",tokenize:cE,previous:dx},Yn={name:"emailAutolink",tokenize:sE,previous:mx},kn={};function uE(){return{text:kn}}let Vi=48;for(;Vi<123;)kn[Vi]=Yn,Vi++,Vi===58?Vi=65:Vi===91&&(Vi=97);kn[43]=Yn;kn[45]=Yn;kn[46]=Yn;kn[95]=Yn;kn[72]=[Yn,fx];kn[104]=[Yn,fx];kn[87]=[Yn,cx];kn[119]=[Yn,cx];function sE(n,l,a){const u=this;let s,c;return f;function f(y){return!nf(y)||!mx.call(u,u.previous)||Mf(u.events)?a(y):(n.enter("literalAutolink"),n.enter("literalAutolinkEmail"),d(y))}function d(y){return nf(y)?(n.consume(y),d):y===64?(n.consume(y),g):a(y)}function g(y){return y===46?n.check(rE,x,m)(y):y===45||y===95||bt(y)?(c=!0,n.consume(y),g):x(y)}function m(y){return n.consume(y),s=!0,g}function x(y){return c&&s&&jt(u.previous)?(n.exit("literalAutolinkEmail"),n.exit("literalAutolink"),l(y)):a(y)}}function oE(n,l,a){const u=this;return s;function s(f){return f!==87&&f!==119||!hx.call(u,u.previous)||Mf(u.events)?a(f):(n.enter("literalAutolink"),n.enter("literalAutolinkWww"),n.check(aE,n.attempt(ux,n.attempt(sx,c),a),a)(f))}function c(f){return n.exit("literalAutolinkWww"),n.exit("literalAutolink"),l(f)}}function cE(n,l,a){const u=this;let s="",c=!1;return f;function f(y){return(y===72||y===104)&&dx.call(u,u.previous)&&!Mf(u.events)?(n.enter("literalAutolink"),n.enter("literalAutolinkHttp"),s+=String.fromCodePoint(y),n.consume(y),d):a(y)}function d(y){if(jt(y)&&s.length<5)return s+=String.fromCodePoint(y),n.consume(y),d;if(y===58){const b=s.toLowerCase();if(b==="http"||b==="https")return n.consume(y),g}return a(y)}function g(y){return y===47?(n.consume(y),c?m:(c=!0,g)):a(y)}function m(y){return y===null||Du(y)||Xe(y)||Zi(y)||Qu(y)?a(y):n.attempt(ux,n.attempt(sx,x),a)(y)}function x(y){return n.exit("literalAutolinkHttp"),n.exit("literalAutolink"),l(y)}}function fE(n,l,a){let u=0;return s;function s(f){return(f===87||f===119)&&u<3?(u++,n.consume(f),s):f===46&&u===3?(n.consume(f),c):a(f)}function c(f){return f===null?a(f):l(f)}}function hE(n,l,a){let u,s,c;return f;function f(m){return m===46||m===95?n.check(ox,g,d)(m):m===null||Xe(m)||Zi(m)||m!==45&&Qu(m)?g(m):(c=!0,n.consume(m),f)}function d(m){return m===95?u=!0:(s=u,u=void 0),n.consume(m),f}function g(m){return s||u||!c?a(m):l(m)}}function dE(n,l){let a=0,u=0;return s;function s(f){return f===40?(a++,n.consume(f),s):f===41&&u<a?c(f):f===33||f===34||f===38||f===39||f===41||f===42||f===44||f===46||f===58||f===59||f===60||f===63||f===93||f===95||f===126?n.check(ox,l,c)(f):f===null||Xe(f)||Zi(f)?l(f):(n.consume(f),s)}function c(f){return f===41&&u++,n.consume(f),s}}function mE(n,l,a){return u;function u(d){return d===33||d===34||d===39||d===41||d===42||d===44||d===46||d===58||d===59||d===63||d===95||d===126?(n.consume(d),u):d===38?(n.consume(d),c):d===93?(n.consume(d),s):d===60||d===null||Xe(d)||Zi(d)?l(d):a(d)}function s(d){return d===null||d===40||d===91||Xe(d)||Zi(d)?l(d):u(d)}function c(d){return jt(d)?f(d):a(d)}function f(d){re Line truncated
`)?p.jsx("code",{className:l,...a,children:n}):p.jsxs("div",{className:"relative group/code",children:[s&&p.jsx("div",{className:"absolute top-0 left-0 px-3 py-1.5 text-[13px] text-white/15 font-mono uppercase tracking-wider select-none",children:s}),p.jsx("button",{onClick:()=>{navigator.clipboard.writeText(c),d(!0),setTimeout(()=>d(!1),1500)},className:"absolute top-1.5 right-1.5 px-2 py-1 text-[13px] font-medium text-white/15 hover:text-white/40 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all opacity-0 group-hover/code:opacity-100",children:f?"Copied":"Copy"}),p.jsx("code",{className:l,...a,children:n})]})}function VE(n){if(typeof n=="string")return{text:n,images:[]};let l="";const a=[];for(const u of n)u.type==="text"&&u.text&&(l+=u.text),u.type==="image_url"&&u.image_url?.url&&a.push(u.image_url.url);return{text:l,images:a}}function GE({message:n,onCopy:l,onDelete:a,onRegenerate:u}){const[s,c]=q.useState(!1),[f,d]=q.useState(!1),[g,m]=q.useState(!1),x=n.role==="user",{text:y,images:b}=VE(n.content),w=()=>{navigator.clipboard.writeText(y),d(!0),l(),setTimeout(()=>d(!1),1500)},k=p.jsxs("div",{className:`flex items-center gap-0.5 h-6 transition-opacity duration-150 ${s?"opacity-100":"opacity-0"}`,children:[p.jsx(zc,{label:f?"Copied":"Copy",onClick:w,children:f?p.jsx("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:p.jsx("path",{d:"M20 6L9 17l-5-5"})}):p.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[p.jsx("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2"}),p.jsx("path",{d:"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"})]})}),!x&&u&&p.jsx(zc,{label:"Regenerate",onClick:u,children:p.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[p.jsx("path",{d:"M1 4v6h6"}),p.jsx("path",{d:"M3.51 15a9 9 0 102.13-9.36L1 10"})]})}),p.jsx(zc,{label:"Delete",onClick:a,children:p.jsx("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:p.jsx("path",{d:"M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"})})})]});return x?p.jsx("div",{className:"flex justify-end",onMouseEnter:()=>c(!0),onMouseLeave:()=>c(!1),children:p.jsxs("div",{className:"flex items-end gap-1.5 max-w-[78%]",children:[k,p.jsxs("div",{className:"bg-white/[0.07] border border-white/[0.05] rounded-2xl rounded-br-md px-4 py-2.5 shadow-sm",children:[b.length>0&&p.jsx("div",{className:"flex gap-1.5 mb-2",children:b.map((N,A)=>p.jsx("img",{src:N,alt:`Attachment ${A+1}`,className:"h-24 rounded-lg border border-white/[0.06]"},A))}),p.jsx("div",{className:"text-white/95 text-[15.5px] leading-relaxed whitespace-pre-wrap break-words",children:y})]})]})}):p.jsxs("div",{className:"flex gap-3",onMouseEnter:()=>c(!0),onMouseLeave:()=>c(!1),children:[p.jsx("div",{className:"w-8 h-8 rounded-lg bg-gradient-to-br from-white/95 to-white/75 flex items-center justify-center shrink-0 mt-0.5 shadow-sm",children:p.jsx("svg",{viewBox:"0 0 32 32",width:"14",height:"14",fill:"none",children:p.jsxs("g",{fill:"#0a0a0c",children:[p.jsx("rect",{x:"6.2",y:"7.5",width:"1.6",height:"18",rx:"0.8",transform:"rotate(-42 6.2 7.5)"}),p.jsx("rect",{x:"24.2",y:"6.3",width:"1.6",height:"18",rx:"0.8",transform:"rotate(42 24.2 6.3)"}),p.jsx("polygon",{points:"7.2,8.8 3.8,7.2 4.5,5.5 8.5,7.2"}),p.jsx("polygon",{points:"24.8,8.8 28.2,7.2 27.5,5.5 23.5,7.2"}),p.jsx("rect",{x:"14.3",y:"14.3",width:"3.4",height:"3.4",rx:"0.4",transform:"rotate(45 16 16)"}),p.jsx("circle",{cx:"9.2",cy:"24.5",r:"4"}),p.jsx("circle",{cx:"9.2",cy:"24.5",r:"1.7",fill:"#fff"}),p.jsx("circle",{cx:"22.8",cy:"24.5",r:"4"}),p.jsx("circle",{cx:"22.8",cy:"24.5",r:"1.7",fill:"#fff"}),p.jsx("path",{d:"M16 5.5L12.5 8.5V12.5L16 10.5L19.5 12.5V8.5Z"})]})})}),p.jsxs("div",{className:"min-w-0 flex-1",children:[n.reasoning_content&&p.jsxs("div",{className:"mb-2",children:[p.jsxs("button",{onClick:()=>m(!g),className:"flex items-center gap-1.5 text-[14px] text-white/20 hover:text-white/35 transition-colors mb-1",children:[p.jsx("svg",{width:"10",height:"10",viewBox:"0 0 10 10",fill:"none",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",className:Te("transition-transform duration-150",g&&"rotate-90"),children:p.jsx("path",{d:"M3.5 2L6.5 5L3.5 8"})}),"Thinking"]}),g&&p.jsx("div",{className:"bg-white/[0.02] border border-white/[0.04] rounded-lg px-3 py-2 text-[15px] text-white/30 leading-relaxed whitespace-pre-wrap animate-fade-in max-h-60 overflow-y-auto",children:n.reasoning_content})]}),y?p.jsx("div",{className:"prose-venice text-[15.5px] leading-relaxed text-white/85",children:p.jsx(jk,{remarkPlugins:[BE],urlTransform:qE,components:{code:QE,a:({href:N,childre Line truncated
${n.stack}`:""]})]}),p.jsxs("div",{className:"flex gap-2 justify-center",children:[p.jsx("button",{onClick:l,className:"px-4 py-2 text-[14px] font-medium bg-white text-black rounded-md hover:bg-white/90 transition-colors",children:"Try again"}),p.jsx("button",{onClick:()=>window.location.reload(),className:"px-4 py-2 text-[14px] font-medium border border-white/[0.1] text-white/60 hover:text-white/80 hover:border-white/[0.2] rounded-md transition-colors",children:"Reload page"})]})]})})}const E5={info:"border-white/[0.08] bg-[#111]",success:"border-green-500/30 bg-green-500/[0.06]",error:"border-red-500/30 bg-red-500/[0.06]"},j5={info:"text-white/75",success:"text-green-200/85",error:"text-red-200/85"};function T5(){const n=Ql(a=>a.toasts),l=Ql(a=>a.dismiss);return n.length===0?null:p.jsx("div",{className:"fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm pointer-events-none",role:"region","aria-live":"polite","aria-label":"Notifications",children:n.map(a=>p.jsx("div",{role:a.variant==="error"?"alert":"status",className:Te("pointer-events-auto rounded-lg border px-3.5 py-2.5 shadow-xl shadow-black/60 backdrop-blur-md animate-scale-in",E5[a.variant]),children:p.jsxs("div",{className:"flex items-start gap-3",children:[p.jsxs("div",{className:"flex-1 min-w-0",children:[p.jsx("div",{className:Te("text-[13.5px] font-medium",j5[a.variant]),children:a.title}),a.description&&p.jsx("div",{className:"text-[12.5px] text-white/45 mt-0.5 leading-relaxed break-words",children:a.description}),a.action&&p.jsx("button",{onClick:()=>{a.action?.onClick(),l(a.id)},className:"mt-1.5 text-[12.5px] font-medium text-white/70 hover:text-white underline underline-offset-2",children:a.action.label})]}),p.jsx("button",{onClick:()=>l(a.id),"aria-label":"Dismiss notification",className:"text-white/25 hover:text-white/60 transition-colors p-0.5 -m-0.5 shrink-0 rounded focus-visible:outline focus-visible:outline-1 focus-visible:outline-white/30",children:p.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",children:[p.jsx("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),p.jsx("line",{x1:"6",y1:"6",x2:"18",y2:"18"})]})})]})},a.id))})}const A5=q.lazy(()=>ay(()=>import("./workflows-view-7tPx_mLe.js"),__vite__mapDeps([0,1,2])).then(n=>({default:n.WorkflowsView})));function _5(){return p.jsx(q.Suspense,{fallback:p.jsx("div",{className:"flex items-center justify-center h-full text-[12px] text-white/30",children:"Loading workflows…"}),children:p.jsx(A5,{})})}const M5=q.lazy(()=>ay(()=>import("./playground-view-urTIiexa.js"),__vite__mapDeps([3,1,2])).then(n=>({default:n.PlaygroundView})));function N5(){return p.jsx(q.Suspense,{fallback:p.jsx("div",{className:"flex items-center justify-center h-full text-[12px] text-white/30",children:"Loading playground…"}),children:p.jsx(M5,{})})}const O5={chat:XE,image:l5,audio:c5,music:m5,video:v5,embeddings:k5,workflows:_5,playground:N5},Ig=["chat","image","audio","music","video","embeddings","workflows","playground"];function z5(){const n=gn(g=>g.hasEncrypted&&!g.apiKey),[l,a]=q.useState(n),[u,s]=q.useState(!1),c=on(g=>g.activeTab),f=on(g=>g.setActiveTab),d=O5[c];return q.useEffect(()=>{const g=m=>{if(!(m.metaKey||m.ctrlKey))return;if(m.key==="n"){m.preventDefault(),f("chat"),s(!1),xt.getState().setActiveConversation(null);return}const y=parseInt(m.key,10);if(y>=1&&y<=Ig.length){m.preventDefault(),f(Ig[y-1]),s(!1);return}};return window.addEventListener("keydown",g),()=>window.removeEventListener("keydown",g)},[f]),p.jsxs("div",{className:"flex h-[100dvh] w-screen overflow-hidden",children:[u&&p.jsx("button",{"aria-label":"Close menu",className:"md:hidden fixed inset-0 z-30 bg-black/60 backdrop-blur-sm animate-fade-in",onClick:()=>s(!1)}),p.jsx(Kv,{mobileOpen:u,onMobileClose:()=>s(!1)}),p.jsxs("div",{className:"flex flex-col flex-1 min-w-0",children:[p.jsx(fb,{onOpenApiKey:()=>a(!0),onOpenMobileSidebar:()=>s(!0)}),p.jsx("main",{className:"flex-1 min-h-0 overflow-hidden",children:p.jsx(gx,{children:p.jsx(d,{})},c)})]}),p.jsx(db,{open:l,onClose:()=>a(!1)}),p.jsx(T5,{})]})}const D5=new cv({defaultOptions:{queries:{retry:1,refetchOnWindowFocus:!1}}});window.addEventListener("unhandledrejection",n=>{console.error("[unhandledrejection]",n.reason)});q1.createRoot(document.getElementById("root")).render(p.jsx(q.StrictMode,{children:p.jsx(gx,{children:p.jsx(fv,{client:D5,children:p.jsx(z5,{})})})}));export{on as A,af as B,lf as C,U1 as D,qu as E,oy as F,nv as Q,vu as R,Gn as S,Fl as a,Kg as b,Te as c,ny as d,hv as e,pv as f,Nv as g,vv as h,gv as i,p as j,yv as k,Et as l,wv as m,ct as n,Xp as o,xv as p,Bu as q,q as r,Mu as s,Fi as t,Si as u,ry as v,ff as w,uy as x,Ji as y,gn as z};
${n.stack}`:""]})]}),p.jsxs("div",{className:"flex gap-2 justify-center",children:[p.jsx("button",{onClick:l,className:"px-4 py-2 text-[14px] font-medium bg-white text-black rounded-md hover:bg-white/90 transition-colors",children:"Try again"}),p.jsx("button",{onClick:()=>window.location.reload(),className:"px-4 py-2 text-[14px] font-medium border border-white/[0.1] text-white/60 hover:text-white/80 hover:border-white/[0.2] rounded-md transition-colors",children:"Reload page"})]})]})})}const E5={info:"border-white/[0.08] bg-[#111]",success:"border-green-500/30 bg-green-500/[0.06]",error:"border-red-500/30 bg-red-500/[0.06]"},j5={info:"text-white/75",success:"text-green-200/85",error:"text-red-200/85"};function T5(){const n=Ql(a=>a.toasts),l=Ql(a=>a.dismiss);return n.length===0?null:p.jsx("div",{className:"fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm pointer-events-none",role:"region","aria-live":"polite","aria-label":"Notifications",children:n.map(a=>p.jsx("div",{role:a.variant==="error"?"alert":"status",className:Te("pointer-events-auto rounded-lg border px-3.5 py-2.5 shadow-xl shadow-black/60 backdrop-blur-md animate-scale-in",E5[a.variant]),children:p.jsxs("div",{className:"flex items-start gap-3",children:[p.jsxs("div",{className:"flex-1 min-w-0",children:[p.jsx("div",{className:Te("text-[13.5px] font-medium",j5[a.variant]),children:a.title}),a.description&&p.jsx("div",{className:"text-[12.5px] text-white/45 mt-0.5 leading-relaxed break-words",children:a.description}),a.action&&p.jsx("button",{onClick:()=>{a.action?.onClick(),l(a.id)},className:"mt-1.5 text-[12.5px] font-medium text-white/70 hover:text-white underline underline-offset-2",children:a.action.label})]}),p.jsx("button",{onClick:()=>l(a.id),"aria-label":"Dismiss notification",className:"text-white/25 hover:text-white/60 transition-colors p-0.5 -m-0.5 shrink-0 rounded focus-visible:outline focus-visible:outline-1 focus-visible:outline-white/30",children:p.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",children:[p.jsx("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),p.jsx("line",{x1:"6",y1:"6",x2:"18",y2:"18"})]})})]})},a.id))})}const A5=q.lazy(()=>ay(()=>import("./workflows-view-C9DNDVch.js"),__vite__mapDeps([0,1,2])).then(n=>({default:n.WorkflowsView})));function _5(){return p.jsx(q.Suspense,{fallback:p.jsx("div",{className:"flex items-center justify-center h-full text-[12px] text-white/30",children:"Loading workflows…"}),children:p.jsx(A5,{})})}const M5=q.lazy(()=>ay(()=>import("./playground-view-B6WRI_IZ.js"),__vite__mapDeps([3,1,2])).then(n=>({default:n.PlaygroundView})));function N5(){return p.jsx(q.Suspense,{fallback:p.jsx("div",{className:"flex items-center justify-center h-full text-[12px] text-white/30",children:"Loading playground…"}),children:p.jsx(M5,{})})}const O5={chat:XE,image:l5,audio:c5,music:m5,video:v5,embeddings:k5,workflows:_5,playground:N5},Ig=["chat","image","audio","music","video","embeddings","workflows","playground"];function z5(){const n=gn(g=>g.hasEncrypted&&!g.apiKey),[l,a]=q.useState(n),[u,s]=q.useState(!1),c=on(g=>g.activeTab),f=on(g=>g.setActiveTab),d=O5[c];return q.useEffect(()=>{const g=m=>{if(!(m.metaKey||m.ctrlKey))return;if(m.key==="n"){m.preventDefault(),f("chat"),s(!1),xt.getState().setActiveConversation(null);return}const y=parseInt(m.key,10);if(y>=1&&y<=Ig.length){m.preventDefault(),f(Ig[y-1]),s(!1);return}};return window.addEventListener("keydown",g),()=>window.removeEventListener("keydown",g)},[f]),p.jsxs("div",{className:"flex h-[100dvh] w-screen overflow-hidden",children:[u&&p.jsx("button",{"aria-label":"Close menu",className:"md:hidden fixed inset-0 z-30 bg-black/60 backdrop-blur-sm animate-fade-in",onClick:()=>s(!1)}),p.jsx(Kv,{mobileOpen:u,onMobileClose:()=>s(!1)}),p.jsxs("div",{className:"flex flex-col flex-1 min-w-0",children:[p.jsx(fb,{onOpenApiKey:()=>a(!0),onOpenMobileSidebar:()=>s(!0)}),p.jsx("main",{className:"flex-1 min-h-0 overflow-hidden",children:p.jsx(gx,{children:p.jsx(d,{})},c)})]}),p.jsx(db,{open:l,onClose:()=>a(!1)}),p.jsx(T5,{})]})}const D5=new cv({defaultOptions:{queries:{retry:1,refetchOnWindowFocus:!1}}});window.addEventListener("unhandledrejection",n=>{console.error("[unhandledrejection]",n.reason)});q1.createRoot(document.getElementById("root")).render(p.jsx(q.StrictMode,{children:p.jsx(gx,{children:p.jsx(fv,{client:D5,children:p.jsx(z5,{})})})}));export{on as A,af as B,lf as C,U1 as D,qu as E,oy as F,nv as Q,vu as R,Gn as S,Fl as a,Kg as b,Te as c,ny as d,hv as e,pv as f,Nv as g,vv as h,gv as i,p as j,yv as k,Et as l,wv as m,ct as n,Xp as o,xv as p,Bu as q,q as r,Mu as s,Fi as t,Si as u,ry as v,ff as w,uy as x,Ji as y,gn as z};
@@ -1 +0,0 @@
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:"Inter", "InterVariable", system-ui, sans-serif;--font-mono:"JetBrains Mono", "SF Mono", monospace;--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-3xl:48rem;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--leading-loose:2;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg-base:#0a0a0c;--color-bg-raised:#111114;--color-text-primary:rgba(var(--fg), .92);--color-accent:#6ee7d3;--color-accent-soft:#6ee7d329}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:Line truncated
@@ -1,4 +1,4 @@
import{a as ne,n as D,s as re,b as oe,Q as Z,d as ie,e as ae,f as le,r as b,h as ce,i as de,k as ue,l as pe,m as B,o as he,p as me,q as fe,v as ge,w as xe,x as we,y as F,u as be,g as U,z as ye,A as V,j as r,c as R,t as Q}from"./index-CmWoX_gW.js";import{g as ve,N as O,h as ke,H as J,P as z,R as je,b as Ne,c as Se,a as _e,i as Re,B as $e,e as Te,C as Ce,u as L,v as Oe,f as Ee}from"./workflow-engine-lSJFuHzZ.js";function H(e,t){const n=new Set(t);return e.filter(s=>!n.has(s))}function Pe(e,t,n){const s=e.slice(0);return s[t]=n,s}var Me=class extends ne{#n;#e;#r;#o;#t;#s;#i;#a;#l;#c=[];constructor(e,t,n){super(),this.#n=e,this.#o=n,this.#r=[],this.#t=[],this.#e=[],this.setQueries(t)}onSubscribe(){this.listeners.size===1&&this.#t.forEach(e=>{e.subscribe(t=>{this.#h(e,t)})})}onUnsubscribe(){this.listeners.size||this.destroy()}destroy(){this.listeners=new Set,this.#t.forEach(e=>{e.destroy()})}setQueries(e,t){this.#r=e,this.#o=t,D.batch(()=>{const n=this.#t,s=this.#p(this.#r);s.forEach(p=>p.observer.setOptions(p.defaultedQueryOptions));const o=s.map(p=>p.observer),i=o.map(p=>p.getCurrentResult()),a=n.length!==o.length,l=o.some((p,h)=>p!==n[h]),d=a||l,x=d?!0:i.some((p,h)=>{const w=this.#e[h];return!w||!re(p,w)});!d&&!x||(d&&(this.#c=s,this.#t=o),this.#e=i,this.hasListeners()&&(d&&(H(n,o).forEach(p=>{p.destroy()}),H(o,n).forEach(p=>{p.subscribe(h=>{this.#h(p,h)})})),this.#m()))})}getCurrentResult(){return this.#e}getQueries(){return this.#t.map(e=>e.getCurrentQuery())}getObservers(){return this.#t}getOptimisticResult(e,t){const n=this.#p(e),s=n.map(i=>i.observer.getOptimisticResult(i.defaultedQueryOptions)),o=n.map(i=>i.defaultedQueryOptions.queryHash);return[s,i=>this.#u(i??s,t,o),()=>this.#d(s,n)]}#d(e,t){return t.map((n,s)=>{const o=e[s];return n.defaultedQueryOptions.notifyOnChangeProps?o:n.observer.trackResult(o,i=>{t.forEach(a=>{a.observer.trackProp(i)})})})}#u(e,t,n){if(t){const s=this.#l,o=n!==void 0&&s!==void 0&&(s.length!==n.length||n.some((i,a)=>i!==s[a]));return(!this.#s||this.#e!==this.#a||o||t!==this.#i)&&(this.#i=t,this.#a=this.#e,n!==void 0&&(this.#l=n),this.#s=oe(this.#s,t(e))),this.#s}return e}#p(e){const t=new Map;this.#t.forEach(s=>{const o=s.options.queryHash;if(!o)return;const i=t.get(o);i?i.push(s):t.set(o,[s])});const n=[];return e.forEach(s=>{const o=this.#n.defaultQueryOptions(s),a=t.get(o.queryHash)?.shift()??new Z(this.#n,o);n.push({defaultedQueryOptions:o,observer:a})}),n}#h(e,t){const n=this.#t.indexOf(e);n!==-1&&(this.#e=Pe(this.#e,n,t),this.#m())}#m(){if(this.hasListeners()){const e=this.#s,t=this.#d(this.#e,this.#c),n=this.#u(t,this.#o?.combine);e!==n&&D.batch(()=>{this.listeners.forEach(s=>{s(this.#e)})})}}};function Ae({queries:e,...t},n){const s=ie(),o=ae(),i=le(),a=b.useMemo(()=>e.map(m=>{const u=s.defaultQueryOptions(m);return u._optimisticResults=o?"isRestoring":"optimistic",u}),[e,s,o]);a.forEach(m=>{ce(m);const u=s.getQueryCache().get(m.queryHash);de(m,i,u)}),ue(i);const[l]=b.useState(()=>new Me(s,a,t)),[d,x,p]=l.getOptimisticResult(a,t.combine),h=!o&&t.subscribed!==!1;b.useSyncExternalStore(b.useCallback(m=>h?l.subscribe(D.batchCalls(m)):pe,[l,h]),()=>l.getCurrentResult(),()=>l.getCurrentResult()),b.useEffect(()=>{l.setQueries(a,t)},[a,t,l]);const y=d.some((m,u)=>B(a[u],m))?d.flatMap((m,u)=>{const g=a[u];if(g&&B(g,m)){const N=new Z(s,g);return he(g,N,i)}return[]}):[];if(y.length>0)throw Promise.all(y);const $=d.find((m,u)=>{const g=a[u];return g&&me({result:m,errorResetBoundary:i,throwOnError:g.throwOnError,query:s.getQueryCache().get(g.queryHash),suspense:g.suspense})});if($?.error)throw $.error;return x(p())}const S=fe()(ge((e,t)=>({messages:[],draft:{nodes:[],edges:[]},linkedWorkflowId:null,isThinking:!1,runResults:{},isRunning:!1,addMessage:n=>e(s=>({messages:[...s.messages,n]})),updateMessage:(n,s)=>e(o=>({messages:o.messages.map(i=>i.id===n?{...i,...s}:i)})),setThinking:n=>e({isThinking:n}),applyAgentPatches:n=>{const{nodes:s,edges:o}=t().draft,i=ve({nodes:s,edges:o},n);return e({draft:{nodes:i.nodes,edges:i.edges},runResults:{}}),i},resetDraft:()=>e({draft:{nodes:[],edges:[]},runResults:{},linkedWorkflowId:null}),clearConversation:()=>e({messages:[],draft:{nodes:[],edges:[]},runResults:{},linkedWorkflowId:null}),setRunResults:n=>e({runResults:n}),updateRunNode:(n,s)=>e(o=>({runResults:{...o.runResults,[n]:{...o.runResults[n],...s}}})),setIsRunning:n=>e({isRunning:n}),clearResults:()=>e({runResults:{}}),loadWorkflow:(n,s,o)=>e({draft:{nodes:s,edges:o},linkedWorkflowId:n,runResults:{},messages:[]}),unlinkWorkflow:()=>e({linkedWorkflowId:null})}),{name:"venice-playground",version:1,storage:xe(()=>we()),partialize:e=>({messages:e.messages.slice(-40),draft:e.draft,linkedWorkflowId:e.linkedWorkflowId})})),We=["text","image","tts","music","video"];function q(e){return e?e.data.filter(t=>!t.model_spec?.offline).map(t=>t.id).sort():[]}function qe(){const e=Ae({queries:We.map(s=>({queryKey:["models",s],queryFn:()=>F(`/models?type=${s}`,{noAuth:!0}),staleTime:3e5}))}),t={text:qLine truncated
import{a as ne,n as D,s as re,b as oe,Q as Z,d as ie,e as ae,f as le,r as b,h as ce,i as de,k as ue,l as pe,m as B,o as he,p as me,q as fe,v as ge,w as xe,x as we,y as F,u as be,g as U,z as ye,A as V,j as r,c as R,t as Q}from"./index-fmJ2VqHZ.js";import{g as ve,N as O,h as ke,H as J,P as z,R as je,b as Ne,c as Se,a as _e,i as Re,B as $e,e as Te,C as Ce,u as L,v as Oe,f as Ee}from"./workflow-engine-BuXGtBGn.js";function H(e,t){const n=new Set(t);return e.filter(s=>!n.has(s))}function Pe(e,t,n){const s=e.slice(0);return s[t]=n,s}var Me=class extends ne{#n;#e;#r;#o;#t;#s;#i;#a;#l;#c=[];constructor(e,t,n){super(),this.#n=e,this.#o=n,this.#r=[],this.#t=[],this.#e=[],this.setQueries(t)}onSubscribe(){this.listeners.size===1&&this.#t.forEach(e=>{e.subscribe(t=>{this.#h(e,t)})})}onUnsubscribe(){this.listeners.size||this.destroy()}destroy(){this.listeners=new Set,this.#t.forEach(e=>{e.destroy()})}setQueries(e,t){this.#r=e,this.#o=t,D.batch(()=>{const n=this.#t,s=this.#p(this.#r);s.forEach(p=>p.observer.setOptions(p.defaultedQueryOptions));const o=s.map(p=>p.observer),i=o.map(p=>p.getCurrentResult()),a=n.length!==o.length,l=o.some((p,h)=>p!==n[h]),d=a||l,x=d?!0:i.some((p,h)=>{const w=this.#e[h];return!w||!re(p,w)});!d&&!x||(d&&(this.#c=s,this.#t=o),this.#e=i,this.hasListeners()&&(d&&(H(n,o).forEach(p=>{p.destroy()}),H(o,n).forEach(p=>{p.subscribe(h=>{this.#h(p,h)})})),this.#m()))})}getCurrentResult(){return this.#e}getQueries(){return this.#t.map(e=>e.getCurrentQuery())}getObservers(){return this.#t}getOptimisticResult(e,t){const n=this.#p(e),s=n.map(i=>i.observer.getOptimisticResult(i.defaultedQueryOptions)),o=n.map(i=>i.defaultedQueryOptions.queryHash);return[s,i=>this.#u(i??s,t,o),()=>this.#d(s,n)]}#d(e,t){return t.map((n,s)=>{const o=e[s];return n.defaultedQueryOptions.notifyOnChangeProps?o:n.observer.trackResult(o,i=>{t.forEach(a=>{a.observer.trackProp(i)})})})}#u(e,t,n){if(t){const s=this.#l,o=n!==void 0&&s!==void 0&&(s.length!==n.length||n.some((i,a)=>i!==s[a]));return(!this.#s||this.#e!==this.#a||o||t!==this.#i)&&(this.#i=t,this.#a=this.#e,n!==void 0&&(this.#l=n),this.#s=oe(this.#s,t(e))),this.#s}return e}#p(e){const t=new Map;this.#t.forEach(s=>{const o=s.options.queryHash;if(!o)return;const i=t.get(o);i?i.push(s):t.set(o,[s])});const n=[];return e.forEach(s=>{const o=this.#n.defaultQueryOptions(s),a=t.get(o.queryHash)?.shift()??new Z(this.#n,o);n.push({defaultedQueryOptions:o,observer:a})}),n}#h(e,t){const n=this.#t.indexOf(e);n!==-1&&(this.#e=Pe(this.#e,n,t),this.#m())}#m(){if(this.hasListeners()){const e=this.#s,t=this.#d(this.#e,this.#c),n=this.#u(t,this.#o?.combine);e!==n&&D.batch(()=>{this.listeners.forEach(s=>{s(this.#e)})})}}};function Ae({queries:e,...t},n){const s=ie(),o=ae(),i=le(),a=b.useMemo(()=>e.map(m=>{const u=s.defaultQueryOptions(m);return u._optimisticResults=o?"isRestoring":"optimistic",u}),[e,s,o]);a.forEach(m=>{ce(m);const u=s.getQueryCache().get(m.queryHash);de(m,i,u)}),ue(i);const[l]=b.useState(()=>new Me(s,a,t)),[d,x,p]=l.getOptimisticResult(a,t.combine),h=!o&&t.subscribed!==!1;b.useSyncExternalStore(b.useCallback(m=>h?l.subscribe(D.batchCalls(m)):pe,[l,h]),()=>l.getCurrentResult(),()=>l.getCurrentResult()),b.useEffect(()=>{l.setQueries(a,t)},[a,t,l]);const y=d.some((m,u)=>B(a[u],m))?d.flatMap((m,u)=>{const g=a[u];if(g&&B(g,m)){const N=new Z(s,g);return he(g,N,i)}return[]}):[];if(y.length>0)throw Promise.all(y);const $=d.find((m,u)=>{const g=a[u];return g&&me({result:m,errorResetBoundary:i,throwOnError:g.throwOnError,query:s.getQueryCache().get(g.queryHash),suspense:g.suspense})});if($?.error)throw $.error;return x(p())}const S=fe()(ge((e,t)=>({messages:[],draft:{nodes:[],edges:[]},linkedWorkflowId:null,isThinking:!1,runResults:{},isRunning:!1,addMessage:n=>e(s=>({messages:[...s.messages,n]})),updateMessage:(n,s)=>e(o=>({messages:o.messages.map(i=>i.id===n?{...i,...s}:i)})),setThinking:n=>e({isThinking:n}),applyAgentPatches:n=>{const{nodes:s,edges:o}=t().draft,i=ve({nodes:s,edges:o},n);return e({draft:{nodes:i.nodes,edges:i.edges},runResults:{}}),i},resetDraft:()=>e({draft:{nodes:[],edges:[]},runResults:{},linkedWorkflowId:null}),clearConversation:()=>e({messages:[],draft:{nodes:[],edges:[]},runResults:{},linkedWorkflowId:null}),setRunResults:n=>e({runResults:n}),updateRunNode:(n,s)=>e(o=>({runResults:{...o.runResults,[n]:{...o.runResults[n],...s}}})),setIsRunning:n=>e({isRunning:n}),clearResults:()=>e({runResults:{}}),loadWorkflow:(n,s,o)=>e({draft:{nodes:s,edges:o},linkedWorkflowId:n,runResults:{},messages:[]}),unlinkWorkflow:()=>e({linkedWorkflowId:null})}),{name:"venice-playground",version:1,storage:xe(()=>we()),partialize:e=>({messages:e.messages.slice(-40),draft:e.draft,linkedWorkflowId:e.linkedWorkflowId})})),We=["text","image","tts","music","video"];function q(e){return e?e.data.filter(t=>!t.model_spec?.offline).map(t=>t.id).sort():[]}function qe(){const e=Ae({queries:We.map(s=>({queryKey:["models",s],queryFn:()=>F(`/models?type=${s}`,{noAuth:!0}),staleTime:3e5}))}),t={text:qLine truncated
`);return`- ${e.type} (${e.label}) | input=${e.input} output=${e.output}
${e.description}${t?`
params:
@@ -80,7 +80,7 @@ ${t}
Edges:
${n}`}function st(e,t,n){const s=n.catalog;return s?e==="chat"?s.text.includes(t):e==="imageGen"?s.image.includes(t):e==="tts"?s.tts.includes(t):e==="music"?s.music.includes(t):e==="video"?s.video.includes(t):!0:!0}function nt(e,t,n){const s=n.catalog;if(!s)return;if(e==="chat"){const i=n.agentModels??[];if(i.length===0)return s.text[0];const a=i.map(l=>{let d=0;const x=l.capabilities;return t==="web"&&(d+=x.supportsWebSearch?100:0),t==="reasoning"&&(d+=x.supportsReasoning?100:0),t==="uncensored"&&(d+=l.uncensored?100:0),t==="best"&&(l.traits.includes("most_intelligent")&&(d+=80),l.contextTokens&&l.contextTokens>=2e5&&(d+=20)),(t==="fast"||!t)&&(x.supportsReasoning&&(d-=30),l.traits.includes("function_calling_default")&&(d+=50),l.recommended&&(d+=30)),x.supportsResponseSchema&&(d+=10),d-=l.tier*5,{id:l.id,score:d}});return a.sort((l,d)=>d.score-l.score),a[0]?.id}return(e==="imageGen"?s.image:e==="tts"?s.tts:e==="music"?s.music:e==="video"?s.video:[])[0]}function rt(e){if(!e)return{};try{const t=JSON.parse(e);return typeof t=="object"&&t!==null?t:{}}catch{return{}}}function ot(e,t,n){try{switch(e){case"clear":return n.applyPatch({op:"clear"}),{ok:!0};case"add_node":{const s=t.node_type;if(!s||!(s in O))return{error:`Unknown node_type: ${String(s)}`};const o=typeof t.id=="string"&&t.id||U(),i=typeof t.params=="object"&&t.params!==null?t.params:{},a=i.model;if(typeof a=="string"&&a.length>0&&!st(s,a,n))return{error:`Unknown model '${a}' for ${s}. Call pick_model({node_type:'${s==="chat"?"chat":s}'}) to get a valid id, then retry add_node with that id.`};const l=O[s],d=new Set(["model","prompt",...l.params.map(h=>h.name)]),x={};for(const[h,w]of Object.entries(i))d.has(h)&&(x[h]=w);for(const h of l.params)if(h.enumValues&&x[h.name]!==void 0){const w=x[h.name];if(typeof w=="boolean"){x[h.name]=w?h.enumValues.includes("on")?"on":h.enumValues[0]:h.enumValues.includes("off")?"off":"";continue}if(typeof w!="string"||!h.enumValues.includes(w))return{error:`Invalid value for ${s}.${h.name}: ${JSON.stringify(w)}. Must be one of [${h.enumValues.filter(Boolean).join(", ")}].`}}const p=n.applyPatch({op:"add_node",nodeType:s,id:o,params:x});return"error"in p?{error:p.error}:{ok:!0,id:p.id??o}}case"connect":{const s=String(t.source??""),o=String(t.target??"");if(!s||!o)return{error:"Both source and target are required."};const i=n.applyPatch({op:"connect",source:s,target:o});return"error"in i?{error:i.error}:{ok:!0,edge_id:i.edge_id}}case"set_params":{const s=String(t.id??""),o=typeof t.params=="object"&&t.params!==null?t.params:{},i=n.applyPatch({op:"set_params",id:s,params:o});return"error"in i?{error:i.error}:{ok:!0}}case"remove_node":{const s=String(t.id??""),o=n.applyPatch({op:"remove_node",id:s});return"error"in o?{error:o.error}:{ok:!0}}case"pick_model":{const s=String(t.node_type??""),o=typeof t.prefer=="string"?t.prefer:void 0,i=nt(s,o,n);return i?{ok:!0,model:i}:{error:`No model available for ${s}`}}case"ask_user":return{ok:!0,message:String(t.question??"")};case"done":return{ok:!0,message:String(t.summary??"Done.")}}return{error:`Unknown tool: ${e}`}}catch(s){return{error:s instanceof Error?s.message:"Tool failed"}}}async function it(e){const t=[{role:"system",content:et},...e.history.map(o=>o.role==="user"?{role:"user",content:o.content}:{role:"assistant",content:o.content}),{role:"user",content:`${tt(e.draft)}
User request: ${e.userMessage}`}];let n=0,s=!1;for(let o=0;o<K;o++){if(e.signal?.aborted)throw new DOMException("Aborted","AbortError");const a=(await F("/chat/completions",{method:"POST",body:JSON.stringify({model:e.model,messages:t,temperature:.2,max_tokens:4096,tools:Xe,tool_choice:"auto"}),signal:e.signal})).choices[0]?.message;if(!a)return{say:"Empty response from agent.",tool_calls:n,asked_user:!1};t.push({role:"assistant",content:a.content??null,tool_calls:a.tool_calls});const l=a.tool_calls??[];if(l.length===0)return{say:(a.content||"").trim()||`Made ${n} edit${n===1?"":"s"}.`,tool_calls:n,asked_user:!1};let d=null,x=!1;for(const p of l){const h=p.function?.name??"",w=rt(p.function?.arguments),y=ot(h,w,e);n++,e.onStep?.({tool:h,args:w,result:y}),t.push({role:"tool",tool_call_id:p.id,content:JSON.stringify(y)}),h==="done"?d=y.message??"Done.":h==="ask_user"&&(d=y.message??"Need clarification.",x=!0)}if(d!==null)return s=x,{say:d,tool_calls:n,asked_user:s}}return{say:`Stopped after ${K} iterations and ${n} edits — let me know if it needs adjusting.`,tool_calls:n,asked_user:!1}}const at=["Build a workflow that makes a 9:16 reel using web search to research the topic, then writes a script and generates the video","Research a topic with web search, summarize into 5 bullets, and narrate it as audio","Turn a concept into a vivid image prompt, then generate the image","Write a short song with lyrics, then generate it as music","Make a cover image and a music track from one idea, then combine them into one video where the cover shows while the track plays"];function lt(e){const t=!("error"in e.result)||!e.result.error,n=e.args;switch(e.tool){case"clear":return{tool:e.tool,summary:"Cleared canvas",ok:t};case"add_node":{const s=e.result.id,o=String(n.node_type??"?");return{tool:e.tool,summary:t?`Added ${o}${s?` "${s}"`:""}`:`Failed to add ${o}: ${e.result.error}`,ok:t}}case"connect":{const s=String(n.source??""),o=String(n.target??"");return{tool:e.tool,summary:t?`Connected ${s} → ${o}`:`Connect failed (${s} → ${o}): ${e.result.error}`,ok:t}}case"set_params":return{tool:e.tool,summary:t?`Updated params on ${String(n.id??"")}`:`set_params failed: ${e.result.error}`,ok:t};case"remove_node":return{tool:e.tool,summary:t?`Removed ${String(n.id??"")}`:`remove failed: ${e.result.error}`,ok:t};case"pick_model":{const s=e.result.model;return{tool:e.tool,summary:t?`Picked ${s} for ${String(n.node_type??"")}`:"pick_model failed",ok:t}}case"ask_user":return{tool:e.tool,summary:"Awaiting your reply",ok:t};case"done":return{tool:e.tool,summary:"Finished",ok:t};default:return{tool:e.tool,summary:e.tool,ok:t}}}function ct(){const{messages:e,draft:t,isThinking:n,addMessage:s,updateMessage:o,setThinking:i,applyAgentPatches:a}=S(),l=ye(f=>f.apiKey!==null),d=V(f=>f.playgroundAgentModel)||te,{catalog:x}=qe(),{models:p}=ee(),h=p.find(f=>f.id===d)?.capabilities,[w,y]=b.useState(""),[$,m]=b.useState(null),u=b.useRef(null),g=b.useRef(null);b.useEffect(()=>{u.current?.scrollTo({top:u.current.scrollHeight,behavior:"smooth"})},[e,n]);const N=async f=>{const C=f.trim();if(!C||n)return;if(!l){m("Connect your Venice API key first.");return}m(null),y("");const P={id:U(),role:"user",content:C},E={id:U(),role:"assistant",content:"",pending:!0,activity:[]};s(P),s(E),i(!0),g.current?.abort();const A=new AbortController;g.current=A;const I=e.filter(T=>!T.pending&&!T.error).slice(-10).map(T=>({role:T.role,content:T.content}));try{if(h?.supportsFunctionCalling===!0){const k=[],W=await it({userMessage:C,draft:t,history:I,catalog:x,agentModels:p,model:d,capabilities:h,signal:A.signal,applyPatch:c=>{try{const j=S.getState().draft,v=ke({nodes:j.nodes,edges:j.edges},c);return S.setState({draft:{nodes:v.nodes,edges:v.edges}}),{ok:!0,id:v.addedNodeId,edge_id:v.addedEdgeId}}catch(j){return{error:j instanceof Error?j.message:"Patch failed"}}},onStep:c=>{k.push(lt(c)),o(E.id,{activity:[...k]})}});o(E.id,{content:W.say||"Done.",activity:k,pending:!1})}else{const k=await Ge({userMessage:C,draft:t,history:I,catalog:x,model:d,capabilities:h,signal:A.signal});let W;try{k.patches.length>0&&a(k.patches)}catch(v){W=v instanceof Error?v.message:"Failed to apply patches"}const c=k.invalidPatches>0?` (${k.invalidPatches} invalid patch${k.invalidPatches===1?"":"es"} ignored)`:"",j=k.patches.length===0&&!k.say?"The agent returned an unparseable response. Try a different model from the picker above, or simplify the request.":k.say||(k.patches.length>0?"Updated the workflow.":"");o(E.id,{content:j+c,patches:k.patches,error:W,pending:!1})}}catch(T){if(A.signal.aborted)o(E.id,{content:"",error:"Cancelled",pending:!1});else{const k=T instanceof Error?T.message:"Agent request failed";o(E.id,{content:"",error:k,pending:!1})}}finally{i(!1),g.current=null}},_=()=>{g.current?.abort()};return r.jsxs("div",{className:"flex flex-col h-full bg-[#0c0c10]",children:[r.jsx("div",{ref:u,className:"flex-1 overflow-y-auto px-4 py-4",children:e.length===0?r.jsxs("div",{className:"flex Line truncated
User request: ${e.userMessage}`}];let n=0,s=!1;for(let o=0;o<K;o++){if(e.signal?.aborted)throw new DOMException("Aborted","AbortError");const a=(await F("/chat/completions",{method:"POST",body:JSON.stringify({model:e.model,messages:t,temperature:.2,max_tokens:4096,tools:Xe,tool_choice:"auto"}),signal:e.signal})).choices[0]?.message;if(!a)return{say:"Empty response from agent.",tool_calls:n,asked_user:!1};t.push({role:"assistant",content:a.content??null,tool_calls:a.tool_calls});const l=a.tool_calls??[];if(l.length===0)return{say:(a.content||"").trim()||`Made ${n} edit${n===1?"":"s"}.`,tool_calls:n,asked_user:!1};let d=null,x=!1;for(const p of l){const h=p.function?.name??"",w=rt(p.function?.arguments),y=ot(h,w,e);n++,e.onStep?.({tool:h,args:w,result:y}),t.push({role:"tool",tool_call_id:p.id,content:JSON.stringify(y)}),h==="done"?d=y.message??"Done.":h==="ask_user"&&(d=y.message??"Need clarification.",x=!0)}if(d!==null)return s=x,{say:d,tool_calls:n,asked_user:s}}return{say:`Stopped after ${K} iterations and ${n} edits — let me know if it needs adjusting.`,tool_calls:n,asked_user:!1}}const at=["Build a workflow that makes a 9:16 reel using web search to research the topic, then writes a script and generates the video","Research a topic with web search, summarize into 5 bullets, and narrate it as audio","Turn a concept into a vivid image prompt, then generate the image","Write a short song with lyrics, then generate it as music","Make a cover image and a music track from one idea, then combine them into one video where the cover shows while the track plays"];function lt(e){const t=!("error"in e.result)||!e.result.error,n=e.args;switch(e.tool){case"clear":return{tool:e.tool,summary:"Cleared canvas",ok:t};case"add_node":{const s=e.result.id,o=String(n.node_type??"?");return{tool:e.tool,summary:t?`Added ${o}${s?` "${s}"`:""}`:`Failed to add ${o}: ${e.result.error}`,ok:t}}case"connect":{const s=String(n.source??""),o=String(n.target??"");return{tool:e.tool,summary:t?`Connected ${s} → ${o}`:`Connect failed (${s} → ${o}): ${e.result.error}`,ok:t}}case"set_params":return{tool:e.tool,summary:t?`Updated params on ${String(n.id??"")}`:`set_params failed: ${e.result.error}`,ok:t};case"remove_node":return{tool:e.tool,summary:t?`Removed ${String(n.id??"")}`:`remove failed: ${e.result.error}`,ok:t};case"pick_model":{const s=e.result.model;return{tool:e.tool,summary:t?`Picked ${s} for ${String(n.node_type??"")}`:"pick_model failed",ok:t}}case"ask_user":return{tool:e.tool,summary:"Awaiting your reply",ok:t};case"done":return{tool:e.tool,summary:"Finished",ok:t};default:return{tool:e.tool,summary:e.tool,ok:t}}}function ct(){const{messages:e,draft:t,isThinking:n,addMessage:s,updateMessage:o,setThinking:i,applyAgentPatches:a}=S(),l=ye(f=>f.apiKey!==null),d=V(f=>f.playgroundAgentModel)||te,{catalog:x}=qe(),{models:p}=ee(),h=p.find(f=>f.id===d)?.capabilities,[w,y]=b.useState(""),[$,m]=b.useState(null),u=b.useRef(null),g=b.useRef(null);b.useEffect(()=>{u.current?.scrollTo({top:u.current.scrollHeight,behavior:"smooth"})},[e,n]);const N=async f=>{const C=f.trim();if(!C||n)return;if(!l){m("Connect your Venice API key first.");return}m(null),y("");const P={id:U(),role:"user",content:C},E={id:U(),role:"assistant",content:"",pending:!0,activity:[]};s(P),s(E),i(!0),g.current?.abort();const A=new AbortController;g.current=A;const I=e.filter(T=>!T.pending&&!T.error).slice(-10).map(T=>({role:T.role,content:T.content}));try{if(h?.supportsFunctionCalling===!0){const k=[],W=await it({userMessage:C,draft:t,history:I,catalog:x,agentModels:p,model:d,capabilities:h,signal:A.signal,applyPatch:c=>{try{const j=S.getState().draft,v=ke({nodes:j.nodes,edges:j.edges},c);return S.setState({draft:{nodes:v.nodes,edges:v.edges}}),{ok:!0,id:v.addedNodeId,edge_id:v.addedEdgeId}}catch(j){return{error:j instanceof Error?j.message:"Patch failed"}}},onStep:c=>{k.push(lt(c)),o(E.id,{activity:[...k]})}});o(E.id,{content:W.say||"Done.",activity:k,pending:!1})}else{const k=await Ge({userMessage:C,draft:t,history:I,catalog:x,model:d,capabilities:h,signal:A.signal});let W;try{k.patches.length>0&&a(k.patches)}catch(v){W=v instanceof Error?v.message:"Failed to apply patches"}const c=k.invalidPatches>0?` (${k.invalidPatches} invalid patch${k.invalidPatches===1?"":"es"} ignored)`:"",j=k.patches.length===0&&!k.say?"The agent returned an unparseable response. Try a different model from the picker above, or simplify the request.":k.say||(k.patches.length>0?"Updated the workflow.":"");o(E.id,{content:j+c,patches:k.patches,error:W,pending:!1})}}catch(T){if(A.signal.aborted)o(E.id,{content:"",error:"Cancelled",pending:!1});else{const k=T instanceof Error?T.message:"Agent request failed";o(E.id,{content:"",error:k,pending:!1})}}finally{i(!1),g.current=null}},_=()=>{g.current?.abort()};return r.jsxs("div",{className:"flex flex-col h-full bg-[#0c0c10]",children:[r.jsx("div",{ref:u,className:"flex-1 overflow-y-auto px-4 py-4",children:e.length===0?r.jsxs("div",{className:"flex Line truncated
`)}),N.errors.length===0&&N.warnings.length>0&&r.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-yellow-400/70 shrink-0",title:N.warnings.map(c=>c.message).join(`
`)}),m&&r.jsx("span",{className:"text-[12px] text-green-400/70 truncate",children:m})]}),r.jsxs("div",{className:"flex items-center gap-1.5 shrink-0",children:[r.jsx("button",{onClick:a,disabled:!_||t,className:"text-white/25 hover:text-white/55 transition-colors p-1.5 disabled:opacity-30 disabled:cursor-not-allowed",title:"Reset canvas",children:r.jsx("svg",{width:"13",height:"13",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:r.jsx("path",{d:"M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"})})}),r.jsx("button",{onClick:C,disabled:!f,className:R("flex items-center gap-1.5 text-[13px] font-medium px-2.5 py-1 rounded-md transition-colors",f?"bg-green-500/20 text-green-300 border border-green-500/30 hover:bg-green-500/30":"bg-white/[0.05] text-white/25 border border-white/[0.06] cursor-not-allowed"),title:N.errors.length>0?N.errors.map(c=>c.message).join(`
`):"Run the workflow inline",children:t?r.jsxs(r.Fragment,{children:[r.jsx("span",{className:"w-2.5 h-2.5 border-2 border-green-300/60 border-t-transparent rounded-full animate-spin"}),r.jsx("span",{className:"hidden sm:inline",children:"Running…"})]}):r.jsxs(r.Fragment,{children:[r.jsx("svg",{width:"9",height:"9",viewBox:"0 0 24 24",fill:"currentColor",children:r.jsx("polygon",{points:"5 3 19 12 5 21 5 3"})}),"Run"]})}),r.jsx("button",{onClick:E,disabled:!_||t,className:R("text-[13px] px-2.5 py-1 rounded-md border border-white/[0.08] transition-colors",_&&!t?"text-white/60 hover:border-white/[0.15] hover:text-white/85":"text-white/20 opacity-40 cursor-not-allowed"),title:g?`Update "${g.name}"`:"Save as new workflow",children:g?"Update":"Save"}),g&&r.jsx("button",{onClick:A,disabled:!_||t,className:"text-white/35 hover:text-white/65 transition-colors p-1.5 disabled:opacity-30 disabled:cursor-not-allowed",title:"Save as a new workflow",children:r.jsxs("svg",{width:"13",height:"13",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[r.jsx("rect",{x:"3",y:"3",width:"14",height:"14",rx:"2"}),r.jsx("path",{d:"M7 21h12a2 2 0 002-2V9"})]})}),r.jsxs("button",{onClick:I,disabled:!_||t,className:R("flex items-center gap-1.5 text-[13px] font-medium px-2.5 py-1 rounded-md transition-colors",_&&!t?"bg-white text-black hover:bg-white/90":"bg-white/[0.05] text-white/30 cursor-not-allowed"),title:"Open in Workflows tab",children:[r.jsx("span",{className:"hidden md:inline",children:"Open in Workflows"}),r.jsx("span",{className:"md:hidden",children:"Open"}),r.jsx("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",children:r.jsx("path",{d:"M7 17L17 7M7 7h10v10"})})]})]})]}),r.jsx("div",{className:"flex-1 min-h-0",children:r.jsx(ft,{nodes:e.nodes,edges:e.edges})})]})]})}export{yt as PlaygroundView};
@@ -1,11 +1,11 @@
import{B as Ar,C as Ea,R as _a,D as Sa,r as R,j as $,g as An,q as Na,v as Ca,w as Ma,x as ka,y as Nt,E as Ia,F as Aa}from"./index-CmWoX_gW.js";function ie(e){if(typeof e=="string"||typeof e=="number")return""+e;let t="";if(Array.isArray(e))for(let n=0,o;n<e.length;n++)(o=ie(e[n]))!==""&&(t+=(t&&" ")+o);else for(let n in e)e[n]&&(t+=(t&&" ")+n);return t}var $a={value:()=>{}};function Jt(){for(var e=0,t=arguments.length,n={},o;e<t;++e){if(!(o=arguments[e]+"")||o in n||/[\s.]/.test(o))throw new Error("illegal type: "+o);n[o]=[]}return new zt(n)}function zt(e){this._=e}function Pa(e,t){return e.trim().split(/^|\s+/).map(function(n){var o="",r=n.indexOf(".");if(r>=0&&(o=n.slice(r+1),n=n.slice(0,r)),n&&!t.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:o}})}zt.prototype=Jt.prototype={constructor:zt,on:function(e,t){var n=this._,o=Pa(e+"",n),r,i=-1,s=o.length;if(arguments.length<2){for(;++i<s;)if((r=(e=o[i]).type)&&(r=Ta(n[r],e.name)))return r;return}if(t!=null&&typeof t!="function")throw new Error("invalid callback: "+t);for(;++i<s;)if(r=(e=o[i]).type)n[r]=wo(n[r],e.name,t);else if(t==null)for(r in n)n[r]=wo(n[r],e.name,null);return this},copy:function(){var e={},t=this._;for(var n in t)e[n]=t[n].slice();return new zt(e)},call:function(e,t){if((r=arguments.length-2)>0)for(var n=new Array(r),o=0,r,i;o<r;++o)n[o]=arguments[o+2];if(!this._.hasOwnProperty(e))throw new Error("unknown type: "+e);for(i=this._[e],o=0,r=i.length;o<r;++o)i[o].value.apply(t,n)},apply:function(e,t,n){if(!this._.hasOwnProperty(e))throw new Error("unknown type: "+e);for(var o=this._[e],r=0,i=o.length;r<i;++r)o[r].value.apply(t,n)}};function Ta(e,t){for(var n=0,o=e.length,r;n<o;++n)if((r=e[n]).name===t)return r.value}function wo(e,t,n){for(var o=0,r=e.length;o<r;++o)if(e[o].name===t){e[o]=$a,e=e.slice(0,o).concat(e.slice(o+1));break}return n!=null&&e.push({name:t,value:n}),e}var $n="http://www.w3.org/1999/xhtml";const vo={svg:"http://www.w3.org/2000/svg",xhtml:$n,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function en(e){var t=e+="",n=t.indexOf(":");return n>=0&&(t=e.slice(0,n))!=="xmlns"&&(e=e.slice(n+1)),vo.hasOwnProperty(t)?{space:vo[t],local:e}:e}function Da(e){return function(){var t=this.ownerDocument,n=this.namespaceURI;return n===$n&&t.documentElement.namespaceURI===$n?t.createElement(e):t.createElementNS(n,e)}}function Ra(e){return function(){return this.ownerDocument.createElementNS(e.space,e.local)}}function $r(e){var t=en(e);return(t.local?Ra:Da)(t)}function za(){}function Xn(e){return e==null?za:function(){return this.querySelector(e)}}function Ha(e){typeof e!="function"&&(e=Xn(e));for(var t=this._groups,n=t.length,o=new Array(n),r=0;r<n;++r)for(var i=t[r],s=i.length,a=o[r]=new Array(s),c,u,d=0;d<s;++d)(c=i[d])&&(u=e.call(c,c.__data__,d,i))&&("__data__"in c&&(u.__data__=c.__data__),a[d]=u);return new ue(o,this._parents)}function La(e){return e==null?[]:Array.isArray(e)?e:Array.from(e)}function Oa(){return[]}function Pr(e){return e==null?Oa:function(){return this.querySelectorAll(e)}}function Va(e){return function(){return La(e.apply(this,arguments))}}function Ba(e){typeof e=="function"?e=Va(e):e=Pr(e);for(var t=this._groups,n=t.length,o=[],r=[],i=0;i<n;++i)for(var s=t[i],a=s.length,c,u=0;u<a;++u)(c=s[u])&&(o.push(e.call(c,c.__data__,u,s)),r.push(c));return new ue(o,r)}function Tr(e){return function(){return this.matches(e)}}function Dr(e){return function(t){return t.matches(e)}}var Fa=Array.prototype.find;function Wa(e){return function(){return Fa.call(this.children,e)}}function ja(){return this.firstElementChild}function Xa(e){return this.select(e==null?ja:Wa(typeof e=="function"?e:Dr(e)))}var Ya=Array.prototype.filter;function Za(){return Array.from(this.children)}function qa(e){return function(){return Ya.call(this.children,e)}}function Ga(e){return this.selectAll(e==null?Za:qa(typeof e=="function"?e:Dr(e)))}function Ua(e){typeof e!="function"&&(e=Tr(e));for(var t=this._groups,n=t.length,o=new Array(n),r=0;r<n;++r)for(var i=t[r],s=i.length,a=o[r]=[],c,u=0;u<s;++u)(c=i[u])&&e.call(c,c.__data__,u,i)&&a.push(c);return new ue(o,this._parents)}function Rr(e){return new Array(e.length)}function Ka(){return new ue(this._enter||this._groups.map(Rr),this._parents)}function Bt(e,t){this.ownerDocument=e.ownerDocument,this.namespaceURI=e.namespaceURI,this._next=null,this._parent=e,this.__data__=t}Bt.prototype={constructor:Bt,appendChild:function(e){return this._parent.insertBefore(e,this._next)},insertBefore:function(e,t){return this._parent.insertBefore(e,t)},querySelector:function(e){return this._parent.querySelector(e)},querySelectorAll:function(e){return this._parent.querySelectorAll(e)}};function Qa(e){return function(){return e}}function Ja(e,t,n,o,r,i){for(var s=0,a,c=t.length,u=i.length;s<u;++s)(a=t[s])?(a.__data__=i[s],o[s]=a):n[s]=new Bt(e,i[s]);for(;s<c;++s)(a=t[s])&&(r[s]=a)}function ec(e,t,n,o,r,i,s){var a,c,u=new Map,d=t.lengthLine truncated
import{B as Ar,C as Ea,R as _a,D as Sa,r as R,j as $,g as An,q as Na,v as Ca,w as Ma,x as ka,y as Nt,E as Ia,F as Aa}from"./index-fmJ2VqHZ.js";function ie(e){if(typeof e=="string"||typeof e=="number")return""+e;let t="";if(Array.isArray(e))for(let n=0,o;n<e.length;n++)(o=ie(e[n]))!==""&&(t+=(t&&" ")+o);else for(let n in e)e[n]&&(t+=(t&&" ")+n);return t}var $a={value:()=>{}};function Jt(){for(var e=0,t=arguments.length,n={},o;e<t;++e){if(!(o=arguments[e]+"")||o in n||/[\s.]/.test(o))throw new Error("illegal type: "+o);n[o]=[]}return new zt(n)}function zt(e){this._=e}function Pa(e,t){return e.trim().split(/^|\s+/).map(function(n){var o="",r=n.indexOf(".");if(r>=0&&(o=n.slice(r+1),n=n.slice(0,r)),n&&!t.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:o}})}zt.prototype=Jt.prototype={constructor:zt,on:function(e,t){var n=this._,o=Pa(e+"",n),r,i=-1,s=o.length;if(arguments.length<2){for(;++i<s;)if((r=(e=o[i]).type)&&(r=Ta(n[r],e.name)))return r;return}if(t!=null&&typeof t!="function")throw new Error("invalid callback: "+t);for(;++i<s;)if(r=(e=o[i]).type)n[r]=wo(n[r],e.name,t);else if(t==null)for(r in n)n[r]=wo(n[r],e.name,null);return this},copy:function(){var e={},t=this._;for(var n in t)e[n]=t[n].slice();return new zt(e)},call:function(e,t){if((r=arguments.length-2)>0)for(var n=new Array(r),o=0,r,i;o<r;++o)n[o]=arguments[o+2];if(!this._.hasOwnProperty(e))throw new Error("unknown type: "+e);for(i=this._[e],o=0,r=i.length;o<r;++o)i[o].value.apply(t,n)},apply:function(e,t,n){if(!this._.hasOwnProperty(e))throw new Error("unknown type: "+e);for(var o=this._[e],r=0,i=o.length;r<i;++r)o[r].value.apply(t,n)}};function Ta(e,t){for(var n=0,o=e.length,r;n<o;++n)if((r=e[n]).name===t)return r.value}function wo(e,t,n){for(var o=0,r=e.length;o<r;++o)if(e[o].name===t){e[o]=$a,e=e.slice(0,o).concat(e.slice(o+1));break}return n!=null&&e.push({name:t,value:n}),e}var $n="http://www.w3.org/1999/xhtml";const vo={svg:"http://www.w3.org/2000/svg",xhtml:$n,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function en(e){var t=e+="",n=t.indexOf(":");return n>=0&&(t=e.slice(0,n))!=="xmlns"&&(e=e.slice(n+1)),vo.hasOwnProperty(t)?{space:vo[t],local:e}:e}function Da(e){return function(){var t=this.ownerDocument,n=this.namespaceURI;return n===$n&&t.documentElement.namespaceURI===$n?t.createElement(e):t.createElementNS(n,e)}}function Ra(e){return function(){return this.ownerDocument.createElementNS(e.space,e.local)}}function $r(e){var t=en(e);return(t.local?Ra:Da)(t)}function za(){}function Xn(e){return e==null?za:function(){return this.querySelector(e)}}function Ha(e){typeof e!="function"&&(e=Xn(e));for(var t=this._groups,n=t.length,o=new Array(n),r=0;r<n;++r)for(var i=t[r],s=i.length,a=o[r]=new Array(s),c,u,d=0;d<s;++d)(c=i[d])&&(u=e.call(c,c.__data__,d,i))&&("__data__"in c&&(u.__data__=c.__data__),a[d]=u);return new ue(o,this._parents)}function La(e){return e==null?[]:Array.isArray(e)?e:Array.from(e)}function Oa(){return[]}function Pr(e){return e==null?Oa:function(){return this.querySelectorAll(e)}}function Va(e){return function(){return La(e.apply(this,arguments))}}function Ba(e){typeof e=="function"?e=Va(e):e=Pr(e);for(var t=this._groups,n=t.length,o=[],r=[],i=0;i<n;++i)for(var s=t[i],a=s.length,c,u=0;u<a;++u)(c=s[u])&&(o.push(e.call(c,c.__data__,u,s)),r.push(c));return new ue(o,r)}function Tr(e){return function(){return this.matches(e)}}function Dr(e){return function(t){return t.matches(e)}}var Fa=Array.prototype.find;function Wa(e){return function(){return Fa.call(this.children,e)}}function ja(){return this.firstElementChild}function Xa(e){return this.select(e==null?ja:Wa(typeof e=="function"?e:Dr(e)))}var Ya=Array.prototype.filter;function Za(){return Array.from(this.children)}function qa(e){return function(){return Ya.call(this.children,e)}}function Ga(e){return this.selectAll(e==null?Za:qa(typeof e=="function"?e:Dr(e)))}function Ua(e){typeof e!="function"&&(e=Tr(e));for(var t=this._groups,n=t.length,o=new Array(n),r=0;r<n;++r)for(var i=t[r],s=i.length,a=o[r]=[],c,u=0;u<s;++u)(c=i[u])&&e.call(c,c.__data__,u,i)&&a.push(c);return new ue(o,this._parents)}function Rr(e){return new Array(e.length)}function Ka(){return new ue(this._enter||this._groups.map(Rr),this._parents)}function Bt(e,t){this.ownerDocument=e.ownerDocument,this.namespaceURI=e.namespaceURI,this._next=null,this._parent=e,this.__data__=t}Bt.prototype={constructor:Bt,appendChild:function(e){return this._parent.insertBefore(e,this._next)},insertBefore:function(e,t){return this._parent.insertBefore(e,t)},querySelector:function(e){return this._parent.querySelector(e)},querySelectorAll:function(e){return this._parent.querySelectorAll(e)}};function Qa(e){return function(){return e}}function Ja(e,t,n,o,r,i){for(var s=0,a,c=t.length,u=i.length;s<u;++s)(a=t[s])?(a.__data__=i[s],o[s]=a):n[s]=new Bt(e,i[s]);for(;s<c;++s)(a=t[s])&&(r[s]=a)}function ec(e,t,n,o,r,i,s){var a,c,u=new Map,d=t.lengthLine truncated
`).replace(`
`,`
+`).split(`
`)),d=u.reduce((l,f)=>l.concat(...f),[]);return[u,d]}return[[],[]]},[e]);return R.useEffect(()=>{const c=t?.target??ar,u=t?.actInsideInputWithModifier??!0;if(e!==null){const d=h=>{if(r.current=h.ctrlKey||h.metaKey||h.shiftKey||h.altKey,(!r.current||r.current&&!u)&&wi(h))return!1;const y=ur(h.code,a);if(i.current.add(h[y]),cr(s,i.current,!1)){const w=h.composedPath?.()?.[0]||h.target,x=w?.nodeName==="BUTTON"||w?.nodeName==="A";t.preventDefault!==!1&&(r.current||!x)&&h.preventDefault(),o(!0)}},l=h=>{const p=ur(h.code,a);cr(s,i.current,!0)?(o(!1),i.current.clear()):i.current.delete(h[p]),h.key==="Meta"&&i.current.clear(),r.current=!1},f=()=>{i.current.clear(),o(!1)};return c?.addEventListener("keydown",d),c?.addEventListener("keyup",l),window.addEventListener("blur",f),window.addEventListener("contextmenu",f),()=>{c?.removeEventListener("keydown",d),c?.removeEventListener("keyup",l),window.removeEventListener("blur",f),window.removeEventListener("contextmenu",f)}}},[e,o]),n}function cr(e,t,n){return e.filter(o=>n||o.length===t.size).some(o=>o.every(r=>t.has(r)))}function ur(e,t){return t.includes(e)?"code":"key"}const Vf=()=>{const e=re();return R.useMemo(()=>({zoomIn:t=>{const{panZoom:n}=e.getState();return n?n.scaleBy(1.2,{duration:t?.duration}):Promise.resolve(!1)},zoomOut:t=>{const{panZoom:n}=e.getState();return n?n.scaleBy(1/1.2,{duration:t?.duration}):Promise.resolve(!1)},zoomTo:(t,n)=>{const{panZoom:o}=e.getState();return o?o.scaleTo(t,{duration:n?.duration}):Promise.resolve(!1)},getZoom:()=>e.getState().transform[2],setViewport:async(t,n)=>{const{transform:[o,r,i],panZoom:s}=e.getState();return s?(await s.setViewport({x:t.x??o,y:t.y??r,zoom:t.zoom??i},n),Promise.resolve(!0)):Promise.resolve(!1)},getViewport:()=>{const[t,n,o]=e.getState().transform;return{x:t,y:n,zoom:o}},setCenter:async(t,n,o)=>e.getState().setCenter(t,n,o),fitBounds:async(t,n)=>{const{width:o,height:r,minZoom:i,maxZoom:s,panZoom:a}=e.getState(),c=eo(t,o,r,i,s,n?.padding??.1);return a?(await a.setViewport(c,{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),Promise.resolve(!0)):Promise.resolve(!1)},screenToFlowPosition:(t,n={})=>{const{transform:o,snapGrid:r,snapToGrid:i,domNode:s}=e.getState();if(!s)return t;const{x:a,y:c}=s.getBoundingClientRect(),u={x:t.x-a,y:t.y-c},d=n.snapGrid??r,l=n.snapToGrid??i;return _t(u,o,l,d)},flowToScreenPosition:t=>{const{transform:n,domNode:o}=e.getState();if(!o)return t;const{x:r,y:i}=o.getBoundingClientRect(),s=Ut(t,n);return{x:s.x+r,y:s.y+i}}}),[])};function Bi(e,t){const n=[],o=new Map,r=[];for(const i of e)if(i.type==="add"){r.push(i);continue}else if(i.type==="remove"||i.type==="replace")o.set(i.id,[i]);else{const s=o.get(i.id);s?s.push(i):o.set(i.id,[i])}for(const i of t){const s=o.get(i.id);if(!s){n.push(i);continue}if(s[0].type==="remove")continue;if(s[0].type==="replace"){n.push({...s[0].item});continue}const a={...i};for(const c of s)Bf(c,a);n.push(a)}return r.length&&r.forEach(i=>{i.index!==void 0?n.splice(i.index,0,{...i.item}):n.push({...i.item})}),n}function Bf(e,t){switch(e.type){case"select":{t.selected=e.selected;break}case"position":{typeof e.position<"u"&&(t.position=e.position),typeof e.dragging<"u"&&(t.dragging=e.dragging);break}case"dimensions":{typeof e.dimensions<"u"&&(t.measured={...e.dimensions},e.setAttributes&&((e.setAttributes===!0||e.setAttributes==="width")&&(t.width=e.dimensions.width),(e.setAttributes===!0||e.setAttributes==="height")&&(t.height=e.dimensions.height))),typeof e.resizing=="boolean"&&(t.resizing=e.resizing);break}}}function Fi(e,t){return Bi(e,t)}function Wi(e,t){return Bi(e,t)}function Ae(e,t){return{id:e,type:"select",selected:t}}function Be(e,t=new Set,n=!1){const o=[];for(const[r,i]of e){const s=t.has(r);!(i.selected===void 0&&!s)&&i.selected!==s&&(n&&(i.selected=s),o.push(Ae(i.id,s)))}return o}function lr({items:e=[],lookup:t}){const n=[],o=new Map(e.map(r=>[r.id,r]));for(const[r,i]of e.entries()){const s=t.get(i.id),a=s?.internals?.userNode??s;a!==void 0&&a!==i&&n.push({id:i.id,item:i,type:"replace"}),a===void 0&&n.push({item:i,type:"add",index:r})}for(const[r]of t)o.get(r)===void 0&&n.push({id:r,type:"remove"});return n}function dr(e){return{id:e.id,type:"remove"}}const fr=e=>ld(e),Ff=e=>di(e);function ji(e){return R.forwardRef(e)}const Wf=typeof window<"u"?R.useLayoutEffect:R.useEffect;function hr(e){const[t,n]=R.useState(BigInt(0)),[o]=R.useState(()=>jf(()=>n(r=>r+BigInt(1))));return Wf(()=>{const r=o.get();r.length&&(e(r),o.reset())},[t]),o}function jf(e){let t=[];return{get:()=>t,reset:()=>{t=[]},push:n=>{t.push(n),e()}}}const Xi=R.createContext(null);function Xf({children:e}){const t=re(),n=R.useCallback(a=>{const{nodes:c=[],setNodes:u,hasDefaultNodes:d,onNodesChange:l,nodeLookup:f,fitViewQueued:h,onNodesChangeMiddlewareMap:p}=t.getState();let y=c;for(const x of a)y=typeof x=="function"?x(y):x;let w=lr({items:y,lookup:f});for(const x of p.values())w=x(w);d&&u(y),w.length>0?l?.(w):h&&window.requestAnimationFrame(()=>{const{fitViewQu Line truncated
M${P.x},${P.y}h${P.width}v${P.height}h${-P.width}z`,fillRule:"evenodd",pointerEvents:"none"})]})})}ms.displayName="MiniMap";const sg=R.memo(ms),Ap=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,$p={[Ke.Line]:"right",[Ke.Handle]:"bottom-right"};function Pp({nodeId:e,position:t,variant:n=Ke.Handle,className:o,style:r=void 0,children:i,color:s,minWidth:a=10,minHeight:c=10,maxWidth:u=Number.MAX_VALUE,maxHeight:d=Number.MAX_VALUE,keepAspectRatio:l=!1,resizeDirection:f,autoScale:h=!0,shouldResize:p,onResizeStart:y,onResize:w,onResizeEnd:x}){const b=qi(),g=typeof e=="string"?e:b,m=re(),S=R.useRef(null),E=n===Ke.Handle,N=J(R.useCallback(Ap(E&&h),[E,h]),oe),P=R.useRef(null),A=t??$p[n];R.useEffect(()=>{if(!(!S.current||!g))return P.current||(P.current=uf({domNode:S.current,nodeId:g,getStoreItems:()=>{const{nodeLookup:k,transform:D,snapGrid:z,snapToGrid:v,nodeOrigin:C,domNode:_}=m.getState();return{nodeLookup:k,transform:D,snapGrid:z,snapToGrid:v,nodeOrigin:C,paneDomNode:_}},onChange:(k,D)=>{const{triggerNodeChanges:z,nodeLookup:v,parentLookup:C,nodeOrigin:_}=m.getState(),I=[],M={x:k.x,y:k.y},T=v.get(g);if(T&&T.expandParent&&T.parentId){const H=T.origin??_,O=k.width??T.measured.width??0,L=k.height??T.measured.height??0,W={id:T.id,parentId:T.parentId,rect:{width:O,height:L,...yi({x:k.x??T.position.x,y:k.y??T.position.y},{width:O,height:L},T.parentId,v,H)}},F=so([W],v,C,_);I.push(...F),M.x=k.x?Math.max(H[0]*O,k.x):void 0,M.y=k.y?Math.max(H[1]*L,k.y):void 0}if(M.x!==void 0&&M.y!==void 0){const H={id:g,type:"position",position:{...M}};I.push(H)}if(k.width!==void 0&&k.height!==void 0){const O={id:g,type:"dimensions",resizing:!0,setAttributes:f?f==="horizontal"?"width":"height":!0,dimensions:{width:k.width,height:k.height}};I.push(O)}for(const H of D){const O={...H,type:"position"};I.push(O)}z(I)},onEnd:({width:k,height:D})=>{const z={id:g,type:"dimensions",resizing:!1,dimensions:{width:k,height:D}};m.getState().triggerNodeChanges([z])}})),P.current.update({controlPosition:A,boundaries:{minWidth:a,minHeight:c,maxWidth:u,maxHeight:d},keepAspectRatio:l,resizeDirection:f,onResizeStart:y,onResize:w,onResizeEnd:x,shouldResize:p}),()=>{P.current?.destroy()}},[A,a,c,u,d,l,y,w,x,p]);const V=A.split("-");return $.jsx("div",{className:ie(["react-flow__resize-control","nodrag",...V,n,o]),ref:S,style:{...r,scale:N,...s&&{[E?"backgroundColor":"borderColor"]:s}},children:i})}R.memo(Pp);const Tp=["off","on","auto"],Dp=["16:9","9:16","1:1","4:3","3:4"],Rp=["","5s","10s"],zp=["","720p","1080p"],Hp=["mp3","opus","aac","flac","wav"],je={textInput:{type:"textInput",label:"Input",description:"Static text provided by the user. Starting point of a workflow.",input:"none",output:"text",params:[{name:"inputText",type:"text",description:"The text value.",required:!0,default:""}]},output:{type:"output",label:"Output",description:"Displays whatever its upstream node produced. Accepts any kind.",input:"text",output:"none",params:[]},chat:{type:"chat",label:"LLM",description:"Run a Venice chat completion. Accepts upstream text as context.",input:"text",output:"text",params:[{name:"model",type:"string",description:"Venice chat model id.",required:!0,default:"llama-3.3-70b"},{name:"prompt",type:"text",description:"Instruction. Use {{input}} to position upstream text, or leave empty to append.",required:!0,default:""},{name:"temperature",type:"number",description:"Sampling temperature.",default:.7,min:0,max:2},{name:"maxTokens",type:"number",description:"Max output tokens.",default:4096,min:64,max:32768},{name:"webSearch",type:"enum",description:"Enable Venice web search.",default:"off",enumValues:Tp}]},imageGen:{type:"imageGen",label:"Image Gen",description:"Generate an image from a text prompt.",input:"text",output:"image",params:[{name:"model",type:"string",description:"Venice image model id.",required:!0,default:"z-image-turbo"},{name:"prompt",type:"text",description:"Image prompt. Use {{input}} to position upstream text.",required:!0,default:""},{name:"negativePrompt",type:"string",description:"What to avoid.",default:""},{name:"steps",type:"number",description:"Denoising steps.",default:20,min:1,max:50},{name:"width",type:"number",description:"Image width in pixels.",default:1024,min:256,max:2048},{name:"height",type:"number",description:"Image height in pixels.",default:1024,min:256,max:2048},{name:"style",type:"string",description:"Style preset name.",default:""},{name:"hideWatermark",type:"boolean",description:"Omit watermark if allowed.",default:!0}]},tts:{type:"tts",label:"Text to Speech",description:"Narrate text into speech audio.",input:"text",output:"audio",params:[{name:"model",type:"string",description:"TTS model id.",required:!0,default:"tts-kokoro"},{name:"prompt",type:"text",description:"Text to speak. Use {{input}} to position upstream text.",default:""},{name:"voice",type:"string",description:"Voice id.",default:"af_sky"},{name:"speed",type:"number",description:"Playback speed.",default:1,min:.25,max:4},{name:"responseForma Line truncated
M${P.x},${P.y}h${P.width}v${P.height}h${-P.width}z`,fillRule:"evenodd",pointerEvents:"none"})]})})}ms.displayName="MiniMap";const sg=R.memo(ms),Ap=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,$p={[Ke.Line]:"right",[Ke.Handle]:"bottom-right"};function Pp({nodeId:e,position:t,variant:n=Ke.Handle,className:o,style:r=void 0,children:i,color:s,minWidth:a=10,minHeight:c=10,maxWidth:u=Number.MAX_VALUE,maxHeight:d=Number.MAX_VALUE,keepAspectRatio:l=!1,resizeDirection:f,autoScale:h=!0,shouldResize:p,onResizeStart:y,onResize:w,onResizeEnd:x}){const b=qi(),g=typeof e=="string"?e:b,m=re(),S=R.useRef(null),E=n===Ke.Handle,N=J(R.useCallback(Ap(E&&h),[E,h]),oe),P=R.useRef(null),A=t??$p[n];R.useEffect(()=>{if(!(!S.current||!g))return P.current||(P.current=uf({domNode:S.current,nodeId:g,getStoreItems:()=>{const{nodeLookup:k,transform:D,snapGrid:z,snapToGrid:v,nodeOrigin:C,domNode:_}=m.getState();return{nodeLookup:k,transform:D,snapGrid:z,snapToGrid:v,nodeOrigin:C,paneDomNode:_}},onChange:(k,D)=>{const{triggerNodeChanges:z,nodeLookup:v,parentLookup:C,nodeOrigin:_}=m.getState(),I=[],M={x:k.x,y:k.y},T=v.get(g);if(T&&T.expandParent&&T.parentId){const H=T.origin??_,O=k.width??T.measured.width??0,L=k.height??T.measured.height??0,W={id:T.id,parentId:T.parentId,rect:{width:O,height:L,...yi({x:k.x??T.position.x,y:k.y??T.position.y},{width:O,height:L},T.parentId,v,H)}},F=so([W],v,C,_);I.push(...F),M.x=k.x?Math.max(H[0]*O,k.x):void 0,M.y=k.y?Math.max(H[1]*L,k.y):void 0}if(M.x!==void 0&&M.y!==void 0){const H={id:g,type:"position",position:{...M}};I.push(H)}if(k.width!==void 0&&k.height!==void 0){const O={id:g,type:"dimensions",resizing:!0,setAttributes:f?f==="horizontal"?"width":"height":!0,dimensions:{width:k.width,height:k.height}};I.push(O)}for(const H of D){const O={...H,type:"position"};I.push(O)}z(I)},onEnd:({width:k,height:D})=>{const z={id:g,type:"dimensions",resizing:!1,dimensions:{width:k,height:D}};m.getState().triggerNodeChanges([z])}})),P.current.update({controlPosition:A,boundaries:{minWidth:a,minHeight:c,maxWidth:u,maxHeight:d},keepAspectRatio:l,resizeDirection:f,onResizeStart:y,onResize:w,onResizeEnd:x,shouldResize:p}),()=>{P.current?.destroy()}},[A,a,c,u,d,l,y,w,x,p]);const V=A.split("-");return $.jsx("div",{className:ie(["react-flow__resize-control","nodrag",...V,n,o]),ref:S,style:{...r,scale:N,...s&&{[E?"backgroundColor":"borderColor"]:s}},children:i})}R.memo(Pp);const Tp=["off","on","auto"],Dp=["16:9","9:16","1:1","4:3","3:4"],Rp=["","5s","10s"],zp=["","720p","1080p"],Hp=["mp3","opus","aac","flac","wav"],je={textInput:{type:"textInput",label:"Input",description:"Static text provided by the user. Starting point of a workflow.",input:"none",output:"text",params:[{name:"inputText",type:"text",description:"The text value.",required:!0,default:""}]},output:{type:"output",label:"Output",description:"Displays whatever its upstream node produced. Accepts any kind.",input:"text",output:"none",params:[]},chat:{type:"chat",label:"LLM",description:"Run a Venice chat completion. Accepts upstream text as context.",input:"text",output:"text",params:[{name:"model",type:"string",description:"Venice chat model id.",required:!0,default:"llama-3.3-70b"},{name:"prompt",type:"text",description:"Instruction. Use {{input}} to position upstream text, or leave empty to append.",required:!0,default:""},{name:"temperature",type:"number",description:"Sampling temperature.",default:.7,min:0,max:2},{name:"maxTokens",type:"number",description:"Max output tokens.",default:4096,min:64,max:32768},{name:"webSearch",type:"enum",description:"Enable Venice web search.",default:"off",enumValues:Tp}]},imageGen:{type:"imageGen",label:"Image Gen",description:"Generate an image from a text prompt.",input:"text",output:"image",params:[{name:"model",type:"string",description:"Venice image model id.",required:!0,default:"z-image-turbo"},{name:"prompt",type:"text",description:"Image prompt. Use {{input}} to position upstream text.",required:!0,default:""},{name:"negativePrompt",type:"string",description:"What to avoid.",default:""},{name:"steps",type:"number",description:"Denoising steps.",default:20,min:1,max:50},{name:"width",type:"number",description:"Image width in pixels.",default:1024,min:256,max:2048},{name:"height",type:"number",description:"Image height in pixels.",default:1024,min:256,max:2048},{name:"style",type:"string",description:"Style preset name.",default:""},{name:"hideWatermark",type:"boolean",description:"Omit watermark if allowed.",default:!0}]},tts:{type:"tts",label:"Text to Speech",description:"Narrate text into speech audio.",input:"text",output:"audio",params:[{name:"model",type:"string",description:"TTS model id.",required:!0,default:"tts-kokoro"},{name:"prompt",type:"text",description:"Text to speak. Use {{input}} to position upstream text.",default:""},{name:"voice",type:"string",description:"Voice id.",default:"af_sky"},{name:"speed",type:"number",description:"Playback speed.",default:1,min:.25,max:4},{name:"responseForma Line truncated
`)}function rt(e,t){return e?e.includes("{{input}}")?e.replace(/\{\{input\}\}/g,t):t?`${e}
${t}`:e:t}async function Ir(e,t,n,o,r){for(let i=0;i<kr;i++){if(r?.aborted)throw new DOMException("Aborted","AbortError");await new Promise((a,c)=>{const u=setTimeout(a,Mr);r?.addEventListener("abort",()=>{clearTimeout(u),c(new DOMException("Aborted","AbortError"))},{once:!0})});const s=await Aa(e,t,n,{downloadUrl:o,signal:r});if(s.state==="done")return s.url;if(s.state==="failed")throw new Error(s.error)}throw new Error(`Generation timed out after ${kr*Mr/1e3}s`)}async function Jp(e,t,n){const o=e.data;switch(o.nodeType){case"textInput":return o.inputText??"";case"compose":{const r=/\[image:([^\]]+)\]/.exec(t)?.[1],i=/\[audio:([^\]]+)\]/.exec(t)?.[1];if(!r||!i){const a=[r&&"an image",i&&"audio"].filter(Boolean).join(" and ")||"neither";throw new Error(`Cover + Track needs one image node and one audio node connected to it (got ${a})`)}return`[video:${await Up(r,i,{maxSide:o.maxSide??1080,signal:n})}]`}case"output":return t;case"chat":{const r=rt(o.prompt,t);return(await Nt("/chat/completions",{method:"POST",body:JSON.stringify({model:o.model||"llama-3.3-70b",messages:[{role:"user",content:r}],temperature:o.temperature??.7,max_tokens:o.maxTokens??4096,venice_parameters:{enable_web_search:o.webSearch??"off"}}),signal:n})).choices[0]?.message?.content??""}case"imageGen":{const r=rt(o.prompt,t),i={model:o.model||"z-image-turbo",prompt:r,negative_prompt:o.negativePrompt||void 0,steps:o.steps??20,style_preset:o.style||void 0,width:o.width??1024,height:o.height??1024,hide_watermark:o.hideWatermark??!0};o.aspectRatio&&(i.aspect_ratio=o.aspectRatio);const a=(await Nt("/image/generate",{method:"POST",body:JSON.stringify(i),signal:n})).images[0],c=typeof a=="string"?a:a.b64_json;return`[image:data:${c.startsWith("/9j/")?"image/jpeg":c.startsWith("iVBOR")?"image/png":c.startsWith("UklGR")?"image/webp":"image/png"};base64,${c}]`}case"tts":{const r=rt(o.prompt,t),i=await Ia("/audio/speech",{model:o.model||"tts-kokoro",input:r,voice:o.voice||"af_sky",speed:o.speed??1,response_format:o.responseFormat||"mp3"},{signal:n});return`[audio:${URL.createObjectURL(i)}]`}case"music":{const r=rt(o.prompt,t),i={model:o.model||"stable-audio-25",prompt:r,duration_seconds:o.duration??30,force_instrumental:o.instrumental??!1};o.lyrics&&(i.lyrics_prompt=o.lyrics);const s=await Nt("/audio/queue",{method:"POST",body:JSON.stringify(i),signal:n});return`[audio:${await Ir("audio",s.model||String(i.model),s.queue_id,s.download_url,n)}]`}case"video":{const r=rt(o.prompt,t),i={model:o.model||"wan-3-0-text-to-video",prompt:r,aspect_ratio:o.videoAspectRatio||"16:9"};o.videoDuration&&(i.duration=o.videoDuration),o.videoResolution&&(i.resolution=o.videoResolution);const s=await Nt("/video/queue",{method:"POST",body:JSON.stringify(i),signal:n});return`[video:${await Ir("video",s.model||String(i.model),s.queue_id||s.id||"",s.download_url,n)}]`}}}async function cg(e,t,n){const o=typeof n=="function"?{onUpdate:n}:n,{signal:r,onUpdate:i}=o,s=Yp({nodes:e,edges:t});if(!s.ok){const d=s.errors[0];throw new In(d?.message??"Workflow has validation errors.",d?.nodeId)}const a=Kp(e,t);if(!a)throw new In("Workflow contains a cycle.");const c=new Map,u=new Map(e.map(d=>[d.id,d]));for(const d of a){if(r?.aborted)return;await Promise.all(d.map(async l=>{const f=u.get(l);if(f){i(l,{status:"running",output:void 0,error:void 0});try{const h=Qp(l,t,c),p=await Jp(f,h,r);c.set(l,p);const y=je[f.data.nodeType]?.output;i(l,{status:"done",output:p,outputKind:y&&y!=="none"?y:void 0})}catch(h){if(h instanceof DOMException&&h.name==="AbortError")throw i(l,{status:"error",error:"Cancelled"}),h;const p=h instanceof Error?h.message:"Unknown error";throw i(l,{status:"error",error:p}),new In(p,l)}}}))}}export{rg as B,ig as C,Kt as H,sg as M,je as N,X as P,rp as R,ao as a,ng as b,og as c,Cd as d,ke as e,cg as f,jp as g,Wp as h,tg as i,ag as u,Yp as v};
${t}`:e:t}async function Ir(e,t,n,o,r){for(let i=0;i<kr;i++){if(r?.aborted)throw new DOMException("Aborted","AbortError");await new Promise((a,c)=>{const u=setTimeout(a,Mr);r?.addEventListener("abort",()=>{clearTimeout(u),c(new DOMException("Aborted","AbortError"))},{once:!0})});const s=await Aa(e,t,n,{downloadUrl:o,signal:r});if(s.state==="done")return s.url;if(s.state==="failed")throw new Error(s.error)}throw new Error(`Generation timed out after ${kr*Mr/1e3}s`)}async function Jp(e,t,n){const o=e.data;switch(o.nodeType){case"textInput":return o.inputText??"";case"compose":{const r=/\[image:([^\]]+)\]/.exec(t)?.[1],i=/\[audio:([^\]]+)\]/.exec(t)?.[1];if(!r||!i){const a=[r&&"an image",i&&"audio"].filter(Boolean).join(" and ")||"neither";throw new Error(`Cover + Track needs one image node and one audio node connected to it (got ${a})`)}return`[video:${await Up(r,i,{maxSide:o.maxSide??1080,signal:n})}]`}case"output":return t;case"step":return t;case"chat":{const r=rt(o.prompt,t);return(await Nt("/chat/completions",{method:"POST",body:JSON.stringify({model:o.model||"llama-3.3-70b",messages:[{role:"user",content:r}],temperature:o.temperature??.7,max_tokens:o.maxTokens??4096,venice_parameters:{enable_web_search:o.webSearch??"off"}}),signal:n})).choices[0]?.message?.content??""}case"imageGen":{const r=rt(o.prompt,t),i={model:o.model||"z-image-turbo",prompt:r,negative_prompt:o.negativePrompt||void 0,steps:o.steps??20,style_preset:o.style||void 0,width:o.width??1024,height:o.height??1024,hide_watermark:o.hideWatermark??!0};o.aspectRatio&&(i.aspect_ratio=o.aspectRatio);const a=(await Nt("/image/generate",{method:"POST",body:JSON.stringify(i),signal:n})).images[0],c=typeof a=="string"?a:a.b64_json;return`[image:data:${c.startsWith("/9j/")?"image/jpeg":c.startsWith("iVBOR")?"image/png":c.startsWith("UklGR")?"image/webp":"image/png"};base64,${c}]`}case"tts":{const r=rt(o.prompt,t),i=await Ia("/audio/speech",{model:o.model||"tts-kokoro",input:r,voice:o.voice||"af_sky",speed:o.speed??1,response_format:o.responseFormat||"mp3"},{signal:n});return`[audio:${URL.createObjectURL(i)}]`}case"music":{const r=rt(o.prompt,t),i={model:o.model||"stable-audio-25",prompt:r,duration_seconds:o.duration??30,force_instrumental:o.instrumental??!1};o.lyrics&&(i.lyrics_prompt=o.lyrics);const s=await Nt("/audio/queue",{method:"POST",body:JSON.stringify(i),signal:n});return`[audio:${await Ir("audio",s.model||String(i.model),s.queue_id,s.download_url,n)}]`}case"video":{const r=rt(o.prompt,t),i={model:o.model||"wan-3-0-text-to-video",prompt:r,aspect_ratio:o.videoAspectRatio||"16:9"};o.videoDuration&&(i.duration=o.videoDuration),o.videoResolution&&(i.resolution=o.videoResolution);const s=await Nt("/video/queue",{method:"POST",body:JSON.stringify(i),signal:n});return`[video:${await Ir("video",s.model||String(i.model),s.queue_id||s.id||"",s.download_url,n)}]`}}}async function cg(e,t,n){const o=typeof n=="function"?{onUpdate:n}:n,{signal:r,onUpdate:i}=o,s=Yp({nodes:e,edges:t});if(!s.ok){const d=s.errors[0];throw new In(d?.message??"Workflow has validation errors.",d?.nodeId)}const a=Kp(e,t);if(!a)throw new In("Workflow contains a cycle.");const c=new Map,u=new Map(e.map(d=>[d.id,d]));for(const d of a){if(r?.aborted)return;await Promise.all(d.map(async l=>{const f=u.get(l);if(f){i(l,{status:"running",output:void 0,error:void 0});try{const h=Qp(l,t,c),p=await Jp(f,h,r);c.set(l,p);const y=je[f.data.nodeType]?.output;i(l,{status:"done",output:p,outputKind:y&&y!=="none"?y:void 0})}catch(h){if(h instanceof DOMException&&h.name==="AbortError")throw i(l,{status:"error",error:"Cancelled"}),h;const p=h instanceof Error?h.message:"Unknown error";throw i(l,{status:"error",error:p}),new In(p,l)}}}))}}export{rg as B,ig as C,Kt as H,sg as M,je as N,X as P,rp as R,ao as a,ng as b,og as c,Cd as d,ke as e,cg as f,jp as g,Wp as h,tg as i,ag as u,Yp as v};
@@ -1 +0,0 @@
import{r as h,j as e,c as w,u as F,S as z,g as E,t as M}from"./index-CmWoX_gW.js";import{u as f,a as P,H as S,P as O,R as _,b as H,c as $,d as U,i as Y,B as K,e as q,C as J,M as Q,f as X}from"./workflow-engine-lSJFuHzZ.js";function Z(){return e.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("polyline",{points:"22 12 16 12 14 15 10 9 8 12 2 12"})})}function ee(){return e.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("path",{d:"M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"})})}function te(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("circle",{cx:"8.5",cy:"8.5",r:"1.5"}),e.jsx("polyline",{points:"21 15 16 10 5 21"})]})}function oe(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polygon",{points:"11 5 6 9 2 9 2 15 6 15 11 19 11 5"}),e.jsx("path",{d:"M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"})]})}function se(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("path",{d:"M9 18V5l12-2v13"}),e.jsx("circle",{cx:"6",cy:"18",r:"3"}),e.jsx("circle",{cx:"18",cy:"16",r:"3"})]})}function ne(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polygon",{points:"23 7 16 12 23 17 23 7"}),e.jsx("rect",{x:"1",y:"5",width:"15",height:"14",rx:"2"})]})}function ie(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("path",{d:"M3 15l5-5 4 4"}),e.jsx("path",{d:"M14 17v-5l4-1v5"}),e.jsx("circle",{cx:"12.5",cy:"17.5",r:"1.5"}),e.jsx("circle",{cx:"16.5",cy:"16.5",r:"1.5"})]})}function re(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("path",{d:"M8 12h8M12 8v8"})]})}const ae={textInput:void 0,output:void 0,chat:"text",imageGen:"image",tts:"tts",music:"music",video:"video",compose:void 0},le={textInput:{label:"Input",Icon:Z,color:"border-blue-500/30",hasInput:!1,hasOutput:!0},chat:{label:"LLM",Icon:ee,color:"border-purple-500/30",hasInput:!0,hasOutput:!0},imageGen:{label:"Image Gen",Icon:te,color:"border-pink-500/30",hasInput:!0,hasOutput:!0},tts:{label:"Text to Speech",Icon:oe,color:"border-green-500/30",hasInput:!0,hasOutput:!0},music:{label:"Music Gen",Icon:se,color:"border-yellow-500/30",hasInput:!0,hasOutput:!0},video:{label:"Video Gen",Icon:ne,color:"border-orange-500/30",hasInput:!0,hasOutput:!0},compose:{label:"Cover + Track",Icon:ie,color:"border-cyan-500/30",hasInput:!0,hasOutput:!0},output:{label:"Output",Icon:re,color:"border-white/20",hasInput:!0,hasOutput:!1}},x="nodrag bg-white/[0.03] border border-white/[0.06] rounded px-1.5 py-0.5 text-[12px] text-white/40 outline-none",I="nodrag w-full bg-white/[0.03] border border-white/[0.06] rounded-md px-2 py-1 text-[13px] text-white/60 outline-none placeholder:text-white/15";function ce({nodeType:n,value:t,onChange:i}){const s=ae[n],{data:d}=F(s),p=d?.map(u=>({value:u.id,label:u.model_spec?.name||u.id}))??[];return e.jsx("div",{className:"nodrag",children:e.jsx(z,{value:t,onChange:i,options:p,searchable:!0,placeholder:"Select model...",className:"w-full [&_button]:!py-1 [&_button]:!text-[13px] [&_button]:!px-2"})})}function de({id:n,data:t}){const i=le[t.nodeType],s=f(o=>o.runResults[n]),{setNodes:d}=P(),[p,u]=h.useState(!1),r=h.useCallback(o=>{d(m=>m.map(g=>g.id===n?{...g,data:{...g.data,...o}}:g))},[n,d]),a=h.useCallback(()=>{d(o=>o.filter(m=>m.id!==n))},[n,d]);return e.jsxs("div",{className:w("rounded-xl border-2 bg-[#111] shadow-xl min-w-[300px] max-w-[340px]",i.color,s?.status==="running"&&"ring-2 ring-white/20 animate-pulse",s?.status==="done"&&"ring-2 ring-green-500/30",s?.status==="error"&&"ring-2 ring-red-500/30"),children:[i.hasInput&&e.jsx(S,{type:"target",position:O.Top,className:"!w-3 !h-3 !bg-white/30 !border-2 !border-[#111]"}),e.jsxs("div",{className:"flex items-center gap-2 px-4 py-3 border-b border-white/[0.06]",children:[e.jsx("span",{className:"text-white/30",children:e.jsx(i.Icon,{})}),e.jsx("span",{className:"text-[14px] font-medium text-white/60",children:i.label}),s?.status===Line truncated
@@ -0,0 +1,38 @@
import{r as w,j as e,c as v,u as Q,S as X,t as $,g as V}from"./index-fmJ2VqHZ.js";import{u as C,a as Y,H as M,P as A,N as F,R as Z,b as ee,c as te,d as oe,i as ne,B as se,e as re,C as ie,M as ae,f as le}from"./workflow-engine-BuXGtBGn.js";function ce(){return e.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("polyline",{points:"22 12 16 12 14 15 10 9 8 12 2 12"})})}function de(){return e.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("path",{d:"M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"})})}function pe(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("circle",{cx:"8.5",cy:"8.5",r:"1.5"}),e.jsx("polyline",{points:"21 15 16 10 5 21"})]})}function ue(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polygon",{points:"11 5 6 9 2 9 2 15 6 15 11 19 11 5"}),e.jsx("path",{d:"M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"})]})}function he(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("path",{d:"M9 18V5l12-2v13"}),e.jsx("circle",{cx:"6",cy:"18",r:"3"}),e.jsx("circle",{cx:"18",cy:"16",r:"3"})]})}function xe(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polygon",{points:"23 7 16 12 23 17 23 7"}),e.jsx("rect",{x:"1",y:"5",width:"15",height:"14",rx:"2"})]})}function me(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("path",{d:"M3 15l5-5 4 4"}),e.jsx("path",{d:"M14 17v-5l4-1v5"}),e.jsx("circle",{cx:"12.5",cy:"17.5",r:"1.5"}),e.jsx("circle",{cx:"16.5",cy:"16.5",r:"1.5"})]})}function ge(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polyline",{points:"4 17 10 11 4 5"}),e.jsx("line",{x1:"12",y1:"19",x2:"20",y2:"19"})]})}function fe(){return e.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("path",{d:"M8 12h8M12 8v8"})]})}const we={textInput:void 0,output:void 0,chat:"text",imageGen:"image",tts:"tts",music:"music",video:"video",compose:void 0,step:void 0},be={textInput:{label:"Input",Icon:ce,color:"border-blue-500/30",hasInput:!1,hasOutput:!0},chat:{label:"LLM",Icon:de,color:"border-purple-500/30",hasInput:!0,hasOutput:!0},imageGen:{label:"Image Gen",Icon:pe,color:"border-pink-500/30",hasInput:!0,hasOutput:!0},tts:{label:"Text to Speech",Icon:ue,color:"border-green-500/30",hasInput:!0,hasOutput:!0},music:{label:"Music Gen",Icon:he,color:"border-yellow-500/30",hasInput:!0,hasOutput:!0},video:{label:"Video Gen",Icon:xe,color:"border-orange-500/30",hasInput:!0,hasOutput:!0},compose:{label:"Cover + Track",Icon:me,color:"border-cyan-500/30",hasInput:!0,hasOutput:!0},step:{label:"Step",Icon:ge,color:"border-white/20",hasInput:!0,hasOutput:!0},output:{label:"Output",Icon:fe,color:"border-white/20",hasInput:!0,hasOutput:!1}},f="nodrag bg-white/[0.03] border border-white/[0.06] rounded px-1.5 py-0.5 text-[12px] text-white/40 outline-none",N="nodrag w-full bg-white/[0.03] border border-white/[0.06] rounded-md px-2 py-1 text-[13px] text-white/60 outline-none placeholder:text-white/15";function ve({nodeType:n,value:t,onChange:r}){const s=we[n],{data:c}=Q(s),a=c?.map(i=>({value:i.id,label:i.model_spec?.name||i.id}))??[];return e.jsx("div",{className:"nodrag",children:e.jsx(X,{value:t,onChange:r,options:a,searchable:!0,placeholder:"Select model...",className:"w-full [&_button]:!py-1 [&_button]:!text-[13px] [&_button]:!px-2"})})}function je({id:n,data:t}){const r=be[t.nodeType],s=C(o=>o.runResults[n]),{setNodes:c}=Y(),[a,i]=w.useState(!1),l=w.useCallback(o=>{c(p=>p.map(h=>h.id===n?{...h,data:{...h.data,...o}}:h))},[n,c]),d=w.useCallback(()=>{c(o=>o.filter(p=>p.id!==n))},[n,c]);return e.jsxs("div",{className:v("rounded-xl border-2 bg-[#111] shadow-xl min-w-[300px] max-w-[340px]",r.color,s?.status==="running"&&"ring-2 ring-white/20 animate-pulse",s?.status==="done"&&"ring-2 ring-green-500/30",s?.status==="error"&&"ring-2 ring-red-500/30")Line truncated
`+t.with,t.stepEnv&&`env:
`+t.stepEnv].filter(Boolean).join(`
`)}),e.jsx("p",{className:"text-[12px] text-white/30 leading-snug",children:"Runs on the factory, not here: on export this is the step as written."})]}):t.nodeType==="compose"?e.jsxs("div",{className:"flex flex-col gap-1.5",children:[e.jsx("p",{className:"text-[13px] text-white/40 leading-snug",children:"Shows the connected image while the connected audio plays, recorded here in your browser into one video (takes as long as the track)."}),e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("span",{className:"text-[12px] text-white/30",children:"max side"}),e.jsx("input",{type:"number",min:256,max:2048,step:2,value:t.maxSide??1080,onChange:o=>l({maxSide:Number(o.target.value)||1080}),className:v(N,"w-24")}),e.jsx("span",{className:"text-[12px] text-white/30",children:"px"})]})]}):t.nodeType==="textInput"?e.jsx("textarea",{value:t.inputText??"",onChange:o=>l({inputText:o.target.value}),placeholder:"Enter starting text...",rows:3,className:"nodrag nowheel w-full bg-white/[0.03] border border-white/[0.06] rounded-md px-2 py-1.5 text-[14px] text-white/70 outline-none resize-none placeholder:text-white/15"}):e.jsxs(e.Fragment,{children:[e.jsx(ve,{nodeType:t.nodeType,value:t.model,onChange:o=>l({model:o})}),e.jsx("textarea",{value:t.prompt,onChange:o=>l({prompt:o.target.value}),placeholder:"Instructions for this step...",rows:2,className:"nodrag nowheel w-full bg-white/[0.03] border border-white/[0.06] rounded-md px-2 py-1.5 text-[14px] text-white/70 outline-none resize-none placeholder:text-white/15"}),t.nodeType==="chat"&&e.jsxs("div",{className:"flex flex-wrap gap-1.5",children:[e.jsxs("select",{value:t.webSearch??"off",onChange:o=>l({webSearch:o.target.value}),className:f,children:[e.jsx("option",{value:"off",children:"Search off"}),e.jsx("option",{value:"on",children:"Search on"}),e.jsx("option",{value:"auto",children:"Search auto"})]}),e.jsx("input",{type:"number",value:t.temperature??.7,onChange:o=>l({temperature:parseFloat(o.target.value)}),step:.1,min:0,max:2,className:v(f,"w-14"),title:"Temperature"}),e.jsx("input",{type:"number",value:t.maxTokens??4096,onChange:o=>l({maxTokens:parseInt(o.target.value)}),step:256,min:64,max:32768,className:v(f,"w-[72px]"),title:"Max tokens"})]}),t.nodeType==="imageGen"&&e.jsxs(e.Fragment,{children:[e.jsx("input",{value:t.negativePrompt??"",onChange:o=>l({negativePrompt:o.target.value}),placeholder:"Negative prompt...",className:N}),e.jsxs("div",{className:"flex gap-1.5",children:[e.jsxs("div",{className:"flex-1",children:[e.jsx("label",{className:"text-[11px] text-white/20 mb-0.5 block",children:"Steps"}),e.jsx("input",{type:"number",value:t.steps??20,onChange:o=>l({steps:parseInt(o.target.value)}),min:1,max:50,className:f+" w-full"})]}),e.jsxs("div",{className:"flex-1",children:[e.jsx("label",{className:"text-[11px] text-white/20 mb-0.5 block",children:"Size"}),e.jsxs("select",{value:`${t.width??1024}x${t.height??1024}`,onChange:o=>{const[p,h]=o.target.value.split("x").map(Number);l({width:p,height:h})},className:f+" w-full",children:[e.jsx("option",{value:"512x512",children:"512"}),e.jsx("option",{value:"768x768",children:"768"}),e.jsx("option",{value:"1024x1024",children:"1024"}),e.jsx("option",{value:"1280x1280",children:"1280"})]})]})]}),e.jsxs("div",{className:"flex gap-1.5",children:[e.jsx("input",{value:t.style??"",onChange:o=>l({style:o.target.value}),placeholder:"Style preset...",className:v(N,"flex-1")}),e.jsxs("label",{className:"nodrag flex items-center gap-1 text-[12px] text-white/30 cursor-pointer shrink-0",children:[e.jsx("input",{type:"checkbox",checked:t.hideWatermark??!0,onChange:o=>l({hideWatermark:o.target.checked}),className:"nodrag w-3 h-3 accent-white/50"}),"No WM"]})]})]}),t.nodeType==="tts"&&e.jsxs("div",{className:"flex flex-wrap gap-1.5",children:[e.jsxs("div",{className:"flex-1 min-w-[80px]",children:[e.jsx("label",{className:"text-[11px] text-white/20 mb-0.5 block",children:"Voice"}),e.jsx("input",{value:t.voice??"af_sky",onChange:o=>l({voice:o.target.value}),placeholder:"Voice ID",className:N})]}),e.jsxs("div",{className:"w-14",children:[e.jsx("label",{className:"text-[11px] text-white/20 mb-0.5 block",children:"Speed"}),e.jsx("input",{type:"number",value:t.speed??1,onChange:o=>l({speed:parseFloat(o.target.value)}),step:.25,min:.25,max:4,className:f+" w-full"})]}),e.jsxs("div",{className:"w-16",children:[e.jsx("label",{className:"text-[11px] text-white/20 mb-0.5 block",children:"Format"}),e.jsxs("select",{value:t.responseFormat??"mp3",onChange:o=>l({responseFormat:o.target.value}),className:f+" w-full",children:[e.jsx("option",{value:"mp3",children:"MP3"}),e.jsx("option",{value:"opus",children:"Opus"}),e.jsx("option",{value:"aac",children:"AAC"}),e.jsx("option",{value:"flac",children:"FLAC"}),e.jsx("option",{value:"wav",children:"WAV"})]})]})]}),t.nodeType==="music"&&e.jsxs(e.Fragment,{children:[e.jsx("textarea",{value:t.lyrics??"",onChange:o=>l({lyrics:o.target.value}),placeholder:"Lyrics (optional)...",rows:2,className:"n Line truncated
`).map(r=>r.trim()?" ".repeat(t)+r:r).join(`
`);function _(n){if(n.stepYaml&&n.stepYaml.trim())return n.stepYaml.replace(/\s+$/,"");const t=[];n.label&&n.label!=="Step"&&t.push(`- name: ${b(n.label)}`);const r=t.length?" ":"- ";let s=!t.length;const c=()=>{const i=s?r:" ";return s=!1,i};n.uses&&t.push(`${c()}uses: ${n.uses}`);const a=(n.run??"").replace(/\s+$/,"");a&&(a.includes(`
`)?t.push(`${c()}run: |`,...a.split(`
`).map(i=>i?" "+i:"")):t.push(`${c()}run: ${a}`));for(const[i,l]of[["with",n.with],["env",n.stepEnv]]){const d=(l??"").replace(/\s+$/,"");d&&t.push(`${c()}${i}:`,...d.split(`
`).map(o=>o?" "+o:""))}return t.length||t.push(`- run: ${b(n.label||"step")}`),t.join(`
`)}function W(n){const t=n.split(`
`),r={},s=p=>{const h=new RegExp(`^(?:- | )${p}:\\s*(.*?)\\s*$`,"m").exec(n);if(!h)return;const u=h[1];return/^".*"$/.test(u)?JSON.parse(u):/^'.*'$/.test(u)?u.slice(1,-1).replace(/''/g,"'"):u},c=p=>{const h=t.findIndex(g=>new RegExp(`^(?:- | )${p}:\\s*(\\|[-+]?|>[-+]?)?\\s*$`).test(g));if(h<0)return;const u=[];for(let g=h+1;g<t.length;g++){const j=t[g];if(j.trim()&&!/^\s{4}/.test(j))break;u.push(j.slice(4))}return u.join(`
`).replace(/\s+$/,"")},a=s("name");a&&(r.label=a);const i=s("uses");i&&(r.uses=i);const l=c("run")??s("run");l&&(r.run=l);const d=c("with");d&&(r.with=d);const o=c("env");return o&&(r.stepEnv=o),r}function $e(n){const t=n.replace(/\r\n?/g,`
`).split(`
`),r=t.findIndex(u=>/^\s+steps:\s*(#.*)?$/.test(u));if(r<0)return null;let s=r+1;for(;s<t.length&&(!t[s].trim()||/^\s*#/.test(t[s]));)s++;const c=/^(\s*)- /.exec(t[s]??"");if(!c)return null;const a=c[1].length,i=[];let l=s;for(;l<t.length;l++){const u=t[l];if(u.trim()&&!/^\s*#/.test(u)){const g=/^\s*/.exec(u)[0].length;if(g<a)break;if(g===a){if(!u.startsWith(" ".repeat(a)+"- "))break;i.push([])}}i.length&&i[i.length-1].push(u.slice(a))}const d=t.slice(0,s).filter(u=>!/^\s*OV_WORKFLOW:/.test(u)).join(`
`),o=t.slice(l).join(`
`).replace(/\s+$/,""),p=/^name:\s*(.*?)\s*$/m.exec(d),h=p?/^".*"$/.test(p[1])?JSON.parse(p[1]):p[1].replace(/^'|'$/g,""):"";return{frame:{pre:d.replace(/^env:\s*\n(?=\S)/m,""),post:o,indent:a},items:i.map(u=>u.join(`
`).replace(/\s+$/,"")),name:h}}function B(n){const t=["- uses: actions/setup-node@v4"," with:"," node-version: 22"];return n&&t.push("- name: ffmpeg, for Cover + Track"," run: |"," command -v ffmpeg >/dev/null || sudo apt-get install -y -qq ffmpeg >/dev/null || apt-get install -y -qq ffmpeg >/dev/null"),t.push("- name: the runner"," run: |",` curl -fsSLo run.mjs ${b(ke)}`),R.startsWith("__")||t.push(` echo "${R} run.mjs" | sha256sum -c -`),t.push(" mkdir -p out"),t}function G(n,t){const r=F[n.data.nodeType],s=[`- name: ${b(`${n.id} (${r?.label??n.data.nodeType})`)}`];return n.data.nodeType==="textInput"?s.push(" run: |",` printf '%s' "$INPUT" > out/${n.id}.txt && echo "${n.id} = $INPUT"`," env:",` INPUT: \${{ inputs.${n.id} }}`):(s.push(` run: node run.mjs --node ${b(n.id)}`),t&&s.push(" env:"," VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}")),s}function Le(n){const t=Ie(n),r=t.some(o=>o.data.nodeType==="compose"),s=t.filter(o=>o.data.nodeType==="output"),c=`${E}: ${b(Ce(JSON.stringify(Ne(n))))}`;if(n.frame){let o=n.frame.pre.replace(/\s+$/,"");o=/^env:\s*$/m.test(o)?o.replace(/^env:\s*$/m,`env:
${c}`):o.replace(/^(jobs:\s*)$/m,`env:
${c}
$1`),o.includes(E)||(o+=`
env:
${c}`);const p=[];let h=!1;for(const g of t){if(g.data.nodeType==="step"){p.push(_(g.data));continue}h||(p.push(B(r).join(`
`)),h=!0),p.push(G(g,!0).join(`
`))}s.length&&p.push(["- name: outputs"," run: |",...s.map(g=>` echo "== ${g.id} =="; cat out/${g.id}.txt; echo`)].join(`
`));const u=p.map(g=>P(g,n.frame.indent)).join(`
`);return o+`
`+u+(n.frame.post?`
`+n.frame.post:"")+`
`}const a=t.filter(o=>o.data.nodeType==="textInput"),i=[];if(i.push(`name: ${b(n.name)}`),i.push("# An openvenice workflow, exported from https://havre.yolopreneur.lol/open/ (Workflows → Export)."),i.push(`# Import the file there to edit it on the canvas; the graph rides in env.${E} below.`),i.push("# Runs on the Venice API with the secret VENICE_API_KEY. Outputs land in out/ (and an artifact, where the runner keeps them)."),i.push("on:"),i.push(" workflow_dispatch:"),a.length){i.push(" inputs:");for(const o of a)i.push(` ${o.id}:`),i.push(` description: ${b(`Input "${o.data.label||o.id}"`)}`),i.push(" required: false"),i.push(` default: ${b(o.data.inputText??"")}`)}i.push(" # schedule: # uncomment to run on a timer, e.g. every morning at 8:"),i.push(' # - cron: "0 8 * * *"'),i.push("env:"),i.push(` ${c}`),i.push("jobs:"),i.push(" run:"),i.push(" runs-on: ubuntu-latest"),i.push(" timeout-minutes: 60"),i.push(" env:"),i.push(" VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}"),i.push(" steps:");const l=[],d=t.some(o=>o.data.nodeType!=="step");d&&l.push(B(r).join(`
`));for(const o of t)l.push(o.data.nodeType==="step"?_(o.data):G(o,!1).join(`
`));return s.length&&l.push(["- name: outputs"," run: |",...s.map(o=>` echo "== ${o.id} =="; cat out/${o.id}.txt; echo`)].join(`
`)),d&&l.push(["- uses: actions/upload-artifact@v4"," if: always()"," continue-on-error: true"," with:",` name: ${b(D(n.name))}`," path: out/"].join(`
`)),i.push(P(l.join(`
`),6)),i.join(`
`)+`
`}function Ee(n){const t=n.trim();let r=null;const s=t.startsWith("{")?null:$e(t);if(t.startsWith("{"))try{r=JSON.parse(t)}catch{return null}else{const o=new RegExp(`^\\s*${E}:\\s*(?:"([^"]+)"|'([^']+)'|(\\S+))\\s*$`,"m").exec(t);if(o)try{r=JSON.parse(Te(o[1]??o[2]??o[3]))}catch{r=null}}if(!r&&s){const o=s.items.map((h,u)=>({id:`step${u+1}`,type:"venice",position:{x:280,y:40+u*180},data:{label:"Step",nodeType:"step",model:"",prompt:"",...W(h),stepYaml:h}})),p=o.slice(1).map((h,u)=>({id:`e${u}-${o[u].id}-${h.id}`,source:o[u].id,target:h.id,animated:!0}));return o.length?{name:s.name||"Imported workflow",nodes:o,edges:p,frame:s.frame}:null}if(!r||typeof r!="object")return null;const c=r;if(!Array.isArray(c.nodes)||!Array.isArray(c.edges))return null;const a=c.nodes.filter(o=>o&&typeof o.id=="string"&&o.data&&typeof o.data=="object"&&typeof o.data.nodeType=="string"&&o.data.nodeType in F).map((o,p)=>({id:o.id,type:"venice",position:o.position&&typeof o.position=="object"?o.position:{x:280,y:40+p*200},data:{...o.data}})),i=new Set(a.map(o=>o.id)),l=c.edges.filter(o=>o&&typeof o.source=="string"&&typeof o.target=="string"&&i.has(o.source)&&i.has(o.target)).map((o,p)=>({id:typeof o.id=="string"?o.id:`e${p}-${o.source}-${o.target}`,source:o.source,target:o.target,animated:!0}));if(!a.length)return null;let d=c.frame&&typeof c.frame=="object"&&typeof c.frame.pre=="string"?c.frame:void 0;if(s&&d){d=s.frame;const o=new Map(s.items.map(p=>[W(p).label??"",p]));for(const p of a){if(p.data.nodeType!=="step")continue;const h=o.get(p.data.label)??(p.data.stepYaml?s.items.find(u=>u.split(`
`)[0]===p.data.stepYaml.split(`
`)[0]):void 0);h&&Object.assign(p.data,W(h),{stepYaml:h})}}return{name:typeof c.name=="string"&&c.name.trim()?c.name.trim():"Imported workflow",nodes:a,edges:l,...d?{frame:d}:{}}}function Se(n,t,r="text/yaml"){const s=document.createElement("a");s.href=URL.createObjectURL(new Blob([t],{type:r})),s.download=n,s.click(),setTimeout(()=>URL.revokeObjectURL(s.href),2e3)}const We={venice:ye};function Oe(){return e.jsx("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("polyline",{points:"22 12 16 12 14 15 10 9 8 12 2 12"})})}function Me(){return e.jsx("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("path",{d:"M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"})})}function Ae(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("circle",{cx:"8.5",cy:"8.5",r:"1.5"}),e.jsx("polyline",{points:"21 15 16 10 5 21"})]})}function Re(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polygon",{points:"11 5 6 9 2 9 2 15 6 15 11 19 11 5"}),e.jsx("path",{d:"M15.54 8.46a5 5 0 010 7.07"})]})}function Pe(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("path",{d:"M9 18V5l12-2v13"}),e.jsx("circle",{cx:"6",cy:"18",r:"3"}),e.jsx("circle",{cx:"18",cy:"16",r:"3"})]})}function _e(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polygon",{points:"23 7 16 12 23 17 23 7"}),e.jsx("rect",{x:"1",y:"5",width:"15",height:"14",rx:"2"})]})}function Be(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("path",{d:"M3 15l5-5 4 4"}),e.jsx("path",{d:"M14 17v-5l4-1v5"}),e.jsx("circle",{cx:"12.5",cy:"17.5",r:"1.5"}),e.jsx("circle",{cx:"16.5",cy:"16.5",r:"1.5"})]})}function Ge(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("polyline",{points:"4 17 10 11 4 5"}),e.jsx("line",{x1:"12",y1:"19",x2:"20",y2:"19"})]})}function Ve(){return e.jsxs("svg",{width:"11",height:"11",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("rect",{x:"3",y:"3",width:"18",height:"18",rx:"2"}),e.jsx("path",{d:"M8 12h8M12 8v8"})]})}const Ye=[{type:"textInput",label:"Input",Icon:Oe,color:"text-blue-400/50"},{type:"chat",label:"LLM",Icon:Me,color:"text-purple-400/50"},{type:"imageGen",label:"Image Gen",Icon:Ae,color:"text-pink-400/50"},{type:"tts",label:"Text to Speech",Icon:Re,color:"text-green-400/50"},{type:"music",label:"Music Gen",Icon:Pe,color:"text-yellow-400/50"},{type:"video",label:"Video Gen",Icon:_e,color:"text-orange-400/50"},{type:"compose",label:"Cover + Track",Icon:Be,color:"text-cyan-400/50"},{type:"step",label:"Step",Icon:Ge,color:"text-white/40"},{type:"output",label:"Output",Icon:Ve,color:"text-white/40"}],Fe={textInput:"",output:"",chat:"llama-3.3-70b",imageGen:"z-image-turbo",tts:"tts-kokoro",music:"stable-audio",video:"wan-2.1",compose:"",step:""},I=n=>Array.from({length:n},()=>V()),m=(n,t)=>({id:`e-${n}-${t}`,source:n,target:t,animated:!0}),De=[{name:"Album Cover",desc:"A song concept → visual art direction → cover artwork",build:()=>{const[n,t,r,s]=I(4);return{nodes:[{id:n,type:"venice",position:{x:280,y:40},data:{label:"Input",nodeType:"textInput",model:"",prompt:"",inputText:"A melancholic indie-folk album about leaving a small coastal town"}},{id:t,type:"venice",position:{x:280,y:220},data:{label:"LLM",nodeType:"chat",model:"llama-3.3-70b",prompt:"You are an art director. Given this album concept, write a vivid, specific image-generation prompt for the cover art — mood, color palette, composition, subject, style. Output only the prompt.",temperature:.9}},{id:r,type:"venice",position:{x:280,y:440},data:{label:"Image Gen",nodeType:"imageGen",model:"z-image-turbo",prompt:"",steps:30,width:1024,height:1024}},{id:s,type:"venice",position:{x:280,y:680},data:{label:"Output",nodeType:"output",model:"",prompt:""}}],edges:[m(n,t),m(t,r),m(r,s)]}}},{name:"Podcast Episode",desc:"Topic → web research → tight 90-second script → narrated audio",build:()=>{const[n,t,r,s,c]=I(5);return{nodes:[ Line truncated
+2 -2
View File
@@ -17,8 +17,8 @@
/>
<title>OpenVenice — clean UI for the Venice AI API</title>
<link rel="icon" type="image/svg+xml" href="/open/favicon.svg" />
<script type="module" crossorigin src="/open/assets/index-CmWoX_gW.js"></script>
<link rel="stylesheet" crossorigin href="/open/assets/index-iJLlckSX.css">
<script type="module" crossorigin src="/open/assets/index-fmJ2VqHZ.js"></script>
<link rel="stylesheet" crossorigin href="/open/assets/index-DaJeA1VZ.css">
</head>
<body class="antialiased">
<div id="root"></div>
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
// run.mjs: an openvenice workflow, one node at a time, headless. The same graph the canvas at /open runs in a
// browser, run on a machine: a Gitea Actions runner, your laptop, a cron. Node 20+, no dependencies; ffmpeg for a
// Cover + Track node.
//
// node run.mjs --graph graph.json --node <id> [--out out] one node: reads its parents' outputs from out/,
// writes out/<id>.txt (and a file for media)
// node run.mjs --graph graph.json --all [--out out] every node, in dependency order
// OV_WORKFLOW=<base64 json> node run.mjs --node <id> the graph as the exported workflow carries it
//
// Needs VENICE_API_KEY. A node's output is one string, exactly as in the browser engine: text, or [image:<file>],
// [audio:<file>], [video:<file>] for media; a node with several parents gets their outputs joined with blank lines.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const BASE = process.env.VENICE_BASE_URL || "https://api.venice.ai/api/v1";
const KEY = process.env.VENICE_API_KEY || "";
const POLL_MS = Number(process.env.OV_POLL_MS || 3000), POLL_MAX = Number(process.env.OV_POLL_MAX || 200);
const args = process.argv.slice(2);
const flag = (name, dflt = "") => { const i = args.indexOf("--" + name); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : dflt; };
const has = (name) => args.includes("--" + name);
const OUT = flag("out", "out");
function loadGraph() {
const path = flag("graph");
const raw = path ? readFileSync(path, "utf8") : process.env.OV_WORKFLOW ? Buffer.from(process.env.OV_WORKFLOW, "base64").toString("utf8") : "";
if (!raw) fail("no graph: --graph graph.json, or OV_WORKFLOW in the environment");
const g = JSON.parse(raw);
const nodes = Array.isArray(g.nodes) ? g.nodes : [], edges = Array.isArray(g.edges) ? g.edges : [];
if (!nodes.length) fail("the graph has no nodes");
return { name: g.name || "workflow", nodes, edges };
}
function fail(msg) { console.error("run.mjs: " + msg); process.exit(1); }
async function venice(path, body, { binary = false } = {}) {
if (!KEY) fail("VENICE_API_KEY is not set");
for (let attempt = 0; ; attempt++) {
const res = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" }, body: JSON.stringify(body) });
if ([408, 425, 429, 500, 502, 503, 504].includes(res.status) && attempt < 2) { await sleep(1500 * (attempt + 1)); continue; }
if (!res.ok) {
let detail = "";
try { detail = (await res.text()).slice(0, 400); } catch { /* none */ }
throw new Error(`Venice ${res.status} on ${path}${detail ? ": " + detail : ""}`);
}
const type = res.headers.get("content-type") || "";
if (binary || !/json/i.test(type)) return { binary: Buffer.from(await res.arrayBuffer()), type };
return { json: await res.json(), type };
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** a queued music or video job, asked about until it's done; the file, however Venice sends it */
async function retrieve(kind, model, queueId, downloadUrl) {
for (let i = 0; i < POLL_MAX; i++) {
await sleep(POLL_MS);
const r = await venice(`/${kind}/retrieve`, { model, queue_id: queueId });
if (r.binary) return { bytes: r.binary, type: r.type };
const d = r.json || {}, status = String(d.status || "").toUpperCase(), ready = d.audio_url || d.video_url || d.url;
if (status === "COMPLETED") {
const url = ready || downloadUrl;
if (!url) throw new Error("completed, but Venice sent no file");
const f = await fetch(url);
if (!f.ok) throw new Error(`download failed (${f.status})`);
return { bytes: Buffer.from(await f.arrayBuffer()), type: f.headers.get("content-type") || "" };
}
if (/FAIL|ERROR|CANCEL|REJECT|EXPIRE/.test(status)) throw new Error(d.error || `generation ${status.toLowerCase()}`);
}
throw new Error(`timed out after ${(POLL_MS * POLL_MAX) / 1000}s`);
}
const ext = (type, dflt) => (/mp4/.test(type) ? "mp4" : /webm/.test(type) ? "webm" : /mpeg|mp3/.test(type) ? "mp3" : /wav/.test(type) ? "wav" : /ogg|opus/.test(type) ? "ogg" : /flac/.test(type) ? "flac" : /png/.test(type) ? "png" : /jpe?g/.test(type) ? "jpg" : /webp/.test(type) ? "webp" : dflt);
function resolvePrompt(template, input) {
if (!template) return input;
if (template.includes("{{input}}")) return template.replace(/\{\{input\}\}/g, input);
return input ? `${template}\n\n${input}` : template;
}
function inputsOf(node, graph) {
return graph.edges.filter((e) => e.target === node.id).map((e) => {
const p = join(OUT, e.source + ".txt");
return existsSync(p) ? readFileSync(p, "utf8") : "";
}).filter(Boolean).join("\n\n");
}
const mediaPath = (s, tag) => { const m = new RegExp(`\\[${tag}:([^\\]]+)\\]`).exec(s); return m ? m[1] : ""; };
async function runNode(node, graph) {
const d = node.data || {}, input = inputsOf(node, graph), file = (e) => join(OUT, `${node.id}.${e}`);
switch (d.nodeType) {
case "textInput": return d.inputText ?? "";
case "output": return input;
case "step": return input; // a runner's own step: the workflow file runs it, not this
case "chat": {
const r = await venice("/chat/completions", { model: d.model || "llama-3.3-70b", messages: [{ role: "user", content: resolvePrompt(d.prompt, input) }],
temperature: d.temperature ?? 0.7, max_tokens: d.maxTokens ?? 4096, venice_parameters: { enable_web_search: d.webSearch ?? "off" } });
return r.json?.choices?.[0]?.message?.content ?? "";
}
case "imageGen": {
const body = { model: d.model || "z-image-turbo", prompt: resolvePrompt(d.prompt, input), negative_prompt: d.negativePrompt || undefined, steps: d.steps ?? 20,
style_preset: d.style || undefined, width: d.width ?? 1024, height: d.height ?? 1024, hide_watermark: d.hideWatermark ?? true };
if (d.aspectRatio) body.aspect_ratio = d.aspectRatio;
const r = await venice("/image/generate", body);
const img = r.json?.images?.[0], b64 = typeof img === "string" ? img : img?.b64_json;
if (!b64) throw new Error("no image came back");
const e = b64.startsWith("/9j/") ? "jpg" : b64.startsWith("UklGR") ? "webp" : "png";
writeFileSync(file(e), Buffer.from(b64, "base64"));
return `[image:${file(e)}]`;
}
case "tts": {
const r = await venice("/audio/speech", { model: d.model || "tts-kokoro", input: resolvePrompt(d.prompt, input), voice: d.voice || "af_sky", speed: d.speed ?? 1,
response_format: d.responseFormat || "mp3" }, { binary: true });
const e = d.responseFormat || ext(r.type, "mp3");
writeFileSync(file(e), r.binary);
return `[audio:${file(e)}]`;
}
case "music": {
const body = { model: d.model || "stable-audio-25", prompt: resolvePrompt(d.prompt, input), duration_seconds: d.duration ?? 30, force_instrumental: d.instrumental ?? false };
if (d.lyrics) body.lyrics_prompt = d.lyrics;
const q = (await venice("/audio/queue", body)).json || {};
const { bytes, type } = await retrieve("audio", q.model || body.model, q.queue_id, q.download_url);
const e = ext(type, "mp3");
writeFileSync(file(e), bytes);
return `[audio:${file(e)}]`;
}
case "video": {
const body = { model: d.model || "wan-3-0-text-to-video", prompt: resolvePrompt(d.prompt, input), aspect_ratio: d.videoAspectRatio || "16:9" };
if (d.videoDuration) body.duration = d.videoDuration;
if (d.videoResolution) body.resolution = d.videoResolution;
const q = (await venice("/video/queue", body)).json || {};
const { bytes, type } = await retrieve("video", q.model || body.model, q.queue_id || q.id, q.download_url);
const e = ext(type, "mp4");
writeFileSync(file(e), bytes);
return `[video:${file(e)}]`;
}
case "compose": {
const image = mediaPath(input, "image"), audio = mediaPath(input, "audio");
if (!image || !audio) throw new Error("Cover + Track needs one image node and one audio node connected to it");
const side = Math.max(256, Math.min(2048, Number(d.maxSide) || 1080));
const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", "-loop", "1", "-i", image, "-i", audio, "-c:v", "libx264", "-tune", "stillimage", "-preset", "medium",
"-vf", `scale='if(gt(iw,ih),${side},-2)':'if(gt(iw,ih),-2,${side})',pad=ceil(iw/2)*2:ceil(ih/2)*2`, "-c:a", "aac", "-b:a", "160k", "-pix_fmt", "yuv420p", "-shortest", "-movflags", "+faststart", file("mp4")], { encoding: "utf8" });
if (r.error) throw new Error("ffmpeg is not installed (a Cover + Track node needs it)");
if (r.status !== 0) throw new Error("ffmpeg: " + (r.stderr || "").trim().slice(-400));
return `[video:${file("mp4")}]`;
}
default: throw new Error(`unknown node type ${d.nodeType}`);
}
}
/** nodes in dependency order (a cycle is an error) */
function order(graph) {
const indeg = new Map(graph.nodes.map((n) => [n.id, 0])), adj = new Map(graph.nodes.map((n) => [n.id, []]));
for (const e of graph.edges) { if (!indeg.has(e.source) || !indeg.has(e.target)) continue; adj.get(e.source).push(e.target); indeg.set(e.target, indeg.get(e.target) + 1); }
const out = [];
let frontier = graph.nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id);
while (frontier.length) {
out.push(...frontier);
const next = [];
for (const id of frontier) for (const c of adj.get(id)) { indeg.set(c, indeg.get(c) - 1); if (indeg.get(c) === 0) next.push(c); }
frontier = next;
}
if (out.length !== graph.nodes.length) fail("the graph has a cycle");
return out.map((id) => graph.nodes.find((n) => n.id === id));
}
async function main() {
const graph = loadGraph();
mkdirSync(OUT, { recursive: true });
const which = has("all") ? order(graph) : [graph.nodes.find((n) => n.id === flag("node"))].filter(Boolean);
if (!which.length) fail(`no such node: ${flag("node")} (nodes: ${graph.nodes.map((n) => n.id).join(", ")})`);
for (const node of which) {
const label = `${node.id} [${node.data?.nodeType}]`;
process.stdout.write(`${label}… `);
const started = Date.now();
const result = await runNode(node, graph);
writeFileSync(join(OUT, node.id + ".txt"), result);
const shown = result.startsWith("[") ? result : result.replace(/\s+/g, " ").slice(0, 120) + (result.length > 120 ? "…" : "");
console.log(`${((Date.now() - started) / 1000).toFixed(1)}s ${shown}`);
}
}
main().catch((e) => fail(e.message || String(e)));
+9
View File
@@ -24,6 +24,15 @@ has changed, so a newer upstream shows exactly which change needs redoing.
- **Light and dark**, with Havre's look: Times and Courier, square corners, Havre's blue. The choice is
`havre.theme` in localStorage, shared with Havre's own page ("light", "dark", or absent to follow the system).
- **Phones**: a labeled "New chat" button, and no ⌘ shortcut hints.
- **Workflows and Gitea Actions are two views of one thing.** Export (the active workflow's header) writes a
workflow file that walks the graph on a runner with `public/run.mjs` (served at /open/run.mjs); the graph rides in
`env.OV_WORKFLOW`. Import (the list view) takes any Actions workflow: one exported from here comes back as it was;
any other becomes a chain of **Step** nodes, one per step, and the rest of the file (`on`, permissions,
concurrency, the job's `runs-on` and `env`) is kept as the workflow's frame, so it exports back identical, with
any Venice nodes spliced in as runner steps. A Step node is one Actions step as it is (run, uses, with, env):
inert in the browser, verbatim on export. A **Cover + Track** node records an image over an audio track into one
video (in the browser; ffmpeg on a runner). The first real workflow is the factory's venture loop,
`.github/workflows/venture-loop.yml` on teahubxyz/time: one step per stage, each `venture.py stage <card> <at>`.
## Changing it
+633
View File
@@ -734,6 +734,639 @@ edit(S + "components/playground/playground-chat.tsx",
(" 'Write a short song with lyrics, then generate it as music',",
" 'Write a short song with lyrics, then generate it as music',\n 'Make a cover image and a music track from one idea, then combine them into one video where the cover shows while the track plays',"))
RUN_MJS = r"""#!/usr/bin/env node
// run.mjs: an openvenice workflow, one node at a time, headless. The same graph the canvas at /open runs in a
// browser, run on a machine: a Gitea Actions runner, your laptop, a cron. Node 20+, no dependencies; ffmpeg for a
// Cover + Track node.
//
// node run.mjs --graph graph.json --node <id> [--out out] one node: reads its parents' outputs from out/,
// writes out/<id>.txt (and a file for media)
// node run.mjs --graph graph.json --all [--out out] every node, in dependency order
// OV_WORKFLOW=<base64 json> node run.mjs --node <id> the graph as the exported workflow carries it
//
// Needs VENICE_API_KEY. A node's output is one string, exactly as in the browser engine: text, or [image:<file>],
// [audio:<file>], [video:<file>] for media; a node with several parents gets their outputs joined with blank lines.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const BASE = process.env.VENICE_BASE_URL || "https://api.venice.ai/api/v1";
const KEY = process.env.VENICE_API_KEY || "";
const POLL_MS = Number(process.env.OV_POLL_MS || 3000), POLL_MAX = Number(process.env.OV_POLL_MAX || 200);
const args = process.argv.slice(2);
const flag = (name, dflt = "") => { const i = args.indexOf("--" + name); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : dflt; };
const has = (name) => args.includes("--" + name);
const OUT = flag("out", "out");
function loadGraph() {
const path = flag("graph");
const raw = path ? readFileSync(path, "utf8") : process.env.OV_WORKFLOW ? Buffer.from(process.env.OV_WORKFLOW, "base64").toString("utf8") : "";
if (!raw) fail("no graph: --graph graph.json, or OV_WORKFLOW in the environment");
const g = JSON.parse(raw);
const nodes = Array.isArray(g.nodes) ? g.nodes : [], edges = Array.isArray(g.edges) ? g.edges : [];
if (!nodes.length) fail("the graph has no nodes");
return { name: g.name || "workflow", nodes, edges };
}
function fail(msg) { console.error("run.mjs: " + msg); process.exit(1); }
async function venice(path, body, { binary = false } = {}) {
if (!KEY) fail("VENICE_API_KEY is not set");
for (let attempt = 0; ; attempt++) {
const res = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" }, body: JSON.stringify(body) });
if ([408, 425, 429, 500, 502, 503, 504].includes(res.status) && attempt < 2) { await sleep(1500 * (attempt + 1)); continue; }
if (!res.ok) {
let detail = "";
try { detail = (await res.text()).slice(0, 400); } catch { /* none */ }
throw new Error(`Venice ${res.status} on ${path}${detail ? ": " + detail : ""}`);
}
const type = res.headers.get("content-type") || "";
if (binary || !/json/i.test(type)) return { binary: Buffer.from(await res.arrayBuffer()), type };
return { json: await res.json(), type };
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** a queued music or video job, asked about until it's done; the file, however Venice sends it */
async function retrieve(kind, model, queueId, downloadUrl) {
for (let i = 0; i < POLL_MAX; i++) {
await sleep(POLL_MS);
const r = await venice(`/${kind}/retrieve`, { model, queue_id: queueId });
if (r.binary) return { bytes: r.binary, type: r.type };
const d = r.json || {}, status = String(d.status || "").toUpperCase(), ready = d.audio_url || d.video_url || d.url;
if (status === "COMPLETED") {
const url = ready || downloadUrl;
if (!url) throw new Error("completed, but Venice sent no file");
const f = await fetch(url);
if (!f.ok) throw new Error(`download failed (${f.status})`);
return { bytes: Buffer.from(await f.arrayBuffer()), type: f.headers.get("content-type") || "" };
}
if (/FAIL|ERROR|CANCEL|REJECT|EXPIRE/.test(status)) throw new Error(d.error || `generation ${status.toLowerCase()}`);
}
throw new Error(`timed out after ${(POLL_MS * POLL_MAX) / 1000}s`);
}
const ext = (type, dflt) => (/mp4/.test(type) ? "mp4" : /webm/.test(type) ? "webm" : /mpeg|mp3/.test(type) ? "mp3" : /wav/.test(type) ? "wav" : /ogg|opus/.test(type) ? "ogg" : /flac/.test(type) ? "flac" : /png/.test(type) ? "png" : /jpe?g/.test(type) ? "jpg" : /webp/.test(type) ? "webp" : dflt);
function resolvePrompt(template, input) {
if (!template) return input;
if (template.includes("{{input}}")) return template.replace(/\{\{input\}\}/g, input);
return input ? `${template}\n\n${input}` : template;
}
function inputsOf(node, graph) {
return graph.edges.filter((e) => e.target === node.id).map((e) => {
const p = join(OUT, e.source + ".txt");
return existsSync(p) ? readFileSync(p, "utf8") : "";
}).filter(Boolean).join("\n\n");
}
const mediaPath = (s, tag) => { const m = new RegExp(`\\[${tag}:([^\\]]+)\\]`).exec(s); return m ? m[1] : ""; };
async function runNode(node, graph) {
const d = node.data || {}, input = inputsOf(node, graph), file = (e) => join(OUT, `${node.id}.${e}`);
switch (d.nodeType) {
case "textInput": return d.inputText ?? "";
case "output": return input;
case "step": return input; // a runner's own step: the workflow file runs it, not this
case "chat": {
const r = await venice("/chat/completions", { model: d.model || "llama-3.3-70b", messages: [{ role: "user", content: resolvePrompt(d.prompt, input) }],
temperature: d.temperature ?? 0.7, max_tokens: d.maxTokens ?? 4096, venice_parameters: { enable_web_search: d.webSearch ?? "off" } });
return r.json?.choices?.[0]?.message?.content ?? "";
}
case "imageGen": {
const body = { model: d.model || "z-image-turbo", prompt: resolvePrompt(d.prompt, input), negative_prompt: d.negativePrompt || undefined, steps: d.steps ?? 20,
style_preset: d.style || undefined, width: d.width ?? 1024, height: d.height ?? 1024, hide_watermark: d.hideWatermark ?? true };
if (d.aspectRatio) body.aspect_ratio = d.aspectRatio;
const r = await venice("/image/generate", body);
const img = r.json?.images?.[0], b64 = typeof img === "string" ? img : img?.b64_json;
if (!b64) throw new Error("no image came back");
const e = b64.startsWith("/9j/") ? "jpg" : b64.startsWith("UklGR") ? "webp" : "png";
writeFileSync(file(e), Buffer.from(b64, "base64"));
return `[image:${file(e)}]`;
}
case "tts": {
const r = await venice("/audio/speech", { model: d.model || "tts-kokoro", input: resolvePrompt(d.prompt, input), voice: d.voice || "af_sky", speed: d.speed ?? 1,
response_format: d.responseFormat || "mp3" }, { binary: true });
const e = d.responseFormat || ext(r.type, "mp3");
writeFileSync(file(e), r.binary);
return `[audio:${file(e)}]`;
}
case "music": {
const body = { model: d.model || "stable-audio-25", prompt: resolvePrompt(d.prompt, input), duration_seconds: d.duration ?? 30, force_instrumental: d.instrumental ?? false };
if (d.lyrics) body.lyrics_prompt = d.lyrics;
const q = (await venice("/audio/queue", body)).json || {};
const { bytes, type } = await retrieve("audio", q.model || body.model, q.queue_id, q.download_url);
const e = ext(type, "mp3");
writeFileSync(file(e), bytes);
return `[audio:${file(e)}]`;
}
case "video": {
const body = { model: d.model || "wan-3-0-text-to-video", prompt: resolvePrompt(d.prompt, input), aspect_ratio: d.videoAspectRatio || "16:9" };
if (d.videoDuration) body.duration = d.videoDuration;
if (d.videoResolution) body.resolution = d.videoResolution;
const q = (await venice("/video/queue", body)).json || {};
const { bytes, type } = await retrieve("video", q.model || body.model, q.queue_id || q.id, q.download_url);
const e = ext(type, "mp4");
writeFileSync(file(e), bytes);
return `[video:${file(e)}]`;
}
case "compose": {
const image = mediaPath(input, "image"), audio = mediaPath(input, "audio");
if (!image || !audio) throw new Error("Cover + Track needs one image node and one audio node connected to it");
const side = Math.max(256, Math.min(2048, Number(d.maxSide) || 1080));
const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", "-loop", "1", "-i", image, "-i", audio, "-c:v", "libx264", "-tune", "stillimage", "-preset", "medium",
"-vf", `scale='if(gt(iw,ih),${side},-2)':'if(gt(iw,ih),-2,${side})',pad=ceil(iw/2)*2:ceil(ih/2)*2`, "-c:a", "aac", "-b:a", "160k", "-pix_fmt", "yuv420p", "-shortest", "-movflags", "+faststart", file("mp4")], { encoding: "utf8" });
if (r.error) throw new Error("ffmpeg is not installed (a Cover + Track node needs it)");
if (r.status !== 0) throw new Error("ffmpeg: " + (r.stderr || "").trim().slice(-400));
return `[video:${file("mp4")}]`;
}
default: throw new Error(`unknown node type ${d.nodeType}`);
}
}
/** nodes in dependency order (a cycle is an error) */
function order(graph) {
const indeg = new Map(graph.nodes.map((n) => [n.id, 0])), adj = new Map(graph.nodes.map((n) => [n.id, []]));
for (const e of graph.edges) { if (!indeg.has(e.source) || !indeg.has(e.target)) continue; adj.get(e.source).push(e.target); indeg.set(e.target, indeg.get(e.target) + 1); }
const out = [];
let frontier = graph.nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id);
while (frontier.length) {
out.push(...frontier);
const next = [];
for (const id of frontier) for (const c of adj.get(id)) { indeg.set(c, indeg.get(c) - 1); if (indeg.get(c) === 0) next.push(c); }
frontier = next;
}
if (out.length !== graph.nodes.length) fail("the graph has a cycle");
return out.map((id) => graph.nodes.find((n) => n.id === id));
}
async function main() {
const graph = loadGraph();
mkdirSync(OUT, { recursive: true });
const which = has("all") ? order(graph) : [graph.nodes.find((n) => n.id === flag("node"))].filter(Boolean);
if (!which.length) fail(`no such node: ${flag("node")} (nodes: ${graph.nodes.map((n) => n.id).join(", ")})`);
for (const node of which) {
const label = `${node.id} [${node.data?.nodeType}]`;
process.stdout.write(`${label}… `);
const started = Date.now();
const result = await runNode(node, graph);
writeFileSync(join(OUT, node.id + ".txt"), result);
const shown = result.startsWith("[") ? result : result.replace(/\s+/g, " ").slice(0, 120) + (result.length > 120 ? "…" : "");
console.log(`${((Date.now() - started) / 1000).toFixed(1)}s ${shown}`);
}
}
main().catch((e) => fail(e.message || String(e)));
"""
ACTIONS_YAML_TS = r"""// actions-yaml.ts: a workflow as a Gitea Actions workflow file, and back. Two views of one thing:
// - a workflow made here exports as a job that walks the graph, one node per step, in dependency order, on
// https://havre.yolopreneur.lol/open/run.mjs (the browser engine's node calls, headless);
// - any Actions workflow imports as a chain of Step nodes (one per step, each keeping its own YAML), with the rest of
// the file (on, permissions, concurrency, the job's runs-on and env) kept as the workflow's frame, so it exports back
// as it came in, with any Venice nodes spliced in as runner steps.
// The graph also rides in the file (env.OV_WORKFLOW, base64 JSON) so positions and params come back exactly; a Step
// node re-reads its YAML from the file on import, so an edit made in the file wins over the canvas's copy.
import type { Workflow, VeniceNodeData } from '../stores/workflow-store'
import { NODE_SCHEMAS } from './workflow-schema'
export const RUNNER_URL = 'https://havre.yolopreneur.lol/open/run.mjs'
/** sha256 of run.mjs as built beside this page; the exported file checks it before running */
export const RUNNER_SHA = '__RUNNER_SHA__'
const ENV_KEY = 'OV_WORKFLOW'
const q = (s: string) => JSON.stringify(String(s))
export const slug = (s: string) => (s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'workflow').slice(0, 60)
/** the file around the steps of an imported Actions workflow: text before the steps list, text after it, and how far in the items sit */
export interface Frame { pre: string; post: string; indent: number }
/** the graph as the file carries it: name, nodes (id, data, position), edges (source, target) and the frame, if any */
export function graphOf(wf: Workflow) {
return {
name: wf.name,
nodes: wf.nodes.map((n) => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
edges: wf.edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
...(wf.frame ? { frame: wf.frame } : {}),
}
}
/** nodes in dependency order (a cycle leaves the rest off) */
function ordered(wf: Workflow): Workflow['nodes'] {
const indeg = new Map(wf.nodes.map((n) => [n.id, 0])), adj = new Map<string, string[]>(wf.nodes.map((n) => [n.id, []]))
for (const e of wf.edges) {
if (!indeg.has(e.source) || !indeg.has(e.target)) continue
adj.get(e.source)!.push(e.target)
indeg.set(e.target, (indeg.get(e.target) ?? 0) + 1)
}
const out: string[] = []
let frontier = wf.nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id)
while (frontier.length) {
out.push(...frontier)
const next: string[] = []
for (const id of frontier) for (const c of adj.get(id) ?? []) { indeg.set(c, indeg.get(c)! - 1); if (indeg.get(c) === 0) next.push(c) }
frontier = next
}
return out.map((id) => wf.nodes.find((n) => n.id === id)!)
}
const b64 = (s: string) => btoa(String.fromCharCode(...new TextEncoder().encode(s)))
const unb64 = (s: string) => new TextDecoder().decode(Uint8Array.from(atob(s.replace(/\s+/g, '')), (c) => c.charCodeAt(0)))
const indentBy = (text: string, n: number) => text.split('\n').map((l) => (l.trim() ? ' '.repeat(n) + l : l)).join('\n')
/** one Step node as YAML list-item lines, at indent 0 ("- name: ..."): its own YAML if it has one, else from its fields */
export function stepYaml(d: VeniceNodeData): string {
if (d.stepYaml && d.stepYaml.trim()) return d.stepYaml.replace(/\s+$/, '')
const lines: string[] = []
if (d.label && d.label !== 'Step') lines.push(`- name: ${q(d.label)}`)
const head = lines.length ? ' ' : '- '
let first = !lines.length
const key = () => { const k = first ? head : ' '; first = false; return k }
if (d.uses) lines.push(`${key()}uses: ${d.uses}`)
const run = (d.run ?? '').replace(/\s+$/, '')
if (run) {
if (run.includes('\n')) lines.push(`${key()}run: |`, ...run.split('\n').map((l) => (l ? ' ' + l : '')))
else lines.push(`${key()}run: ${run}`)
}
for (const [k, v] of [['with', d.with], ['env', d.stepEnv]] as const) {
const body = (v ?? '').replace(/\s+$/, '')
if (body) lines.push(`${key()}${k}:`, ...body.split('\n').map((l) => (l ? ' ' + l : '')))
}
if (!lines.length) lines.push(`- run: ${q(d.label || 'step')}`)
return lines.join('\n')
}
/** the text of a step item -> name, uses, run (a `|` block joined), with and env (raw lines) */
function stepFields(item: string): Partial<VeniceNodeData> {
const lines = item.split('\n')
const out: Partial<VeniceNodeData> = {}
const scalar = (k: string) => {
const m = new RegExp(`^(?:- | )${k}:\\s*(.*?)\\s*$`, 'm').exec(item)
if (!m) return undefined
const v = m[1]
return /^".*"$/.test(v) ? JSON.parse(v) : /^'.*'$/.test(v) ? v.slice(1, -1).replace(/''/g, "'") : v
}
const block = (k: string) => {
const i = lines.findIndex((l) => new RegExp(`^(?:- | )${k}:\\s*(\\|[-+]?|>[-+]?)?\\s*$`).test(l))
if (i < 0) return undefined
const body: string[] = []
for (let j = i + 1; j < lines.length; j++) {
const l = lines[j]
if (l.trim() && !/^\s{4}/.test(l)) break
body.push(l.slice(4))
}
return body.join('\n').replace(/\s+$/, '')
}
const name = scalar('name'); if (name) out.label = name
const uses = scalar('uses'); if (uses) out.uses = uses
const run = block('run') ?? scalar('run'); if (run) out.run = run
const w = block('with'); if (w) out.with = w
const e = block('env'); if (e) out.stepEnv = e
return out
}
/** a hand-written Actions file as a frame and its step items; null when it has no steps list */
export function frameOf(text: string): { frame: Frame; items: string[]; name: string } | null {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const at = lines.findIndex((l) => /^\s+steps:\s*(#.*)?$/.test(l))
if (at < 0) return null
let i = at + 1
while (i < lines.length && (!lines[i].trim() || /^\s*#/.test(lines[i]))) i++
const m = /^(\s*)- /.exec(lines[i] ?? '')
if (!m) return null
const indent = m[1].length
const items: string[][] = []
let j = i
for (; j < lines.length; j++) {
const l = lines[j]
if (l.trim() && !/^\s*#/.test(l)) {
const ind = /^\s*/.exec(l)![0].length
if (ind < indent) break
if (ind === indent) { if (!l.startsWith(' '.repeat(indent) + '- ')) break; items.push([]) }
}
if (!items.length) continue
items[items.length - 1].push(l.slice(indent))
}
const pre = lines.slice(0, i).filter((l) => !/^\s*OV_WORKFLOW:/.test(l)).join('\n')
const post = lines.slice(j).join('\n').replace(/\s+$/, '')
const nm = /^name:\s*(.*?)\s*$/m.exec(pre)
const name = nm ? (/^".*"$/.test(nm[1]) ? JSON.parse(nm[1]) : nm[1].replace(/^'|'$/g, '')) : ''
return { frame: { pre: pre.replace(/^env:\s*\n(?=\S)/m, ''), post, indent }, items: items.map((it) => it.join('\n').replace(/\s+$/, '')), name }
}
/** the runner steps a framed job needs before its first Venice node */
function runnerSteps(needsFfmpeg: boolean): string[] {
const s = ['- uses: actions/setup-node@v4', ' with:', ' node-version: 22']
if (needsFfmpeg) s.push('- name: ffmpeg, for Cover + Track', ' run: |', ' command -v ffmpeg >/dev/null || sudo apt-get install -y -qq ffmpeg >/dev/null || apt-get install -y -qq ffmpeg >/dev/null')
s.push('- name: the runner', ' run: |', ` curl -fsSLo run.mjs ${q(RUNNER_URL)}`)
if (RUNNER_SHA && !RUNNER_SHA.startsWith('__')) s.push(` echo "${RUNNER_SHA} run.mjs" | sha256sum -c -`)
s.push(' mkdir -p out')
return s
}
/** one Venice node as runner step lines at indent 0; `keyed` adds the Venice key to the step (framed jobs have none at job level) */
function nodeStep(n: Workflow['nodes'][number], keyed: boolean): string[] {
const s = NODE_SCHEMAS[n.data.nodeType]
const lines = [`- name: ${q(`${n.id} (${s?.label ?? n.data.nodeType})`)}`]
if (n.data.nodeType === 'textInput') {
lines.push(' run: |', ` printf '%s' "$INPUT" > out/${n.id}.txt && echo "${n.id} = $INPUT"`, ' env:', ` INPUT: \${{ inputs.${n.id} }}`)
} else {
lines.push(` run: node run.mjs --node ${q(n.id)}`)
if (keyed) lines.push(' env:', ' VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}')
}
return lines
}
export function toActionsYaml(wf: Workflow): string {
const nodes = ordered(wf)
const needsFfmpeg = nodes.some((n) => n.data.nodeType === 'compose')
const outputs = nodes.filter((n) => n.data.nodeType === 'output')
const graph = `${ENV_KEY}: ${q(b64(JSON.stringify(graphOf(wf))))}`
if (wf.frame) {
// the file as it came in, its steps replaced by the graph's: Step nodes as their own YAML, Venice nodes as runner steps
let pre = wf.frame.pre.replace(/\s+$/, '')
pre = /^env:\s*$/m.test(pre) ? pre.replace(/^env:\s*$/m, `env:\n ${graph}`) : pre.replace(/^(jobs:\s*)$/m, `env:\n ${graph}\n$1`)
if (!pre.includes(ENV_KEY)) pre += `\nenv:\n ${graph}`
const body: string[] = []
let runnerIn = false
for (const n of nodes) {
if (n.data.nodeType === 'step') { body.push(stepYaml(n.data)); continue }
if (!runnerIn) { body.push(runnerSteps(needsFfmpeg).join('\n')); runnerIn = true }
body.push(nodeStep(n, true).join('\n'))
}
if (outputs.length) body.push(['- name: outputs', ' run: |', ...outputs.map((n) => ` echo "== ${n.id} =="; cat out/${n.id}.txt; echo`)].join('\n'))
const steps = body.map((b) => indentBy(b, wf.frame!.indent)).join('\n')
return pre + '\n' + steps + (wf.frame.post ? '\n' + wf.frame.post : '') + '\n'
}
const inputs = nodes.filter((n) => n.data.nodeType === 'textInput')
const lines: string[] = []
lines.push(`name: ${q(wf.name)}`)
lines.push(`# An openvenice workflow, exported from https://havre.yolopreneur.lol/open/ (Workflows → Export).`)
lines.push(`# Import the file there to edit it on the canvas; the graph rides in env.${ENV_KEY} below.`)
lines.push(`# Runs on the Venice API with the secret VENICE_API_KEY. Outputs land in out/ (and an artifact, where the runner keeps them).`)
lines.push(`on:`)
lines.push(` workflow_dispatch:`)
if (inputs.length) {
lines.push(` inputs:`)
for (const n of inputs) {
lines.push(` ${n.id}:`)
lines.push(` description: ${q(`Input "${n.data.label || n.id}"`)}`)
lines.push(` required: false`)
lines.push(` default: ${q(n.data.inputText ?? '')}`)
}
}
lines.push(` # schedule: # uncomment to run on a timer, e.g. every morning at 8:`)
lines.push(` # - cron: "0 8 * * *"`)
lines.push(`env:`)
lines.push(` ${graph}`)
lines.push(`jobs:`)
lines.push(` run:`)
lines.push(` runs-on: ubuntu-latest`)
lines.push(` timeout-minutes: 60`)
lines.push(` env:`)
lines.push(` VENICE_API_KEY: \${{ secrets.VENICE_API_KEY }}`)
lines.push(` steps:`)
const body: string[] = []
const anyVenice = nodes.some((n) => n.data.nodeType !== 'step')
if (anyVenice) body.push(runnerSteps(needsFfmpeg).join('\n'))
for (const n of nodes) body.push(n.data.nodeType === 'step' ? stepYaml(n.data) : nodeStep(n, false).join('\n'))
if (outputs.length) body.push(['- name: outputs', ' run: |', ...outputs.map((n) => ` echo "== ${n.id} =="; cat out/${n.id}.txt; echo`)].join('\n'))
if (anyVenice) body.push(['- uses: actions/upload-artifact@v4', ' if: always()', ' continue-on-error: true', ' with:', ` name: ${q(slug(wf.name))}`, ' path: out/'].join('\n'))
lines.push(indentBy(body.join('\n'), 6))
return lines.join('\n') + '\n'
}
export type Imported = { name: string; nodes: Workflow['nodes']; edges: Workflow['edges']; frame?: Frame }
/** the graph an exported file carries (also a plain graph JSON), or any Actions file as a chain of Step nodes; null if neither */
export function fromActionsYaml(text: string): Imported | null {
const raw = text.trim()
let g: unknown = null
const framed = raw.startsWith('{') ? null : frameOf(raw)
if (raw.startsWith('{')) {
try { g = JSON.parse(raw) } catch { return null }
} else {
const m = new RegExp(`^\\s*${ENV_KEY}:\\s*(?:"([^"]+)"|'([^']+)'|(\\S+))\\s*$`, 'm').exec(raw)
if (m) { try { g = JSON.parse(unb64(m[1] ?? m[2] ?? m[3])) } catch { g = null } }
}
if (!g && framed) {
// a hand-written workflow: one Step node per step, chained in the file's order
const nodes = framed.items.map((item, i) => ({
id: `step${i + 1}`, type: 'venice', position: { x: 280, y: 40 + i * 180 },
data: { label: 'Step', nodeType: 'step', model: '', prompt: '', ...stepFields(item), stepYaml: item } as VeniceNodeData,
}))
const edges = nodes.slice(1).map((n, i) => ({ id: `e${i}-${nodes[i].id}-${n.id}`, source: nodes[i].id, target: n.id, animated: true }))
if (!nodes.length) return null
return { name: framed.name || 'Imported workflow', nodes, edges, frame: framed.frame }
}
if (!g || typeof g !== 'object') return null
const o = g as { name?: unknown; nodes?: unknown; edges?: unknown; frame?: unknown }
if (!Array.isArray(o.nodes) || !Array.isArray(o.edges)) return null
const nodes = (o.nodes as Array<Record<string, unknown>>).filter((n) => n && typeof n.id === 'string' && n.data && typeof n.data === 'object' && typeof (n.data as { nodeType?: unknown }).nodeType === 'string' && (n.data as { nodeType: string }).nodeType in NODE_SCHEMAS)
.map((n, i) => ({ id: n.id as string, type: 'venice', position: (n.position && typeof n.position === 'object' ? n.position : { x: 280, y: 40 + i * 200 }) as { x: number; y: number }, data: { ...(n.data as VeniceNodeData) } }))
const ids = new Set(nodes.map((n) => n.id))
const edges = (o.edges as Array<Record<string, unknown>>).filter((e) => e && typeof e.source === 'string' && typeof e.target === 'string' && ids.has(e.source) && ids.has(e.target))
.map((e, i) => ({ id: typeof e.id === 'string' ? e.id : `e${i}-${e.source}-${e.target}`, source: e.source as string, target: e.target as string, animated: true }))
if (!nodes.length) return null
let frame = o.frame && typeof o.frame === 'object' && typeof (o.frame as Frame).pre === 'string' ? (o.frame as Frame) : undefined
if (framed && frame) {
// a framed file is the other view of the same thing: its frame and its steps' YAML win over the graph's copies
// (a file exported from a plain canvas workflow has no frame: the graph alone is the workflow)
frame = framed.frame
const byName = new Map<string, string>(framed.items.map((it) => [stepFields(it).label ?? '', it]))
for (const n of nodes) {
if (n.data.nodeType !== 'step') continue
const it = byName.get(n.data.label) ?? (n.data.stepYaml ? framed.items.find((x) => x.split('\n')[0] === n.data.stepYaml!.split('\n')[0]) : undefined)
if (it) Object.assign(n.data, stepFields(it), { stepYaml: it })
}
}
return { name: typeof o.name === 'string' && o.name.trim() ? o.name.trim() : 'Imported workflow', nodes, edges, ...(frame ? { frame } : {}) }
}
export function downloadText(name: string, text: string, type = 'text/yaml') {
const a = document.createElement('a')
a.href = URL.createObjectURL(new Blob([text], { type }))
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 2000)
}
"""
# ---------- workflows: export as a Gitea Actions workflow file, and import one back; the headless runner ----------
# The graph rides in the file (env.OV_WORKFLOW), so import reads back exactly what was exported; the steps walk the
# same graph with public/run.mjs (served at /open/run.mjs), pinned by sha256 in the exported file.
import hashlib as _hashlib
write("public/run.mjs", RUN_MJS)
write(S + "lib/actions-yaml.ts", ACTIONS_YAML_TS.replace("__RUNNER_SHA__", _hashlib.sha256(RUN_MJS.encode("utf-8")).hexdigest()))
P = S + "components/workflows/workflows-view.tsx"
edit(P,
("import { toast } from '../../stores/toast-store'\n",
"import { toast } from '../../stores/toast-store'\nimport { toActionsYaml, fromActionsYaml, downloadText, slug as wfSlug } from '../../lib/actions-yaml'\n"),
(""" <span className="text-[14px] text-white/50 font-medium">
{workflows.find((w) => w.id === activeWorkflowId)?.name}
</span>
</div>""", """ <span className="text-[14px] text-white/50 font-medium">
{workflows.find((w) => w.id === activeWorkflowId)?.name}
</span>
<div className="flex-1" />
<button
type="button"
title="This workflow as a Gitea Actions workflow file: run it on the factory, on a timer, or import it back here"
onClick={() => {
const wf = workflows.find((w) => w.id === activeWorkflowId)
if (!wf) return
downloadText(wfSlug(wf.name) + '.yml', toActionsYaml(wf))
toast.success('Exported', 'Commit it under .gitea/workflows/ or .github/workflows/ in a repo with the secret VENICE_API_KEY.')
}}
className="text-[13px] text-white/35 hover:text-white/70 transition-colors px-2 py-1 border border-white/[0.08]"
>
Export
</button>
</div>"""),
(""" <button
onClick={() => handleCreate(newName)}
className="text-[14px] font-medium px-4 py-2 rounded-lg bg-white text-black hover:bg-white/90 transition-colors"
>
New Workflow
</button>
</div>""", """ <button
onClick={() => handleCreate(newName)}
className="text-[14px] font-medium px-4 py-2 rounded-lg bg-white text-black hover:bg-white/90 transition-colors"
>
New Workflow
</button>
<label
title="A workflow file exported from here (.yml), or a graph as JSON"
className="text-[14px] font-medium px-4 py-2 rounded-lg border border-white/[0.12] text-white/60 hover:text-white/90 transition-colors cursor-pointer"
>
Import
<input
type="file"
accept=".yml,.yaml,.json,text/yaml,application/json"
className="hidden"
onChange={async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
const got = fromActionsYaml(await f.text())
if (!got) { toast.error('No workflow in that file', 'Import a .yml exported from here, or a graph as JSON.'); return }
const id = createWorkflow(got.name)
useWorkflowStore.getState().updateWorkflow(id, { nodes: got.nodes, edges: got.edges })
toast.success(`Imported ${got.name}`, `${got.nodes.length} nodes`)
}}
/>
</label>
</div>"""))
# ---------- step node: a literal Actions step, so any workflow file is a chain of nodes and comes back as it went ----------
# A Step node carries one step's YAML (run, uses, with, env). In the browser it does nothing (it passes upstream text
# through); on export it is itself, verbatim; a workflow imported from a hand-written file keeps that file's frame
# (on, permissions, concurrency, runs-on, job env) and exports back into it, with Venice nodes spliced in as runner steps.
edit(S + "stores/workflow-store.ts",
("export type VeniceNodeType = 'chat' | 'imageGen' | 'tts' | 'music' | 'video' | 'compose' | 'textInput' | 'output'",
"export type VeniceNodeType = 'chat' | 'imageGen' | 'tts' | 'music' | 'video' | 'compose' | 'step' | 'textInput' | 'output'"),
(" // Compose (cover + track)\n maxSide?: number\n",
" // Compose (cover + track)\n maxSide?: number\n // Step (an Actions step as it is): its fields, and its YAML as it came from a file\n run?: string\n uses?: string\n with?: string\n stepEnv?: string\n stepYaml?: string\n"),
(" edges: Edge[]\n createdAt: number\n}",
" edges: Edge[]\n createdAt: number\n /** an imported Actions file's text around its steps: the workflow exports back into it */\n frame?: { pre: string; post: string; indent: number }\n}"),
(" updateWorkflow: (id: string, updates: Partial<Pick<Workflow, 'name' | 'nodes' | 'edges'>>) => void",
" updateWorkflow: (id: string, updates: Partial<Pick<Workflow, 'name' | 'nodes' | 'edges' | 'frame'>>) => void"))
edit(S + "lib/workflow-schema.ts",
(""" compose: {
type: 'compose',""", """ step: {
type: 'step',
label: 'Step',
description: 'A Gitea Actions step as it is (run, uses, with, env). Does nothing in the browser: it passes upstream text through. On export it is this step, verbatim, in the workflow file.',
input: 'text',
output: 'text',
params: [
{ name: 'run', type: 'text', description: 'Shell to run on the runner.', default: '' },
{ name: 'uses', type: 'string', description: 'An action to use instead of run, e.g. actions/checkout@v5.', default: '' },
],
},
compose: {
type: 'compose',"""))
edit(S + "lib/workflow-engine.ts",
(""" case 'output':
return input
""", """ case 'output':
return input
case 'step':
return input // a runner's step: nothing to do in a browser
"""))
P = S + "components/workflows/workflows-view.tsx"
edit(P,
(" { type: 'compose', label: 'Cover + Track', Icon: PaletteComposeIcon, color: 'text-cyan-400/50' },\n",
" { type: 'compose', label: 'Cover + Track', Icon: PaletteComposeIcon, color: 'text-cyan-400/50' },\n { type: 'step', label: 'Step', Icon: PaletteStepIcon, color: 'text-white/40' },\n"),
("function PaletteOutputIcon() {",
"""function PaletteStepIcon() {
return <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg>
}
function PaletteOutputIcon() {"""),
(" compose: '',\n}", " compose: '',\n step: '',\n}"),
(" useWorkflowStore.getState().updateWorkflow(id, { nodes: got.nodes, edges: got.edges })",
" useWorkflowStore.getState().updateWorkflow(id, { nodes: got.nodes, edges: got.edges, frame: got.frame })"),
(" if (!got) { toast.error('No workflow in that file', 'Import a .yml exported from here, or a graph as JSON.'); return }",
" if (!got) { toast.error('No workflow in that file', 'Import an Actions workflow (.yml) or a graph as JSON.'); return }"),
(""" title="A workflow file exported from here (.yml), or a graph as JSON\"""",
""" title="Any Gitea Actions workflow (.yml): one exported from here comes back as it was; any other becomes a chain of Step nodes. Or a graph as JSON\""""))
P = S + "components/workflows/workflow-node.tsx"
edit(P,
(" compose: undefined,\n}", " compose: undefined,\n step: undefined,\n}"),
(" compose: { label: 'Cover + Track', Icon: ComposeIcon, color: 'border-cyan-500/30', hasInput: true, hasOutput: true },\n",
" compose: { label: 'Cover + Track', Icon: ComposeIcon, color: 'border-cyan-500/30', hasInput: true, hasOutput: true },\n step: { label: 'Step', Icon: StepIcon, color: 'border-white/20', hasInput: true, hasOutput: true },\n"),
("\nfunction OutputIcon() {", """
function StepIcon() {
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg>
}
function OutputIcon() {"""),
(""" <span className="text-[14px] font-medium text-white/60">{config.label}</span>""",
""" <span className="text-[14px] font-medium text-white/60">{data.nodeType === 'step' && data.label && data.label !== 'Step' ? data.label : config.label}</span>"""),
(""" ) : data.nodeType === 'compose' ? (""", """ ) : data.nodeType === 'step' ? (
<div className="flex flex-col gap-1.5">
<input
value={data.label === 'Step' ? '' : data.label}
onChange={(e) => updateNode({ label: e.target.value || 'Step', stepYaml: '' })}
placeholder="name (optional)"
className={inputCls}
/>
<input
value={data.uses ?? ''}
onChange={(e) => updateNode({ uses: e.target.value, stepYaml: '' })}
placeholder="uses: owner/action@v1 (optional)"
className={inputCls}
/>
<textarea
value={data.run ?? ''}
onChange={(e) => updateNode({ run: e.target.value, stepYaml: '' })}
placeholder="run: shell for the runner"
rows={3}
className="nodrag nowheel w-full bg-white/[0.03] border border-white/[0.06] rounded-md px-2 py-1.5 text-[13px] font-mono text-white/70 outline-none resize-none placeholder:text-white/15"
/>
{(data.with || data.stepEnv) && (
<pre className="nodrag nowheel text-[12px] font-mono text-white/40 whitespace-pre-wrap max-h-24 overflow-auto">{[data.with && 'with:\\n' + data.with, data.stepEnv && 'env:\\n' + data.stepEnv].filter(Boolean).join('\\n')}</pre>
)}
<p className="text-[12px] text-white/30 leading-snug">Runs on the factory, not here: on export this is the step as written.</p>
</div>
) : data.nodeType === 'compose' ? ("""))
edit(S + "components/playground/preview-node.tsx",
(" compose: 'border-cyan-500/30',\n", " compose: 'border-cyan-500/30',\n step: 'border-white/20',\n"))
edit(S + "lib/playground-agent-tools.ts",
(" return true // textInput / output / compose don't have models",
" return true // textInput / output / compose / step don't have models"))
if FAILED:
sys.exit("havre.py: upstream no longer matches:\n " + "\n ".join(FAILED))
print("havre.py: applied")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+30 -9
View File
@@ -34,8 +34,7 @@ body {
color: var(--ink);
font: 19px/1.35 var(--serif);
}
a { color: var(--link); }
a:visited { color: var(--visited); }
a, a:visited { color: var(--link); }
a:hover, a:focus-visible { color: var(--ink); background: var(--hi); }
:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
p { margin: 0; }
@@ -311,13 +310,18 @@ pre.saw {
.line.media .words { white-space: pre-wrap; }
.pic { margin: 0; border: 1px solid var(--ink); width: min(100%, 340px); }
.pic img { width: 100%; height: auto; max-height: 440px; object-fit: contain; background: var(--well); cursor: zoom-in; }
.lightbox { padding: 0; border: 1px solid var(--ink); background: var(--paper); max-width: 96vw; max-height: 96vh; }
/* the picture, big: it takes focus when it opens, and the focus ring (link blue) read as a blue background around it */
.lightbox { padding: 0; border: 0; background: transparent; max-width: 96vw; max-height: 96vh; outline: none; }
.lightbox:focus, .lightbox:focus-visible { outline: none; }
.lightbox::backdrop { background: rgba(0, 0, 0, 0.88); }
.lightbox img { max-width: 96vw; max-height: 94vh; object-fit: contain; cursor: zoom-out; }
.newcard label { display: block; margin: 12px 0; font: 14px var(--mono); }
.newcard input, .newcard textarea { display: block; width: 100%; margin-top: 4px; padding: 6px 8px; font: 16px var(--serif);
border: 1px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); }
tr.opens { cursor: pointer; }
#roster tr.newrow .rowname { font-weight: 700; }
#roster tr.newrow[aria-current="true"] { background: var(--ink); color: var(--paper); }
#roster tr.newrow[aria-current="true"] .linkish { color: var(--paper); }
tr.opens:hover { background: var(--hi); }
.rowname { font: inherit; color: inherit; text-decoration: none; }
.rewrite { display: flex; gap: 8px; margin: 6px 0; }
@@ -491,13 +495,16 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
/* ---------- narrow screens ---------- */
@media (max-width: 1040px) {
.chars { border-right: 0; border-bottom: 1px solid var(--ink); }
/* one at a time, like a phone's messages: the list, or the chat with a way back to it */
.studio.pane-list .chat, .studio.pane-chat .chars { display: none; }
/* one at a time, like a phone's messages: the list, or the chat. In the chat the left column's tabs stay above it,
without their panels, so CHATS / CHARACTERS / HARBOR are always one tap away */
.studio.pane-list .chat { display: none; }
.studio.pane-chat .chars [role=tabpanel] { display: none; }
.studio.pane-list .chars { border-bottom: 0; }
.chat .back { display: block; flex-basis: 100%; font: 14px var(--mono); }
.chat .back { display: none; }
.grid { margin-right: 0; }
.chat { position: static; max-height: none; }
.log, .chat.asking .log { height: max(320px, 58vh); }
/* the conversation takes what the screen has left under the tabs and above the composer */
.log, .chat.asking .log { height: max(360px, calc(100dvh - 300px)); }
.ask { max-height: none; }
}
@media (max-width: 640px) {
@@ -509,7 +516,12 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.keybar { gap: 8px 10px; }
.keybar input:not([type=checkbox]) { flex: 1 1 100%; }
.keyfine, .keystatus { margin-left: 0; }
.sechead h2, .lefthead [role=tab], .lefthead .sep { font-size: 28px; }
.sechead h2 { font-size: 28px; }
/* the three tabs on one line: smaller, a dot between them instead of a slash */
.lefthead [role=tablist] { flex-wrap: nowrap; gap: 0 10px; }
.lefthead [role=tab] { font-size: 21px; white-space: nowrap; }
.lefthead .sep { font-size: 0; }
.lefthead .sep::before { content: "\00b7"; font: 700 21px/1 var(--serif); }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid > li:nth-child(2n) { border-right: 0; }
.line { grid-template-columns: minmax(0, 1fr); gap: 0; }
@@ -535,8 +547,17 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
}
/* a small phone (an iPhone SE is 375px wide): the left column's tabs a little smaller, so a count of what's new fits */
@media (max-width: 400px) {
.lefthead [role=tab], .lefthead .sep { font-size: 24px; }
.lefthead [role=tablist] { gap: 0 8px; }
.lefthead [role=tab] { font-size: 19px; }
.lefthead .sep::before { font-size: 19px; }
}
/* a new version is out: a bar at the top to reload (update.js) */
#upd {
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 30;
padding: 9px 16px; border: 1px solid var(--paper); border-radius: 0; background: var(--ink); color: var(--paper);
font: 700 13px/1 var(--mono); letter-spacing: .12em; white-space: nowrap; cursor: pointer;
}
#upd:hover, #upd:focus-visible { background: var(--hi); color: var(--ink); border-color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
}
+5 -4
View File
@@ -284,10 +284,11 @@ test("the conversation as the model reads it: a photo it sent is mentioned to it
{ role: "user", content: "hello there", media: { kind: "voice-in", id: "u2" } },
];
const api = A.apiHistory(hist);
assert.deepEqual(api.map((m) => m.role), ["user", "user", "user", "assistant", "user"]);
assert.equal(api[1].content, "(You sent me a photo: On the deck.) (You sent me a short video: Waving.)\n\nlovely");
assert.equal(api[2].content, "[a photo: A gull on a post.] what's this?");
assert.equal(api[4].content, "hello there");
assert.deepEqual(api.map((m) => m.role), ["user", "assistant", "user", "user", "assistant", "user"]);
assert.equal(api[1].content, "*sends a photo: On the deck*\n\n*sends a short video: Waving*");
assert.equal(api[2].content, "lovely");
assert.equal(api[3].content, "[a photo: A gull on a post.] what's this?");
assert.equal(api[5].content, "hello there");
assert.ok(!api.some((m) => /\[photo\]/.test(m.content)), "the model never sees a photo placeholder in its own voice");
const seen = A.withPicture([{ role: "system", content: "s" }, { role: "user", content: "[a photo] look" }], "data:image/jpeg;base64,AAAA");
assert.deepEqual(seen[1].content[1], { type: "image_url", image_url: { url: "data:image/jpeg;base64,AAAA" } });
+61
View File
@@ -0,0 +1,61 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as B from "../backup.js";
import * as R from "../memory.js";
import { normalizeCard } from "../card.js";
const ines = normalizeCard({ name: "Ines Corbel", persona: "You are Ines." });
const wren = normalizeCard({ name: "Wren", persona: "You are Wren." });
const threads = [
{ id: "t1", card: "ines-corbel", msgs: [{ id: "a", role: "user", content: "hi" }, { id: "b", role: "assistant", content: "", media: { kind: "photo", id: "p1" } }] },
{ id: "t2", card: "wren", msgs: [{ id: "c", role: "assistant", content: "", media: { kind: "photo", id: "p2" } }] },
];
const media = { p1: "data:image/webp;base64,AAAA", p2: "data:image/webp;base64,BBBB", p9: "data:image/webp;base64,CCCC" };
test("one character's bundle holds their card, memory, chats and only those chats' pictures", () => {
const mem = { notes: [R.note("They hate fog.", { at: 5 })], upTo: { t1: "b" }, at: 5 };
const b = B.bundleOne(ines, { memory: mem, threads, media, at: 9 });
assert.equal(b.havre_backup, 1);
assert.deepEqual(b.cards.map((c) => c.id), ["ines-corbel"]);
assert.deepEqual(Object.keys(b.memory), ["ines-corbel"]);
assert.deepEqual(b.threads.map((t) => t.id), ["t1"]);
assert.deepEqual(Object.keys(b.media), ["p1"]);
assert.equal(B.bundleName(ines), "ines-corbel.havre-full.json");
assert.match(B.bundleName(null, Date.UTC(2026, 9, 20)), /^havre-backup-2026-10-20\.json$/);
});
test("the whole harbor: everything, and the settings, never a key", () => {
const b = B.bundleAll({ cards: [ines, wren], memory: {}, threads, media, settings: { abilities: { own: true } } });
assert.equal(b.cards.length, 2);
assert.equal(b.threads.length, 2);
assert.deepEqual(Object.keys(b.media).sort(), ["p1", "p2", "p9"]);
assert.deepEqual(b.settings, { abilities: { own: true } });
assert.ok(!JSON.stringify(b).includes("key"));
});
test("a bundle reads back safely, and anything else is not one", () => {
assert.equal(B.readBundle({ cards: [] }), null);
assert.equal(B.readBundle("x"), null);
const b = B.readBundle(JSON.parse(JSON.stringify(B.bundleOne(ines, { memory: { notes: ["old"], upTo: {}, at: 1 }, threads, media }))));
assert.equal(b.cards[0].name, "Ines Corbel");
assert.deepEqual(b.memory["ines-corbel"].notes.map((n) => n.text), ["old"]);
assert.equal(b.threads.length, 1);
assert.deepEqual(Object.keys(b.media), ["p1"]);
assert.equal(b.settings, null);
const junk = B.readBundle({ havre_backup: 1, cards: [{ name: "X" }, null, 3], threads: [{ id: "t", card: "x", msgs: [{ role: "system" }, { role: "user", content: "k" }] }, {}], media: { ok: "data:image/png;base64,AA", bad: "javascript:x" } });
assert.equal(junk.cards.length, 1);
assert.equal(junk.threads.length, 1);
assert.equal(junk.threads[0].msgs.length, 1);
assert.deepEqual(Object.keys(junk.media), ["ok"]);
});
test("restoring merges: cards already there stay, notes are appended, chats by id are added if missing", () => {
const b = B.readBundle(B.bundleAll({ cards: [ines, wren], memory: { "ines-corbel": { notes: [R.note("new", { at: 3 })], upTo: { t1: "b" }, at: 3 } }, threads, media }));
const m = B.merge(b, { cards: [ines], memory: { "ines-corbel": { notes: [R.note("old", { at: 1 })], upTo: { t5: "z" }, at: 1 } }, threads: { t1: {} } });
assert.deepEqual(m.addCards.map((c) => c.id), ["wren"]);
assert.equal(m.skippedCards, 1);
assert.deepEqual(m.memory["ines-corbel"].notes.map((n) => n.text), ["old", "new"]);
assert.deepEqual(m.memory["ines-corbel"].upTo, { t1: "b", t5: "z" });
assert.deepEqual(m.addThreads.map((t) => t.id), ["t2"]);
assert.equal(m.skippedThreads, 1);
});
+26 -23
View File
@@ -171,7 +171,7 @@ async function fakeVenice(route) {
reply = /^Them:.*\bSend me a pic/i.test(said) ? json({ move: "photo", shot: "The character from the reference picture holds up a mug\nin a half-unpacked kitchen." })
: /^Them:.*\bFilm the harbor/i.test(said) ? json({ move: "clip", shot: "The character from the reference pictures pans across the harbor at dusk." })
: /^Them:.*\bWhisper it/i.test(said) ? json({ move: "voice", shot: "" }) : json({ move: "nothing", shot: "" });
} else if (/^(?:\(You sent me [^\n]*\)\n\n)?\[Time passes/.test(lastSaid)) { // after a word about the photo she sent last, if she did
} else if (/^\[Time passes/.test(lastSaid)) {
reply = "You're back!\n\nI kept the kettle warm.";
} else if (system.startsWith("You write the shot for a photo")) {
reply = "Shot: The character from the reference picture sits on a stack of moving boxes, grinning, warm lamplight.";
@@ -325,7 +325,7 @@ async function main() {
assert.match(await textOf(page, "#who-name"), /^chat$/i);
await has(page, "#log .line.note .said", /paste your venice key/);
assert.ok(await page.locator("#cast-box").isHidden(), "the harbor's own aren't listed");
assert.equal(await page.locator("#harbor-empty").evaluate((e) => e.hidden), false, "the empty harbor says so");
assert.equal(await page.locator("#roster tr.newrow").count(), 1, "the harbor always has a row for someone new");
assert.equal(await page.locator("#card-empty").evaluate((e) => e.hidden), false, "so does the empty card");
assert.ok(await page.locator("#card").isHidden(), "the card lives in the harbor tab, not under the characters");
assert.ok(await page.locator("#say").isDisabled(), "nobody to talk to yet");
@@ -334,13 +334,14 @@ async function main() {
await page.click("#lt-harbor");
assert.ok(await page.locator("#card-new").isVisible(), "an empty harbor opens on a new character to write");
assert.match(await textOf(page, "#card-h"), /^NEW CHARACTER$/);
assert.match(await textOf(page, "#roster tr.newrow"), /\+ new character/);
await page.click("#lt-chars");
await shot(page, "desktop.png");
// a card comes in through the import dialog, and talks from its own examples without a key
await page.click("#import");
await page.setInputFiles("#file", { name: "ro.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(v2)) });
await count(page, "#roster tr", 1);
await count(page, "#roster tr:not(.newrow)", 1);
await has(page, "#who-name", /^Captain Ro$/);
await has(page, "#log .line.them .said", /We're closed\. Mostly\./);
await has(page, "#roster td", /Ported from Captain Ro by someone/);
@@ -416,9 +417,11 @@ async function main() {
assert.equal(await page.locator("#log .line.me").count(), 0, "what started it isn't shown");
const hello = seen.chats[chatsBefore];
assert.equal(hello.venice_parameters.character_slug, "sable-penrose");
assert.equal(hello.messages.length, 1);
assert.equal(hello.messages[0].role, "user");
await count(page, "#roster tr", 2);
assert.equal(hello.messages.length, 2, "what they can send here, then what started it");
assert.equal(hello.messages[0].role, "system");
assert.match(hello.messages[0].content, /you can send photos of yourself/);
assert.equal(hello.messages[1].role, "user");
await count(page, "#roster tr:not(.newrow)", 2);
assert.match(await page.locator('#vgrid .tile[aria-pressed="true"]').evaluate((el) => el.textContent), /Sable Penrose.*talking now/);
// the model: their own on Venice to start with, and any other Venice model from the picker
await has(page, "#model-btn", /^GLM 5\.2$/);
@@ -523,7 +526,8 @@ async function main() {
await has(page, "#log .line.media .cap", /photo · Grok Imagine · \$0\.03 · save/);
// the photo is in the conversation the model reads next, as something it sent
await answered(page, async () => { await page.fill("#say", "Cute."); await page.keyboard.press("Enter"); });
assert.match(talks().at(-1).messages.at(-1).content, /^\(You sent me a photo: The character from the reference picture sits on a stack of moving boxes, grinning, warm lamplight\.\)\n\nCute\.$/);
assert.equal(talks().at(-1).messages.at(-1).content, "Cute.");
assert.match(talks().at(-1).messages.at(-2).content, /\*sends a photo: The character from the reference picture sits on a stack of moving boxes, grinning, warm lamplight\*$/);
// voice notes: one on request ("hear it"), then every reply, in the voice on her card
const hears = page.getByRole("button", { name: "Hear this as a voice note from Sable Penrose" });
@@ -613,8 +617,8 @@ async function main() {
assert.match(seen.heard.at(-1), /name="model"\r\n\r\nnvidia\/parakeet-tdt-0\.6b-v3/);
assert.match(seen.heard.at(-1), /filename="voice-note\.(webm|ogg|m4a)"/);
await until(page, () => { const a = [...document.querySelectorAll("#log .line.me audio")].at(-1); return !!a && a.src.startsWith("blob:"); }, null, "your voice note in the chat");
assert.equal(talks().at(-1).messages.at(-1).content, "(You sent me a short video: The character from the reference pictures waves from the doorway as the camera drifts closer.)\n\nhello from the mic",
"what you said, after a word about the clip she sent");
assert.equal(talks().at(-1).messages.at(-1).content, "hello from the mic", "what you said");
assert.match(talks().at(-1).messages.at(-2).content, /\*sends a short video: The character from the reference pictures waves from the doorway as the camera drifts closer\*$/, "after the clip she sent, in her turn");
// your photo, to a model that can see (she talks through Tiny now)
const red = await page.evaluate(() => { const c = document.createElement("canvas"); c.width = c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#c00"; g.fillRect(8, 8, 48, 48); return c.toDataURL("image/png"); });
const redPng = Buffer.from(red.split(",")[1], "base64");
@@ -696,9 +700,9 @@ async function main() {
});
await page.reload();
await has(page, "#log .line.them .said", /I kept the kettle warm\./);
const nudges = () => talks().filter((b) => /^(?:\(You sent me [^\n]*\)\n\n)?\[Time passes/.test(String(b.messages.at(-1).content)));
assert.match(nudges()[0].messages.at(-1).content, /^\(You sent me a photo: The character from the reference picture holds up a mug in a half-unpacked kitchen\.\)\n\n\[Time passes: about 8 hours since you last talked\. They just came back to the chat\./,
"the photo she sent last, then the time that's passed");
const nudges = () => talks().filter((b) => /^\[Time passes/.test(String(b.messages.at(-1).content)));
assert.match(nudges()[0].messages.at(-1).content, /^\[Time passes: about 8 hours since you last talked\. They just came back to the chat\./, "the time that's passed");
assert.match(nudges()[0].messages.at(-2).content, /\*sends a photo: The character from the reference picture holds up a mug in a half-unpacked kitchen\*$/, "the photo she sent last, in her turn");
await poll(() => nudges().length === 2, "Captain Ro texting first", 9000);
assert.match(nudges()[1].messages.at(-1).content, /^\[Time passes: about 8 hours since you last talked\. Reach out to them first/);
await has(page, "#chats-new", /^1$/);
@@ -791,7 +795,7 @@ async function main() {
await shot(page, "desktop-remix.png");
await page.locator("#rx-draft button", { hasText: "KEEP IN HARBOR" }).click();
await has(page, "#who-name", /^Ines Corbel$/);
await count(page, "#roster tr", 4);
await count(page, "#roster tr:not(.newrow)", 4);
await has(page, "#log .line.them .said", /Where are you headed\?/);
assert.equal(await page.locator("#studio.pane-chat").count(), 1, "a kept remix opens their chat, to say hello");
assert.ok(await page.locator("#chat-back").isHidden(), "a wide screen shows the list and the chat side by side, so no way back is needed");
@@ -1057,7 +1061,7 @@ async function main() {
await page.click("#lt-chars");
await page.click("#import");
await page.setInputFiles("#file", png);
await count(page, "#roster tr", 5);
await count(page, "#roster tr:not(.newrow)", 5);
// the security policy: nothing but api.venice.ai (and its private-share links for clips)
assert.equal(await page.evaluate(() => fetch("https://example.com/").then(() => "reached", () => "blocked")), "blocked");
@@ -1128,7 +1132,7 @@ async function main() {
taskmaster: [{ role: "user", content: "Plan my week" }, { role: "assistant", content: "Monday first." }] }));
});
await p2.reload();
await count(p2, "#roster tr", 1);
await count(p2, "#roster tr:not(.newrow)", 1);
const left = await p2.evaluate(() => {
const threads = JSON.parse(localStorage.getItem("havre.threads.v1") || "{}"), t = Object.values(threads)[0];
return { cards: JSON.parse(localStorage.getItem("havre.cards.v1")), seeded: localStorage.getItem("havre.seeded.v1"),
@@ -1176,7 +1180,7 @@ async function main() {
await has(p5, "#cast .tile[data-cast=maren] .go", /^talk again$/);
await p5.goto(base + "?meet=maren");
await has(p5, "#who-name", /^Maren$/);
await count(p5, "#roster tr", 2);
await count(p5, "#roster tr:not(.newrow)", 2);
assert.ok(await p5.locator("#log .line.me .said", { hasText: "What is this place?" }).isVisible(), "the same chat, not a new copy of her");
await own.close();
@@ -1260,15 +1264,15 @@ async function main() {
await p3.locator("#vgrid .tile", { hasText: "Wren" }).click();
await has(p3, "#log .line.them .said", /Careful, the boxes bite\./);
assert.ok(await p3.locator("#say").isVisible(), "the chat is on screen");
assert.ok(await p3.locator("#chars").isHidden(), "and not stranded below the list: one at a time");
assert.ok(await p3.locator("#lp-chars").isHidden() && await p3.locator("#lt-chats").isVisible(), "the list is out of the way, its tabs stay above the chat");
assert.ok(await wide() <= W, `still no sideways scroll (scrollWidth ${await wide()})`);
assert.deepEqual(await small(), [], "nor here");
await p3.waitForTimeout(500);
await shot(p3, "phone-chat.png");
await shot(p3, "phone-full.png", { fullPage: true });
// from a chat, back to the list it came from; a chat from your chats opens in its place, and goes back to them
await has(p3, "#chat-back", /^← characters$/);
await p3.click("#chat-back");
// from a chat, the tabs above it go back to the list; a chat from your chats opens in its place
assert.ok(await p3.locator("#chat-back").isHidden(), "no back link: the tabs are the way back");
await p3.click("#lt-chars");
assert.ok(await p3.locator("#chat").isHidden());
assert.ok(await p3.locator("#lp-chars").isVisible());
await p3.click("#lt-chats");
@@ -1276,9 +1280,8 @@ async function main() {
await p3.waitForTimeout(400);
await shot(p3, "phone-chats.png");
await p3.locator("#threads .trow").first().click();
assert.ok(await p3.locator("#chars").isHidden() && await p3.locator("#say").isVisible());
await has(p3, "#chat-back", /^← your chats$/);
await p3.click("#chat-back");
assert.ok(await p3.locator("#lp-chats").isHidden() && await p3.locator("#say").isVisible());
await p3.click("#lt-chats");
assert.ok(await p3.locator("#lp-chats").isVisible());
await phone.close();
} finally {
+3
View File
@@ -15,5 +15,8 @@
apply();
var b = document.getElementById("theme");
if (b) b.addEventListener("click", function () { try { localStorage.setItem(K, dark() ? "light" : "dark"); } catch (e) { /* this time only */ } apply(); });
// installed as an app there's no address bar and no pull to refresh: this is the way to the latest Havre
var r = document.getElementById("reload");
if (r) r.addEventListener("click", function () { r.textContent = "reloading…"; location.reload(); });
});
})();
+37
View File
@@ -0,0 +1,37 @@
// update.js: an installed Havre has no address bar to refresh from, so the page notices when a new version has been
// published and offers a reload, like gandora's pill. It fingerprints the files the app is made of when it opens,
// checks again whenever it comes back to the foreground (and every ten minutes), and shows the pill when they differ.
// Checking fetches with cache: "reload", which also refreshes the browser's copies, so the reload gets the new files.
(() => {
const FILES = ["./", "app.js", "style.css", "workflows.js", "abilities.js", "card.js", "venice.js", "memory.js",
"threads.js", "media.js", "backup.js", "theme.js"];
let base = null, shown = false, busy = false;
async function print() {
const parts = await Promise.all(FILES.map((f) => fetch(f, { cache: "reload" }).then((r) => (r.ok ? r.text() : "")).catch(() => null)));
if (parts.some((p) => p === null)) return null; // offline: no verdict
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\u0000")));
return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join("");
}
function show() {
if (shown) return;
shown = true;
const b = document.createElement("button");
b.id = "upd"; b.type = "button"; b.textContent = "NEW VERSION · RELOAD";
b.addEventListener("click", () => location.reload());
document.body.append(b);
}
async function check() {
if (busy || shown || !crypto.subtle) return;
busy = true;
try {
const now = await print();
if (!now) return;
if (!base) base = now;
else if (now !== base) show();
} finally { busy = false; }
}
if (location.protocol !== "https:") return;
setTimeout(check, 3000);
setInterval(check, 10 * 60 * 1000);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
})();
+2 -1
View File
@@ -141,8 +141,9 @@ export class Venice {
}
/** a voice note: words read out by a voice model. mood goes to models that take a style prompt; the rest ignore it. */
async speak({ model, voice, input, mood = "", speed = 1, signal } = {}) {
async speak({ model, voice, input, mood = "", speed = 1, format = "", signal } = {}) {
const body = { input: String(input || "").slice(0, 4096), model, speed };
if (format) body.response_format = format;
if (voice) body.voice = voice;
if (mood) body.prompt = String(mood).slice(0, 500);
const res = await this.raw("/audio/speech", { body, signal });
+50
View File
@@ -33,6 +33,11 @@ export const WORKFLOWS = [
about: "a few photos of them from the pictures on their card, one shoot, each a different moment (3 unless you say)",
usage: "/photoshoot [how many] [what the shoot is about]",
},
{
id: "whisper", command: "whisper", title: "a whisper", takes: "card", gives: "voice note", doors: ["chat"],
about: "a short ASMR voice note from them, whispered over soft rain (luna or eve; add what it's about)",
usage: "/whisper [luna|eve] [what it's about]",
},
{
id: "recall", command: "recall", title: "recall", takes: "words", gives: "memory", doors: ["chat"],
about: "what they remember about something (their notes, then the chats themselves), brought to mind for a while",
@@ -128,6 +133,51 @@ export function cardFromIdea(o, { model } = {}) {
return normalizeCard({ ...base, look: { ...base.look, description: look } });
}
// ---------- whisper: a short ASMR voice note, over soft rain ----------
// The voices that whisper well and cheaply (picked in the asmr.gandora.lol bake-off): Venice model, voice, how the
// words are wrapped so the model whispers, and the pace (0.9 of their usual reading speed sounded best).
export const WHISPER_PRESETS = {
luna: { model: "tts-inworld-1-5-max", voice: "Luna", lead: "[whispering] ", tail: "", speed: 0.77, format: "wav" },
eve: { model: "tts-xai-v1", voice: "eve", lead: "<whisper>", tail: "</whisper>", speed: 0.77, format: "mp3" },
};
export const WHISPER_DEFAULT = "luna";
/** the words after /whisper: a preset name first if there is one, the rest what it's about */
export function parseWhisper(arg = "") {
const m = /^\s*(\w+)(?:\s+|$)([\s\S]*)$/.exec(str(arg, 2000));
const first = m ? m[1].toLowerCase() : "";
if (WHISPER_PRESETS[first]) return { preset: first, brief: m[2].replace(/\s+/g, " ").trim() };
return { preset: "", brief: str(arg, 2000).replace(/\s+/g, " ").trim() };
}
export const WHISPER_SCHEMA = {
type: "object", additionalProperties: false, required: ["script"],
properties: { script: { type: "string" } },
};
export function whisperMessages(card, { brief = "", lines = [] } = {}) {
const system = `You are ${card.name}. Write a short ASMR voice note you whisper to the person you're talking with: ` +
"60 to 110 words, in your own voice and manner, intimate and calming, present tense, short phrases with pauses " +
"written as \"...\". Gentle sensory detail (breath, rain, a soft sound nearby) is welcome. No stage directions, no " +
"emoji, no markup, nothing in brackets: only the words to be whispered. Return one JSON object: script.";
const persona = card.persona ? clip(card.persona.replace(/\s+/g, " "), 900) : "";
const user = [
persona ? `Who you are: ${persona}` : "",
lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "You haven't talked yet.",
"What it's about: " + (str(brief, 500).trim() || "winding down together; help them relax."),
].filter(Boolean).join("\n\n");
return [{ role: "system", content: system }, { role: "user", content: user }];
}
/** the script, cleaned of anything the model shouldn't have added (brackets, asterisks, emoji-ish markup) */
export function cleanWhisper(o) {
return str(o && o.script, 1500).replace(/\[[^\]]*\]|\([^)]*\)|<[^>]*>|\*[^*]*\*/g, "").replace(/\s+/g, " ").trim();
}
/** what the voice model is sent: the script inside the preset's whisper markup */
export const whisperInput = (script, preset) => (preset.lead || "") + script + (preset.tail || "");
// ---------- photoshoot: a few photos of them, from the pictures on their card ----------
export const SHOOT_MIN = 1, SHOOT_MAX = 6, SHOOT_DEFAULT = 3;
+1 -1
View File
@@ -10,7 +10,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat
## How to use it
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately.
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately. Already have a `.md` file? Drop it anywhere on the page, or press **Open .md** above the editor; the file is read in the tab and replaces the text.
2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place.
3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more.
4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies.
+58
View File
@@ -576,6 +576,64 @@
if (copied) done(); else failed();
}
// Files: a .md (or any text file) dropped anywhere on the page, or picked with Open .md, becomes the
// deck. It is read in the tab with FileReader; nothing is uploaded.
var fileInput = document.getElementById("file");
var openBtn = document.getElementById("open");
var dragDepth = 0;
function hasFile(e) {
var t = e.dataTransfer && e.dataTransfer.types;
return !!t && Array.prototype.indexOf.call(t, "Files") >= 0;
}
function openFile(file) {
if (!file) return;
if (file.size > 2000000) {
say(file.name + " is over 2 MB. A deck is plain text, so something else is in that file.", true);
return;
}
var reader = new FileReader();
reader.onload = function () {
load(String(reader.result).replace(/^/, ""));
current = 0;
history.replaceState(null, "", location.href.split("#")[0]);
parse();
say("Opened " + file.name + ". Edits stay in this tab; copy a link or the HTML to keep them.");
};
reader.onerror = function () { say("Couldn't read " + file.name + ".", true); };
reader.readAsText(file);
}
openBtn.addEventListener("click", function () { fileInput.click(); });
fileInput.addEventListener("change", function () {
openFile(fileInput.files[0]);
fileInput.value = "";
});
document.addEventListener("dragenter", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
dragDepth++;
document.body.classList.add("dropping");
});
document.addEventListener("dragover", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("dragleave", function (e) {
if (!hasFile(e)) return;
if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); }
});
document.addEventListener("drop", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
dragDepth = 0;
document.body.classList.remove("dropping");
openFile(e.dataTransfer.files[0]);
});
// A deck in the address opens in place of the sample, and so does one pasted into it later.
var linked = /(^#|&)(d|md)=/.test(location.hash);
load(linked ? "" : SAMPLE);
+31 -5
View File
@@ -87,12 +87,34 @@
flex-direction: column;
min-height: 0;
}
.editor-pane .label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.editor-pane label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-dim);
margin-bottom: 6px;
}
.editor-pane .open { padding: 4px 10px; font-size: 0.78rem; }
#file { display: none; }
body.dropping textarea { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(110,168,254,0.25); }
body.dropping::after {
content: "Drop your .md file to open it";
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15,17,21,0.75);
color: var(--ink);
font-size: 1.3rem;
pointer-events: none;
z-index: 10;
}
textarea {
flex: 1;
@@ -402,12 +424,16 @@
</header>
<main id="try">
<section class="editor-pane">
<label for="src">Slides</label>
<div class="label-row">
<label for="src">Slides</label>
<button id="open" class="open" type="button">Open .md</button>
<input id="file" type="file" accept=".md,.markdown,.txt,text/markdown,text/plain">
</div>
<textarea id="src" spellcheck="false"></textarea>
<p class="hint">
<code># Title</code> heading · <code>## Subtitle</code> · <code>- bullet</code> point ·
<code>---</code> on its own line starts a new slide · inline <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>.
Plain lines become paragraphs.
Plain lines become paragraphs. Or drop a <code>.md</code> file anywhere on the page.
</p>
</section>
<section class="stage">
@@ -429,7 +455,7 @@
<div class="steps">
<div class="step">
<h3>Type your slides</h3>
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type.</p>
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type. Already have a <code>.md</code> file? Drop it on the page.</p>
</div>
<div class="step">
<h3>Watch it set live</h3>
@@ -475,7 +501,7 @@
<div class="price">$0</div>
<p>For anyone who needs a quick deck right now.</p>
<ul>
<li>Full plain-text editor</li>
<li>Full plain-text editor; drop in a <code>.md</code> file to start from one</li>
<li>Live navigable preview with arrow keys and progress bar</li>
<li>One-click copy of the deck as self-contained HTML</li>
<li>Links with the deck inside them, nothing stored</li>
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# tools/build.sh: shots/desktop.png, the first screen at 1440x900, rendered from this folder by headless Chrome.
# Time's refresh workflow runs this (Actions -> refresh, slug roll-credits). Needs python3 (for a local server) and
# Chrome or Chromium: set CHROME=/path/to/chrome if it isn't found, and CHROME_FLAGS=--no-sandbox when running as root.
set -eu
cd "$(dirname "$0")/.."
CHROME=${CHROME:-}
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" \
"$(command -v chromium-browser || true)" "$(command -v google-chrome || true)" "$(command -v google-chrome-stable || true)"; do
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
done
[ -n "$CHROME" ] || { echo "no Chrome or Chromium found; set CHROME" >&2; exit 1; }
PORT=${PORT:-8795}
python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
mkdir -p shots
out=$PWD/shots/desktop.png
prof=$(mktemp -d)
rm -f "$out"
# shellcheck disable=SC2086
"$CHROME" --headless=new --hide-scrollbars --no-first-run --no-default-browser-check --disable-background-networking \
--disable-component-update --user-data-dir="$prof" --virtual-time-budget=6000 --window-size=1440,900 \
--screenshot="$out" ${CHROME_FLAGS:-} "http://127.0.0.1:$PORT/" >"$prof.log" 2>&1 &
pid=$!
n=0
while kill -0 "$pid" 2>/dev/null && [ ! -s "$out" ]; do n=$((n + 1)); [ "$n" -lt 120 ] || break; sleep 0.5; done
sleep 1
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
[ -s "$out" ] || { echo "Chrome made no screenshot:" >&2; tail -5 "$prof.log" >&2; exit 1; }
rm -rf "$prof" "$prof.log"
echo "built: shots/desktop.png"
+16 -1
View File
@@ -1,5 +1,19 @@
{
"ventures": [
{
"slug": "gandora",
"name": "gandora",
"one_liner": "Type a seed, get a station: every song is written and rendered for you as it plays, every cover is drawn from the song's own settings, and your reactions steer what comes next.",
"url": "https://gandora.lol/",
"pages": "https://gandora.yolopreneur.lol/",
"launched": "2026-10-20",
"status": "live",
"signups": 0,
"source": "yolopreneur",
"repo": "https://teahub.xyz/gandora/site",
"hosting": "pages",
"shot": "https://gandora.yolopreneur.lol/shots/desktop.png"
},
{
"slug": "slyng",
"name": "SLYNG",
@@ -487,6 +501,7 @@
},
{
"slug": "roll-credits",
"shot": "roll-credits/shots/desktop.png",
"name": "Roll Credits",
"one_liner": "Type the names of the people who made something, pick a credits style, and download a scrolling end-credits video, entirely in your browser.",
"for_whom": "A team lead or friend-group organizer who wants to thank the people behind a launch, a trip, a bake-off, or a last day at a job, and wants it to feel like a film ending.",
@@ -505,5 +520,5 @@
"hosting": "pages"
}
],
"refreshed": "2026-10-20"
"refreshed": "2026-10-21"
}