flowgraphapp 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg">
2
+ <symbol id="bluesky-icon" viewBox="0 0 16 17">
3
+ <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
4
+ <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
5
+ </symbol>
6
+ <symbol id="discord-icon" viewBox="0 0 20 19">
7
+ <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
8
+ </symbol>
9
+ <symbol id="documentation-icon" viewBox="0 0 21 20">
10
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
11
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
12
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
13
+ </symbol>
14
+ <symbol id="github-icon" viewBox="0 0 19 19">
15
+ <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
16
+ </symbol>
17
+ <symbol id="social-icon" viewBox="0 0 20 20">
18
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
19
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
20
+ </symbol>
21
+ <symbol id="x-icon" viewBox="0 0 19 19">
22
+ <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
23
+ </symbol>
24
+ </svg>
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="description" content="FlowGraph — a local-first canvas where every diagram is a living knowledge graph." />
8
+ <title>FlowGraph</title>
9
+ <script type="module" crossorigin src="./assets/index-SiLO3vOy.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-BE9FlCos.css">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ </body>
15
+ </html>
flowgraph/ai_proxy.py ADDED
@@ -0,0 +1,146 @@
1
+ """POST /api/ai/chat — the local mirror of the Cloudflare Worker's AI proxy.
2
+
3
+ Same request/response contract as worker/src/index.ts so the SAME SPA build talks
4
+ to either backend (charter §1). The provider key stays server-side — it is read
5
+ from the keychain and used here; it is NEVER returned to the browser. Every call
6
+ emits an ordered StepTrace + run id (charter §2 traceability) and reports the active
7
+ provider + key source (charter §8 honesty).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ import time
14
+ from typing import Any, Optional
15
+
16
+ import httpx
17
+
18
+ from .providers import DEFAULT_MODELS, ProviderChoice, resolve_provider
19
+
20
+
21
+ def _step(name: str, status: str, t0: float, error: Optional[str] = None) -> dict:
22
+ return {
23
+ "name": name,
24
+ "status": status,
25
+ "durationMs": round((time.perf_counter() - t0) * 1000, 1),
26
+ "error": error,
27
+ }
28
+
29
+
30
+ async def _call_anthropic(client: httpx.AsyncClient, choice: ProviderChoice, req: dict) -> dict:
31
+ body: dict[str, Any] = {
32
+ "model": req.get("model") or DEFAULT_MODELS["anthropic"],
33
+ "max_tokens": req.get("max_tokens", 1024),
34
+ "messages": req.get("messages", []),
35
+ }
36
+ if req.get("system"):
37
+ body["system"] = req["system"]
38
+ r = await client.post(
39
+ choice.base_url + "/v1/messages",
40
+ json=body,
41
+ headers={
42
+ "x-api-key": choice.api_key or "",
43
+ "anthropic-version": "2023-06-01",
44
+ "content-type": "application/json",
45
+ },
46
+ timeout=120.0,
47
+ )
48
+ r.raise_for_status()
49
+ data = r.json()
50
+ text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
51
+ usage = data.get("usage", {})
52
+ return {
53
+ "text": text or None,
54
+ "model": data.get("model"),
55
+ "usage": {"input": usage.get("input_tokens"), "output": usage.get("output_tokens")},
56
+ }
57
+
58
+
59
+ async def _call_openai_compat(client: httpx.AsyncClient, choice: ProviderChoice, req: dict) -> dict:
60
+ messages = []
61
+ if req.get("system"):
62
+ messages.append({"role": "system", "content": req["system"]})
63
+ messages.extend(req.get("messages", []))
64
+ body: dict[str, Any] = {
65
+ "model": req.get("model") or DEFAULT_MODELS.get(choice.kind, ""),
66
+ "messages": messages,
67
+ "max_tokens": req.get("max_tokens", 1024),
68
+ }
69
+ headers = {"content-type": "application/json"}
70
+ if choice.api_key:
71
+ headers["authorization"] = f"Bearer {choice.api_key}"
72
+ r = await client.post(
73
+ choice.base_url + "/v1/chat/completions", json=body, headers=headers, timeout=120.0
74
+ )
75
+ r.raise_for_status()
76
+ data = r.json()
77
+ choice0 = (data.get("choices") or [{}])[0]
78
+ text = (choice0.get("message") or {}).get("content")
79
+ usage = data.get("usage", {})
80
+ return {
81
+ "text": text,
82
+ "model": data.get("model"),
83
+ "usage": {"input": usage.get("prompt_tokens"), "output": usage.get("completion_tokens")},
84
+ }
85
+
86
+
87
+ async def proxy_chat(
88
+ req: dict,
89
+ *,
90
+ prefer_local: bool = True,
91
+ client: Optional[httpx.AsyncClient] = None,
92
+ choice: Optional[ProviderChoice] = None,
93
+ ) -> dict:
94
+ """Run one chat completion through the resolved provider. Returns the Worker-shaped
95
+ response augmented with `provider`, `keySource`, `requestId`, and `trace`."""
96
+ run_id = secrets.token_hex(8)
97
+ trace: list[dict] = []
98
+ own_client = client is None
99
+ client = client or httpx.AsyncClient()
100
+ try:
101
+ if choice is None:
102
+ t0 = time.perf_counter()
103
+ try:
104
+ choice = await resolve_provider(client, prefer_local=prefer_local)
105
+ trace.append(_step("resolve-provider", "ok" if choice else "empty", t0,
106
+ None if choice else "no local runtime and no BYOK key configured"))
107
+ except Exception as e: # pragma: no cover - defensive
108
+ trace.append(_step("resolve-provider", "error", t0, str(e)))
109
+ return {"text": None, "error": "provider-resolution-failed", "requestId": run_id, "trace": trace}
110
+ if choice is None:
111
+ return {
112
+ "text": None,
113
+ "error": "no-provider",
114
+ "detail": "Start a local model (Ollama/LM Studio) or set a provider key with `flowgraph keys set`.",
115
+ "requestId": run_id,
116
+ "trace": trace,
117
+ }
118
+
119
+ t1 = time.perf_counter()
120
+ try:
121
+ if choice.is_anthropic:
122
+ result = await _call_anthropic(client, choice, req)
123
+ else:
124
+ result = await _call_openai_compat(client, choice, req)
125
+ trace.append(_step("provider-call", "ok", t1))
126
+ except httpx.HTTPStatusError as e:
127
+ trace.append(_step("provider-call", "error", t1, f"HTTP {e.response.status_code}"))
128
+ return {"text": None, "error": "provider-http-error", "status": e.response.status_code,
129
+ "provider": choice.kind, "keySource": choice.key_source, "requestId": run_id, "trace": trace}
130
+ except Exception as e:
131
+ trace.append(_step("provider-call", "error", t1, str(e)))
132
+ return {"text": None, "error": "provider-call-failed", "provider": choice.kind,
133
+ "keySource": choice.key_source, "requestId": run_id, "trace": trace}
134
+
135
+ # NOTE: choice.api_key is deliberately NEVER included in the response.
136
+ result.update({
137
+ "provider": choice.kind,
138
+ "providerLabel": choice.label,
139
+ "keySource": choice.key_source,
140
+ "requestId": run_id,
141
+ "trace": trace,
142
+ })
143
+ return result
144
+ finally:
145
+ if own_client:
146
+ await client.aclose()
flowgraph/cli.py ADDED
@@ -0,0 +1,201 @@
1
+ """`flowgraph` command — launch the local server, manage keys, self-upgrade.
2
+
3
+ Bare `flowgraph` runs the server on 127.0.0.1 with auth ON. Off-loopback exposure
4
+ (`--allow-lan`) is a loud, deliberate opt-in that REQUIRES TLS (see docs/18 threat
5
+ model). The session token is delivered out-of-band (terminal + a 0600 file); the
6
+ auto-opened URL carries only a single-use ticket, never the long-lived token.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import socket
13
+ import sys
14
+ import threading
15
+ import webbrowser
16
+
17
+ import typer
18
+
19
+ from . import __version__, keys as keymod, update_check
20
+ from .config import ServerConfig, resolve_static_dir, state_path
21
+ from .security import new_session_token, TicketStore
22
+
23
+ app = typer.Typer(add_completion=False, help="FlowGraph — run the local-first canvas on your own machine.")
24
+ keys_app = typer.Typer(help="Manage provider API keys (stored in the OS keychain).")
25
+ app.add_typer(keys_app, name="keys")
26
+
27
+
28
+ def _pick_free_port(host: str) -> int:
29
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
30
+ try:
31
+ s.bind((host if host != "::1" else "127.0.0.1", 0))
32
+ return s.getsockname()[1]
33
+ finally:
34
+ s.close()
35
+
36
+
37
+ def _write_token_file(token: str) -> str:
38
+ path = state_path("session-token")
39
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
40
+ try:
41
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
42
+ f.write(token + "\n")
43
+ finally:
44
+ try:
45
+ os.chmod(path, 0o600)
46
+ except OSError:
47
+ pass
48
+ return str(path)
49
+
50
+
51
+ def _run_server(config: ServerConfig) -> None:
52
+ import uvicorn
53
+
54
+ from .server import create_app
55
+
56
+ # Resolve the SPA up-front so a missing build fails fast with a clear message.
57
+ try:
58
+ config.static_dir = resolve_static_dir()
59
+ except FileNotFoundError as e:
60
+ typer.secho(str(e), fg="red", err=True)
61
+ raise typer.Exit(code=1)
62
+
63
+ # Off-loopback exposure must carry TLS (token over plain HTTP off-loopback is unsafe).
64
+ if config.allow_lan:
65
+ config.host = "0.0.0.0"
66
+ if not (config.tls_certfile and config.tls_keyfile):
67
+ typer.secho(
68
+ "Refusing to expose FlowGraph beyond loopback without TLS. "
69
+ "Pass --tls-certfile and --tls-keyfile, or drop --allow-lan.",
70
+ fg="red", err=True,
71
+ )
72
+ raise typer.Exit(code=1)
73
+ typer.secho("WARNING: serving on 0.0.0.0 — reachable by your whole network.", fg="yellow", err=True)
74
+
75
+ if config.port == 0:
76
+ config.port = _pick_free_port(config.host)
77
+
78
+ config.session_token = new_session_token()
79
+ tickets = TicketStore()
80
+ ticket = tickets.mint()
81
+
82
+ fg_app = create_app(config, tickets)
83
+
84
+ display_host = "127.0.0.1" if config.host in ("0.0.0.0", "::1") else config.host
85
+ base_url = f"{config.scheme}://{display_host}:{config.port}"
86
+ open_url = f"{base_url}/?ticket={ticket}" if config.auth_enabled else base_url
87
+
88
+ token_file = _write_token_file(config.session_token)
89
+
90
+ typer.secho("\n FlowGraph (local)", fg="cyan", bold=True)
91
+ typer.echo(f" → Open: {open_url}")
92
+ typer.echo(f" Bound: {config.host}:{config.port} ({'auth on' if config.auth_enabled else 'AUTH OFF'})")
93
+ if config.auth_enabled:
94
+ typer.echo(f" Session token (for CLI/API clients): {token_file}")
95
+ typer.echo(" Stop with Ctrl-C.\n")
96
+
97
+ if config.open_browser:
98
+ def _open():
99
+ try:
100
+ webbrowser.open(open_url)
101
+ except Exception:
102
+ pass
103
+ fg_app.add_event_handler("startup", lambda: threading.Timer(0.3, _open).start())
104
+
105
+ update_check.check_in_background()
106
+
107
+ # Hardened uvicorn flags: do NOT trust proxy headers (X-Forwarded-* spoofing),
108
+ # disable access logs so the one-time ticket never lands in a log, hide the
109
+ # Server header. The Host/Origin/token checks live in our ASGI middleware.
110
+ uvicorn.run(
111
+ fg_app,
112
+ host=config.host,
113
+ port=config.port,
114
+ proxy_headers=False,
115
+ forwarded_allow_ips="",
116
+ access_log=False,
117
+ server_header=False,
118
+ log_level="warning",
119
+ ssl_certfile=config.tls_certfile,
120
+ ssl_keyfile=config.tls_keyfile,
121
+ )
122
+
123
+
124
+ @app.callback(invoke_without_command=True)
125
+ def main_callback(
126
+ ctx: typer.Context,
127
+ port: int = typer.Option(8765, "--port", "-p", help="Port (0 = pick a free one)."),
128
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser on start."),
129
+ no_auth: bool = typer.Option(False, "--no-auth", help="Disable the session token (single-user trusted machine only)."),
130
+ allow_lan: bool = typer.Option(False, "--allow-lan", help="Expose beyond loopback (requires --tls-*)."),
131
+ tls_certfile: str = typer.Option(None, "--tls-certfile", help="TLS cert (enables HTTPS)."),
132
+ tls_keyfile: str = typer.Option(None, "--tls-keyfile", help="TLS key."),
133
+ local_model_first: bool = typer.Option(True, "--local-first/--byok-first", help="Prefer a local model, else BYOK."),
134
+ ) -> None:
135
+ """Run the FlowGraph local server (default action)."""
136
+ if ctx.invoked_subcommand is not None:
137
+ return
138
+ if no_auth:
139
+ typer.secho("WARNING: --no-auth disables the session token; any local process can reach the server.", fg="yellow", err=True)
140
+ config = ServerConfig(
141
+ port=port,
142
+ open_browser=open_browser,
143
+ auth_enabled=not no_auth,
144
+ allow_lan=allow_lan,
145
+ tls_certfile=tls_certfile,
146
+ tls_keyfile=tls_keyfile,
147
+ local_model_first=local_model_first,
148
+ )
149
+ _run_server(config)
150
+
151
+
152
+ @app.command()
153
+ def version() -> None:
154
+ """Print the installed version."""
155
+ typer.echo(f"flowgraph {__version__} (installed: {update_check.installed_version()})")
156
+
157
+
158
+ @app.command()
159
+ def upgrade() -> None:
160
+ """Upgrade FlowGraph to the latest release (pip or pipx)."""
161
+ import subprocess
162
+ cmd = update_check._upgrade_command()
163
+ typer.echo(f"Running: {cmd}")
164
+ parts = cmd.split()
165
+ if parts[0] == "pip":
166
+ parts = [sys.executable, "-m", *parts]
167
+ raise typer.Exit(code=subprocess.call(parts))
168
+
169
+
170
+ @keys_app.command("set")
171
+ def keys_set(provider: str = typer.Argument(..., help="anthropic | openrouter | openai | google | deepseek")) -> None:
172
+ """Store a provider key (prompts hidden; saved to the OS keychain)."""
173
+ value = typer.prompt(f"{provider} API key", hide_input=True)
174
+ backend = keymod.set_key(provider, value)
175
+ if backend == "file":
176
+ typer.secho("No OS keychain available — saved to a 0600 config file instead.", fg="yellow")
177
+ typer.secho(f"Saved {provider} key ({backend}).", fg="green")
178
+
179
+
180
+ @keys_app.command("list")
181
+ def keys_list() -> None:
182
+ """Show which providers have a key and where it comes from (never the key itself)."""
183
+ for provider in keymod.ENV_VARS:
184
+ _, source = keymod.get_key(provider)
185
+ mark = "·" if source == "none" else "✓"
186
+ typer.echo(f" {mark} {provider:<11} {source}")
187
+
188
+
189
+ @keys_app.command("rm")
190
+ def keys_rm(provider: str = typer.Argument(...)) -> None:
191
+ """Remove a stored provider key."""
192
+ keymod.delete_key(provider)
193
+ typer.secho(f"Removed {provider} key.", fg="green")
194
+
195
+
196
+ def main() -> None:
197
+ app()
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
flowgraph/config.py ADDED
@@ -0,0 +1,125 @@
1
+ """Runtime configuration, paths, and static-asset resolution.
2
+
3
+ All user-writable paths go through ``platformdirs`` (never a hardcoded ~/.config).
4
+ The static SPA dir is resolved in three steps so the package works both as an
5
+ installed wheel and from a dev checkout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.resources
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from platformdirs import PlatformDirs
17
+
18
+ APP_NAME = "flowgraph"
19
+ APP_AUTHOR = "FlowGraph"
20
+
21
+ _dirs = PlatformDirs(appname=APP_NAME, appauthor=APP_AUTHOR)
22
+
23
+ # Provider base URLs the SPA may talk to directly (BYOK mode) — used to scope CSP connect-src.
24
+ PROVIDER_ORIGINS = (
25
+ "https://api.anthropic.com",
26
+ "https://openrouter.ai",
27
+ "https://api.openai.com",
28
+ "https://generativelanguage.googleapis.com",
29
+ "https://api.deepseek.com",
30
+ )
31
+
32
+ # Common OpenAI-compatible local-runtime endpoints (Ollama / LM Studio / llama.cpp).
33
+ LOCAL_RUNTIME_PROBES = (
34
+ ("ollama", "http://127.0.0.1:11434"),
35
+ ("lmstudio", "http://127.0.0.1:1234"),
36
+ ("llamacpp", "http://127.0.0.1:8080"),
37
+ )
38
+
39
+
40
+ def config_dir() -> Path:
41
+ p = Path(_dirs.user_config_dir)
42
+ p.mkdir(parents=True, exist_ok=True)
43
+ return p
44
+
45
+
46
+ def data_dir() -> Path:
47
+ p = Path(_dirs.user_data_dir)
48
+ p.mkdir(parents=True, exist_ok=True)
49
+ return p
50
+
51
+
52
+ def cache_dir() -> Path:
53
+ p = Path(_dirs.user_cache_dir)
54
+ p.mkdir(parents=True, exist_ok=True)
55
+ return p
56
+
57
+
58
+ def state_path(name: str) -> Path:
59
+ """A 0700 per-user state dir for short-lived secrets (the session-token file)."""
60
+ base = Path(_dirs.user_state_dir) if hasattr(_dirs, "user_state_dir") else Path(_dirs.user_data_dir)
61
+ base.mkdir(parents=True, exist_ok=True)
62
+ try:
63
+ os.chmod(base, 0o700)
64
+ except OSError:
65
+ pass
66
+ return base / name
67
+
68
+
69
+ def resolve_static_dir() -> Path:
70
+ """Locate the compiled SPA. Order: explicit env > bundled wheel > dev checkout."""
71
+ env = os.environ.get("FLOWGRAPH_STATIC_DIR")
72
+ if env:
73
+ p = Path(env).expanduser().resolve()
74
+ if (p / "index.html").is_file():
75
+ return p
76
+ raise FileNotFoundError(f"FLOWGRAPH_STATIC_DIR={env} has no index.html")
77
+
78
+ # Installed wheel: flowgraph/_static (populated by hatch_build.py).
79
+ try:
80
+ bundled = importlib.resources.files("flowgraph") / "_static"
81
+ bundled_path = Path(str(bundled))
82
+ if (bundled_path / "index.html").is_file():
83
+ return bundled_path
84
+ except (ModuleNotFoundError, FileNotFoundError):
85
+ pass
86
+
87
+ # Dev checkout: <repo>/app/dist relative to this file (server/flowgraph/config.py).
88
+ dev = Path(__file__).resolve().parents[2] / "app" / "dist"
89
+ if (dev / "index.html").is_file():
90
+ return dev
91
+
92
+ raise FileNotFoundError(
93
+ "FlowGraph SPA not found. Build it with `cd app && npm run build`, "
94
+ "or set FLOWGRAPH_STATIC_DIR to a directory containing index.html."
95
+ )
96
+
97
+
98
+ @dataclass
99
+ class ServerConfig:
100
+ host: str = "127.0.0.1"
101
+ port: int = 8765
102
+ open_browser: bool = True
103
+ auth_enabled: bool = True
104
+ local_model_first: bool = True
105
+ # docs/18 §4.8/§7.4 — the server runs an AI proxy by default (GET /config advertises
106
+ # transport='proxy'), so the SPA routes AI same-origin and never needs to reach a
107
+ # provider/local-runtime origin directly. proxy_mode=True ⇒ tighten CSP connect-src to
108
+ # 'self'. Set False (BYOK-direct) only to let the browser call providers itself.
109
+ proxy_mode: bool = True
110
+ # Off-loopback exposure is a loud, deliberate opt-in (see cli.py / security.py).
111
+ allow_lan: bool = False
112
+ tls_certfile: Optional[str] = None
113
+ tls_keyfile: Optional[str] = None
114
+ static_dir: Optional[Path] = None
115
+ # Populated at launch; never logged.
116
+ session_token: str = ""
117
+ extra_connect_src: tuple = field(default_factory=tuple)
118
+
119
+ @property
120
+ def scheme(self) -> str:
121
+ return "https" if self.tls_certfile and self.tls_keyfile else "http"
122
+
123
+ @property
124
+ def is_loopback(self) -> bool:
125
+ return self.host in ("127.0.0.1", "::1", "localhost")