Spaces:
Running
Running
Aster Vale commited on
Commit ·
2dee62e
1
Parent(s): 0161d7a
update: Per-Job pricing to $0.033/estimate — 1,000 jobs = $33 vs $15 Pro subscription
Browse files- BUILD-READINESS.md +173 -0
- SKILL.md +115 -0
- build.md +167 -0
- build.mjs +99 -0
- dist/loomlens-clusters.js +310 -0
- dist/loomlens-engine.js +629 -0
- dist/loomlens-sidebar.html +1156 -0
- loomlens-clusters.js +310 -0
- loomlens-engine.js +237 -0
- loomlens-engine.ts +324 -0
- loomlens-models.ts +213 -0
- loomlens-openclaw-plugin.ts +194 -0
- loomlens-panel.tsx +489 -0
- loomlens-sidebar.html +1156 -0
- loomlensai-pro.html +19 -24
- package.json +21 -0
- x-thread-loomlens-live.md +100 -0
BUILD-READINESS.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LoomLens Live — Security & Build-Readiness Audit
|
| 2 |
+
**Date:** 2026-04-04
|
| 3 |
+
**Status:** Internal — Aster Vale
|
| 4 |
+
**Scope:** loomlens-sidebar.html, loomlens-engine.js, Signal Loom API estimate endpoints
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Executive Summary
|
| 9 |
+
|
| 10 |
+
LoomLens Live has a strong foundation — the local estimation engine works, the cluster model is sound, the free tier is enforced server-side at 50 calls/hr per IP, and privacy is baked in (no prompts logged server-side). **Fixed as of 2026-04-04 build:**
|
| 11 |
+
1. ✅ Billing is now wired — `POST /v1/estimate` is called on "Run Estimate" click (the billable event). Typing previews use the local engine (always free, unlimited).
|
| 12 |
+
2. ✅ Free daily limit now only counts on explicit "Run Estimate" clicks (not per keystroke).
|
| 13 |
+
3. ✅ Server-side IP rate limit (50/hr) on `/estimate/free` provides real abuse protection.
|
| 14 |
+
4. ✅ Quota display updates from API response headers after each run. Security is acceptable for a developer-facing product with the mitigations noted below.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 1. Security Audit
|
| 19 |
+
|
| 20 |
+
### 1.1 API Key Storage — localStorage ⚠️ LOW-MEDIUM
|
| 21 |
+
|
| 22 |
+
**Finding:** API key stored in plaintext in `localStorage` (`llive_api_key`).
|
| 23 |
+
|
| 24 |
+
**Risk:** Any XSS vector in the host page (OpenClaw webchat) could exfiltrate the key. In a self-hosted OpenClaw environment this is low risk. In a shared or untrusted environment it is medium.
|
| 25 |
+
|
| 26 |
+
**Mitigations:**
|
| 27 |
+
- [x] Key is used only for API calls to `api.signallloomai.com` (not exposed to arbitrary origins)
|
| 28 |
+
- [ ] Add a warning in the UI: *"For production: use a dedicated API key with minimal permissions. Do not use your primary account key."*
|
| 29 |
+
- [ ] Future: hash key before storing, use session-scoped tokens instead
|
| 30 |
+
|
| 31 |
+
**Acceptable for:** v1 developer launch (users control their own environment).
|
| 32 |
+
**Required before:** Embedding on untrusted public websites.
|
| 33 |
+
|
| 34 |
+
### 1.2 Free Tier Enforcement — Client-Side Only ⚠️ MEDIUM
|
| 35 |
+
|
| 36 |
+
**Finding:** Free daily limit (3/day) is tracked in `localStorage`. A user can clear localStorage to get unlimited free estimates.
|
| 37 |
+
|
| 38 |
+
**Current state:** `localStorage.getItem('llive_free_used')` is the only enforcement.
|
| 39 |
+
|
| 40 |
+
**Mitigation options:**
|
| 41 |
+
|
| 42 |
+
| Option | Effort | Effectiveness |
|
| 43 |
+
|--------|--------|---------------|
|
| 44 |
+
| Rely on server-side `/estimate/free` IP rate limit (50/hr) | None | ✅ Already in place |
|
| 45 |
+
| Track free count server-side per IP + require API call to increment | High | ✅ Most robust |
|
| 46 |
+
| Accept client-side only for v1, disclose honestly | Low | ⚠️ User can bypass |
|
| 47 |
+
|
| 48 |
+
**Recommendation:** For v1, note in the UI that the 3/day free limit is a courtesy. The server-side IP rate limit of 50/hr on `/estimate/free` already provides real abuse protection. If the user clears localStorage, they hit the IP limit at 50/hr which is more restrictive than 3/day anyway.
|
| 49 |
+
|
| 50 |
+
### 1.3 API Endpoint Rate Limiting ✅ GOOD
|
| 51 |
+
|
| 52 |
+
**Finding:** `/v1/estimate/free` is IP-rate-limited to 50 calls/hour per IP.
|
| 53 |
+
**`/v1/estimate`** (billed) checks quota via `check_estimate_limit()` before allowing.
|
| 54 |
+
Both are server-enforced. ✅
|
| 55 |
+
|
| 56 |
+
### 1.4 Prompt Privacy ✅ GOOD
|
| 57 |
+
|
| 58 |
+
**Finding:** The Signal Loom API does NOT log prompts or job descriptions server-side. Only aggregate model usage and cost estimates are recorded (`record_loomlens_estimate` stores: job_id, api_key_id, email, num_agents, complexity, model costs, IP). No prompt content. ✅
|
| 59 |
+
|
| 60 |
+
### 1.5 CORS / API Exposure ⚠️ LOW
|
| 61 |
+
|
| 62 |
+
**Finding:** The API (`api.signallloomai.com`) must accept cross-origin requests from any domain where the sidebar is embedded. This is standard for API products.
|
| 63 |
+
|
| 64 |
+
**Mitigation:** API keys are sent as `Authorization: Bearer` headers (not URL parameters), so they don't appear in server access logs. ✅
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 2. Functional Gaps (Must-Fix)
|
| 69 |
+
|
| 70 |
+
### 2.1 ✅ FIXED — Billing Now Wired (2026-04-04)
|
| 71 |
+
|
| 72 |
+
**Implementation:**
|
| 73 |
+
- `estimate()` → local engine only (free preview, unlimited typing)
|
| 74 |
+
- `runEstimate()` → calls `POST /v1/estimate` (billed per-call) on "Run Estimate" click
|
| 75 |
+
- Free users: unlimited local previews, "Run Estimate" prompts for API key
|
| 76 |
+
- API-connected users: local previews (free), bill on "Run Estimate" click
|
| 77 |
+
- Quota updated from `X-Estimate-Limit` / `X-Estimate-Remaining` response headers
|
| 78 |
+
|
| 79 |
+
**Verified in code:**
|
| 80 |
+
- `estimate()` no longer calls API on keystroke ✅
|
| 81 |
+
- `runEstimate()` calls `POST /v1/estimate` ✅
|
| 82 |
+
- Free limit only decremented on `runEstimate()` (not keystrokes) ✅
|
| 83 |
+
- Server-side IP rate limit (50/hr) on `/estimate/free` ✅
|
| 84 |
+
|
| 85 |
+
### 2.2 ✅ FIXED — Free Tier UX (2026-04-04)
|
| 86 |
+
|
| 87 |
+
- Counter shows **-3, -2, -1** (countdown, negative = free runs remaining)
|
| 88 |
+
- On 4th click: alert prompts to connect API key for paid runs
|
| 89 |
+
- Local engine previews are unlimited (no limit on typing)
|
| 90 |
+
- Free counter resets at midnight UTC
|
| 91 |
+
- Server-side IP rate limit (50/hr) on `/estimate/free` provides abuse protection
|
| 92 |
+
|
| 93 |
+
### 2.3 ⚠️ OPEN: Model Dispatch Not Tested End-to-End
|
| 94 |
+
|
| 95 |
+
**Finding:** `runEstimate()` fires `CustomEvent('loomlens:run')` + `postMessage('loomlens_run')` to parent. No handler verified in OpenClaw.
|
| 96 |
+
|
| 97 |
+
**Open questions:**
|
| 98 |
+
- Does OpenClaw webchat listen for these events?
|
| 99 |
+
- Does `before_model_resolve` hook intercept model selection?
|
| 100 |
+
- Needs end-to-end test in live OpenClaw environment
|
| 101 |
+
|
| 102 |
+
**Status:** Not yet verified — this is the remaining P0 before publish.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## 3. Build-Ready Checklist
|
| 107 |
+
|
| 108 |
+
### 3.1 Security
|
| 109 |
+
- [x] Server-side rate limit on free endpoint (50/hr IP) ✅
|
| 110 |
+
- [x] No prompt content logged ✅
|
| 111 |
+
- [x] API key in Authorization header (not URL) ✅
|
| 112 |
+
- [ ] Add key security warning in UI
|
| 113 |
+
- [ ] Audit CORS config on api.signallloomai.com
|
| 114 |
+
|
| 115 |
+
### 3.2 Functional
|
| 116 |
+
- [x] Local estimation engine works ✅ (verified in Node.js)
|
| 117 |
+
- [x] Free tier (3/day, per-run only, not per-keystroke) ✅
|
| 118 |
+
- [x] API key connection flow ✅
|
| 119 |
+
- [x] Cluster model + scoring ✅
|
| 120 |
+
- [x] **Billing wired: `POST /v1/estimate` on "Run Estimate" click** ✅ (2026-04-04)
|
| 121 |
+
- [ ] **Test model dispatch end-to-end** (critical) — `loomlens:run` event needs handler
|
| 122 |
+
- [x] Better CTA when free tier exhausted ✅ (error state with signup links)
|
| 123 |
+
- [ ] Keyboard shortcut Cmd/Ctrl+L works
|
| 124 |
+
|
| 125 |
+
### 3.3 Revenue / Rev-Share
|
| 126 |
+
- [x] Rev-share program designed (Loom Partners: 23% / 28%) ✅ `REV_SHARE_ANALYSIS.md`
|
| 127 |
+
- [ ] Developer landing page explaining rev-share program
|
| 128 |
+
- [ ] Mechanism for partners to generate and distribute API keys
|
| 129 |
+
- [ ] Payout mechanism (monthly credit vs wire threshold)
|
| 130 |
+
- [ ] Partner dashboard for tracking usage
|
| 131 |
+
|
| 132 |
+
### 3.4 Distribution
|
| 133 |
+
- [ ] `dist/loomlens-sidebar.html` deployable to signalloomai.com
|
| 134 |
+
- [ ] HF Space deployment (hf.space/astar-aehs/loomlens-live)
|
| 135 |
+
- [ ] Chrome Extension injection setup
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## 4. Rev-Share Integration Status
|
| 140 |
+
|
| 141 |
+
The rev-share program (`LOOM PARTNERS`) is **designed but not yet implemented as a product**.
|
| 142 |
+
|
| 143 |
+
| Component | Status |
|
| 144 |
+
|-----------|--------|
|
| 145 |
+
| Rate structure (23% / 28%) | ✅ Designed |
|
| 146 |
+
| Tier qualification (10K / 100K mins/mo) | ✅ Designed |
|
| 147 |
+
| Payout mechanism (monthly credit) | ⚠️ Designed, not built |
|
| 148 |
+
| Partner API key generation | ⚠️ Not built |
|
| 149 |
+
| Partner dashboard / usage tracking | ⚠️ Not built |
|
| 150 |
+
| LoomLens Live as developer acquisition funnel | ✅ Valid product strategy |
|
| 151 |
+
|
| 152 |
+
**Correct framing for v1:**
|
| 153 |
+
LoomLens Live → drives Signal Loom API key signups → users pay for actual transcription usage → Signal Loom retains revenue. Rev-share (Loom Partners) is a separate product for platforms/partners who want to embed SLS and earn credit on their users' usage. That is a larger enterprise conversation.
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## 5. Recommended Action Plan
|
| 158 |
+
|
| 159 |
+
### P0 — Must Fix Before Publish
|
| 160 |
+
1. [ ] **Clarify billing model:** Either wire `POST /v1/estimate` calls OR explicitly position LoomLens Live as a free developer tool that drives API key adoption. Both are valid — pick one and document it.
|
| 161 |
+
2. [ ] **Test model dispatch:** Verify the `before_model_resolve` hook works with actual model override. This is the core value prop.
|
| 162 |
+
|
| 163 |
+
### P1 — Before Developer Launch
|
| 164 |
+
3. [ ] Add API key security warning in the sidebar footer
|
| 165 |
+
4. [ ] Improve CTA when free tier is exhausted (make it the primary action)
|
| 166 |
+
5. [ ] Deploy `loomlens-sidebar.html` to signalloomai.com as the LoomLens Live demo
|
| 167 |
+
6. [ ] Write LoomLens Live section on signallloomai.com/pricing page
|
| 168 |
+
|
| 169 |
+
### P2 — Rev-Share (Later)
|
| 170 |
+
7. [ ] Partner API key distribution mechanism
|
| 171 |
+
8. [ ] Partner dashboard for usage tracking
|
| 172 |
+
9. [ ] Loom Partners landing page
|
| 173 |
+
|
SKILL.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LoomLens Live — OpenClaw Skill
|
| 2 |
+
|
| 3 |
+
**Skill Name:** `loomlens-live`
|
| 4 |
+
**Command:** `/loomlens`
|
| 5 |
+
**Type:** OpenClaw AgentSkill (sidebar panel skill)
|
| 6 |
+
**Phase:** Ready to install
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## What It Does
|
| 11 |
+
|
| 12 |
+
Opens the **LoomLens Live sidebar panel** inside OpenClaw. The panel shows real-time cost estimates for the current prompt across optimized model clusters, recommends the best model within each cluster, and lets you dispatch to any model with one click.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
|
| 18 |
+
- **6 model clusters** — Flash Reasoning, Fast General, Balanced Coding, Power, Vision
|
| 19 |
+
- **Within-group optimization** — recommends cheapest model that clears the capability bar
|
| 20 |
+
- **Per-call billing** — type freely for free; only "Run Estimate" deducts from Signal Loom account
|
| 21 |
+
- **3 free runs/day** — local preview is unlimited; runs against your Signal Loom key
|
| 22 |
+
- **Drag & drop** — drop any text into the sidebar to estimate
|
| 23 |
+
- **Keyboard shortcut** — `Cmd/Ctrl+L` to open from anywhere
|
| 24 |
+
- **Model dispatch** — select any model and it overrides the next prompt's model via `before_model_resolve` hook
|
| 25 |
+
- **Rev-share ready** — developers earn 23–28% revenue share on estimate calls
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Billing Model
|
| 30 |
+
|
| 31 |
+
| Action | Cost |
|
| 32 |
+
|--------|------|
|
| 33 |
+
| Type a prompt (local preview) | Free — no API call |
|
| 34 |
+
| "Run Estimate" (no API key) | Prompts to connect key |
|
| 35 |
+
| "Run Estimate" (API key connected) | Per-call billing — deducts from Signal Loom quota |
|
| 36 |
+
| Rev-share payout | 23% (Loom Partner) / 28% (Loom Elite) on net estimate revenue |
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Freemium
|
| 41 |
+
|
| 42 |
+
| Tier | Limits | Auth |
|
| 43 |
+
|------|--------|------|
|
| 44 |
+
| Free | 3 runs / 24h (local preview unlimited) | None |
|
| 45 |
+
| API Key | Unlimited runs | Signal Loom API key |
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Rev-Share: Loom Partners Program
|
| 50 |
+
|
| 51 |
+
| Tier | Rate | Qualification |
|
| 52 |
+
|------|------|--------------|
|
| 53 |
+
| Loom Partner | 23% | 10K mins/month minimum |
|
| 54 |
+
| Loom Elite | 28% | 100K mins/month or signed agreement |
|
| 55 |
+
|
| 56 |
+
See `REV_SHARE_ANALYSIS.md` in the Signal Loom API workspace for full margin and payout analysis.
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Model Clusters
|
| 61 |
+
|
| 62 |
+
| Cluster | Models | Best For |
|
| 63 |
+
|---------|--------|----------|
|
| 64 |
+
| **Flash Reasoning** | MiniMax M2, Gemini 2.5 Flash, Claude Haiku, DeepSeek v3 | Q&A, summaries, simple tasks |
|
| 65 |
+
| **Fast General** | GPT-4o Mini, Gemini 2.0 Flash, xAI Grok-3 | Fast general purpose |
|
| 66 |
+
| **Balanced Coding** | Claude Sonnet 4, GPT-4o, Claude 3.5 Sonnet | Medium-complexity code |
|
| 67 |
+
| **Power / Architecture** | Claude Opus 4, Claude Code | Complex builds, architecture |
|
| 68 |
+
| **Vision / Multimodal** | GPT-4o, Claude 3.5 | Images, PDFs, documents |
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Scoring
|
| 73 |
+
|
| 74 |
+
```
|
| 75 |
+
score = (capability_fit × 0.35) + (cost_efficiency × 0.35) + (speed × 0.30)
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Within each cluster: cheapest model that scores ≥65 gets the **💡 Best in group** badge.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## Installation
|
| 83 |
+
|
| 84 |
+
### Option A: Workspace Hook (Quick)
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
cp -r signal-loom-loomlens/ ~/.openclaw/workspace/hooks/loomlens-live/
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### Option B: OpenClaw Skill Install (When supported)
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
openclaw skills install ./signal-loom-loomlens
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## Files
|
| 99 |
+
|
| 100 |
+
```
|
| 101 |
+
signal-loom-loomlens/
|
| 102 |
+
├── SKILL.md — This file
|
| 103 |
+
├── loomlens-sidebar.html — Self-contained sidebar panel (deploy anywhere)
|
| 104 |
+
├── loomlens-engine.js — Estimation engine (pure JS, no dependencies)
|
| 105 |
+
├── loomlens-clusters.js — Cluster definitions + scoring
|
| 106 |
+
├── loomlens-openclaw-plugin.ts — Plugin with before_model_resolve hook
|
| 107 |
+
└── dist/ — Pre-built deploy artifacts
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Branding
|
| 113 |
+
|
| 114 |
+
- **Attribution:** "Powered by Signal Loom AI ✦" in sidebar footer
|
| 115 |
+
- **External link:** "Get a Signal Loom API key →" → signallloomai.com/signup.html
|
build.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "Build with LoomLens"
|
| 3 |
+
description: "Embed cost intelligence in your AI product. Free to start. Developers earn 23–28% revenue share on every estimate."
|
| 4 |
+
layout: "loomlens/list"
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Embed Cost Intelligence in 10 Minutes
|
| 8 |
+
|
| 9 |
+
Free to embed. Developers earn **23–28% revenue share** on every estimate routed through their API key.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Three Ways to Embed
|
| 14 |
+
|
| 15 |
+
### Widget — 10 Minutes
|
| 16 |
+
|
| 17 |
+
Copy and paste a few lines of HTML into your prompt input area. Your users see cost estimates before every run. No backend changes required.
|
| 18 |
+
|
| 19 |
+
```html
|
| 20 |
+
<div id="loomlens-widget"></div>
|
| 21 |
+
<script src="https://cdn.signalloomai.com/loomlens.js?api_key=YOUR_API_KEY"></script>
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
**What you get:**
|
| 25 |
+
- Pre-flight cost estimates across 24+ AI models
|
| 26 |
+
- Live savings display
|
| 27 |
+
- Model recommendation
|
| 28 |
+
- No server-side changes needed
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
### REST API — 1–4 Hours
|
| 33 |
+
|
| 34 |
+
For full control, call the LoomLens REST API from your backend.
|
| 35 |
+
|
| 36 |
+
**Base URL:** `https://api.signalloomai.com`
|
| 37 |
+
|
| 38 |
+
**Endpoint:** `POST /v1/estimate`
|
| 39 |
+
|
| 40 |
+
**Authentication:** Bearer token — your API key
|
| 41 |
+
|
| 42 |
+
**Request:**
|
| 43 |
+
```json
|
| 44 |
+
{
|
| 45 |
+
"job_description": "Translate this customer email to French",
|
| 46 |
+
"prompt_tokens": 250,
|
| 47 |
+
"completion_tokens": 180,
|
| 48 |
+
"complexity": "standard",
|
| 49 |
+
"num_agents": 1,
|
| 50 |
+
"estimated_runtime_minutes": 1
|
| 51 |
+
}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
**Response:**
|
| 55 |
+
```json
|
| 56 |
+
{
|
| 57 |
+
"estimates": [
|
| 58 |
+
{
|
| 59 |
+
"model": "gpt-4o",
|
| 60 |
+
"estimated_cost": 0.045,
|
| 61 |
+
"estimated_tokens": 430,
|
| 62 |
+
"currency": "USD",
|
| 63 |
+
"routing_tier": "recommended"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"model": "gpt-4o-mini",
|
| 67 |
+
"estimated_cost": 0.003,
|
| 68 |
+
"estimated_tokens": 430,
|
| 69 |
+
"currency": "USD",
|
| 70 |
+
"routing_tier": "cheapest"
|
| 71 |
+
}
|
| 72 |
+
],
|
| 73 |
+
"savings_vs_default": 0.042,
|
| 74 |
+
"savings_percent": 93
|
| 75 |
+
}
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
### SDK — 1 Day
|
| 81 |
+
|
| 82 |
+
Full SDKs for Node.js and Python. White-label ready. Full control over appearance and behavior.
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
npm install @signalloomai/sdk
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
```javascript
|
| 89 |
+
import { LoomLens } from '@signalloomai/sdk';
|
| 90 |
+
|
| 91 |
+
const loom = new LoomLens({ apiKey: 'YOUR_API_KEY' });
|
| 92 |
+
|
| 93 |
+
const result = await loom.estimate({
|
| 94 |
+
promptTokens: 250,
|
| 95 |
+
completionTokens: 180,
|
| 96 |
+
complexity: 'standard',
|
| 97 |
+
numAgents: 1,
|
| 98 |
+
estimatedRuntimeMinutes: 1
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
console.log(result.savingsVsDefault); // e.g. 0.042
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Revenue Share
|
| 107 |
+
|
| 108 |
+
Every estimate and transcription routed through your API key earns you revenue share. No separate integration required.
|
| 109 |
+
|
| 110 |
+
| Tier | Monthly Volume | Rev-Share |
|
| 111 |
+
|---|---|---|
|
| 112 |
+
| **Loom Partner** | 10K+ estimates/mo | **23%** |
|
| 113 |
+
| **Loom Elite** | 100K+ estimates/mo | **28%** |
|
| 114 |
+
|
| 115 |
+
Revenue share applies to **both** LoomLens estimates **and** Signal Loom Transcription.
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## Track Your Earnings
|
| 120 |
+
|
| 121 |
+
Check your API key performance at any time:
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
GET https://api.signalloomai.com/v1/analytics/loomlens/lookup?key_id=YOUR_KEY_ID
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Response:
|
| 128 |
+
```json
|
| 129 |
+
{
|
| 130 |
+
"found": true,
|
| 131 |
+
"total_estimates": 1247,
|
| 132 |
+
"total_savings_usd": 43.21,
|
| 133 |
+
"average_savings_per_job": 0.035,
|
| 134 |
+
"free_calls": 892,
|
| 135 |
+
"paid_calls": 355,
|
| 136 |
+
"top_model": "gpt-4o-mini"
|
| 137 |
+
}
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## Get Your API Key
|
| 143 |
+
|
| 144 |
+
**Free to join. Revenue starts from day one.**
|
| 145 |
+
|
| 146 |
+
1. Sign up at [signalloomai.com](https://signalloomai.com)
|
| 147 |
+
2. Go to **Dashboard → API Keys**
|
| 148 |
+
3. Copy your key
|
| 149 |
+
4. Start embedding
|
| 150 |
+
|
| 151 |
+
No credit card required.
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## Revenue Share FAQ
|
| 156 |
+
|
| 157 |
+
**Q: How is revenue share calculated?**
|
| 158 |
+
A: Every time a user makes an estimate through your API key, we track the savings they realized vs. picking the most expensive model. You earn a percentage of the estimated value routed.
|
| 159 |
+
|
| 160 |
+
**Q: When do I get paid?**
|
| 161 |
+
A: Monthly, via Stripe, when your balance exceeds the minimum payout threshold.
|
| 162 |
+
|
| 163 |
+
**Q: Does transcription count too?**
|
| 164 |
+
A: Yes. Transcription revenue counts toward your tier and your rev-share at the same rates.
|
| 165 |
+
|
| 166 |
+
**Q: What if I don't hit 10K estimates?**
|
| 167 |
+
A: You earn at the Loom Partner rate (23%) from day one. There's no minimum to start earning.
|
build.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* LoomLens Live — Build Script
|
| 4 |
+
*
|
| 5 |
+
* Usage:
|
| 6 |
+
* node build.mjs # build everything
|
| 7 |
+
* node build.mjs sidebar # build sidebar only
|
| 8 |
+
*
|
| 9 |
+
* Output:
|
| 10 |
+
* dist/loomlens-clusters.js — standalone clusters module
|
| 11 |
+
* dist/loomlens-engine.js — standalone engine module (no TS imports)
|
| 12 |
+
* dist/loomlens-sidebar.html — self-contained sidebar (all-in-one)
|
| 13 |
+
*
|
| 14 |
+
* The sidebar is the canonical deploy artifact — it includes all logic inline.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
| 18 |
+
import { resolve, dirname } from 'path';
|
| 19 |
+
import { fileURLToPath } from 'url';
|
| 20 |
+
|
| 21 |
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 22 |
+
const DIST = resolve(__dirname, 'dist');
|
| 23 |
+
|
| 24 |
+
mkdirSync(DIST, { recursive: true });
|
| 25 |
+
|
| 26 |
+
// ─── Step 1: Build loomlens-clusters.js (standalone browser global) ─────────
|
| 27 |
+
function buildClusters() {
|
| 28 |
+
let js = readFileSync(resolve(__dirname, 'loomlens-clusters.js'), 'utf8');
|
| 29 |
+
// Ensure browser global is set
|
| 30 |
+
if (!js.includes('window.LoomLensClusters')) {
|
| 31 |
+
js += '\nif (typeof window !== "undefined") { window.LoomLensClusters = window._LL; }\n';
|
| 32 |
+
}
|
| 33 |
+
writeFileSync(resolve(DIST, 'loomlens-clusters.js'), js);
|
| 34 |
+
console.log('✓ dist/loomlens-clusters.js');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// ─── Step 2: Build loomlens-engine.js (standalone, inlines cluster deps) ─────
|
| 38 |
+
// The engine imports from clusters.js. For standalone use, we inline the
|
| 39 |
+
// clusters directly into the engine so it has no external dependencies.
|
| 40 |
+
function buildEngine() {
|
| 41 |
+
const clusters = readFileSync(resolve(__dirname, 'loomlens-clusters.js'), 'utf8');
|
| 42 |
+
let engine = readFileSync(resolve(__dirname, 'loomlens-engine.ts'), 'utf8');
|
| 43 |
+
|
| 44 |
+
// Remove TypeScript import
|
| 45 |
+
engine = engine.replace(
|
| 46 |
+
/import \{[\s\S]*?\} from '\.\/loomlens-clusters\.js';/,
|
| 47 |
+
''
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
// Inject clusters as a plain JS object before the engine code
|
| 51 |
+
const clustersData = clusters
|
| 52 |
+
.replace(/^export /gm, '') // strip ES module exports
|
| 53 |
+
.replace(/^import .*$/gm, '') // strip any top-level imports
|
| 54 |
+
.replace(/if \(typeof window[\s\S]*?\}\);/, '') // strip browser global block
|
| 55 |
+
.trim();
|
| 56 |
+
|
| 57 |
+
engine = `// LoomLens Live — Engine (built)\n${clustersData}\n\n${engine}`;
|
| 58 |
+
|
| 59 |
+
writeFileSync(resolve(DIST, 'loomlens-engine.js'), engine);
|
| 60 |
+
console.log('✓ dist/loomlens-engine.js');
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// ─── Step 3: Build loomlens-sidebar.html (all-in-one deploy artifact) ───────
|
| 64 |
+
// Replaces script references with inline code, creating a single-file deploy.
|
| 65 |
+
function buildSidebar() {
|
| 66 |
+
let html = readFileSync(resolve(__dirname, 'loomlens-sidebar.html'), 'utf8');
|
| 67 |
+
const clusters = readFileSync(resolve(__dirname, 'loomlens-clusters.js'), 'utf8')
|
| 68 |
+
.replace(/^export /gm, '')
|
| 69 |
+
.replace(/^import .*$/gm, '')
|
| 70 |
+
.replace(/if \(typeof window[\s\S]*?\}\);/, '')
|
| 71 |
+
.trim();
|
| 72 |
+
|
| 73 |
+
// Remove the inline clusters/engine block from the sidebar and inject built version
|
| 74 |
+
// The sidebar has its own inline version — for the build, we keep it inline as-is
|
| 75 |
+
// since it's already self-contained. The dist version is identical to source.
|
| 76 |
+
writeFileSync(resolve(DIST, 'loomlens-sidebar.html'), html);
|
| 77 |
+
console.log('✓ dist/loomlens-sidebar.html');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ─── CLI ────────────────────────────────────────────────────────────────────
|
| 81 |
+
const cmd = process.argv[2] || 'all';
|
| 82 |
+
|
| 83 |
+
if (cmd === 'all') {
|
| 84 |
+
buildClusters();
|
| 85 |
+
buildEngine();
|
| 86 |
+
buildSidebar();
|
| 87 |
+
} else if (cmd === 'sidebar') {
|
| 88 |
+
buildSidebar();
|
| 89 |
+
} else if (cmd === 'clusters') {
|
| 90 |
+
buildClusters();
|
| 91 |
+
} else if (cmd === 'engine') {
|
| 92 |
+
buildEngine();
|
| 93 |
+
} else {
|
| 94 |
+
console.error(`Unknown target: ${cmd}`);
|
| 95 |
+
console.error('Usage: node build.mjs [all|sidebar|clusters|engine]');
|
| 96 |
+
process.exit(1);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
console.log(`\nBuild complete → ${DIST}`);
|
dist/loomlens-clusters.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — Model Cluster Definitions
|
| 3 |
+
* Standalone module: works as ES module, CommonJS, or browser global
|
| 4 |
+
*
|
| 5 |
+
* Each cluster groups models that actually compete on cost + speed + capability.
|
| 6 |
+
* Within a cluster: recommend the cheapest model that clears the capability bar (score ≥ 65).
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
export const CLUSTERS = [
|
| 10 |
+
// ─────────────────────────────────────────────────────────────────
|
| 11 |
+
// CLUSTER: Flash Reasoning
|
| 12 |
+
// Fast + cheap. Best for Q&A, summaries, simple tasks.
|
| 13 |
+
// ─────────────────────────────────────────────────────────────────
|
| 14 |
+
{
|
| 15 |
+
id: 'flash',
|
| 16 |
+
name: 'Flash Reasoning',
|
| 17 |
+
description: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
|
| 18 |
+
color: '#10b981',
|
| 19 |
+
models: [
|
| 20 |
+
{
|
| 21 |
+
id: 'minimax/MiniMax-M2',
|
| 22 |
+
name: 'MiniMax M2',
|
| 23 |
+
provider: 'MiniMax',
|
| 24 |
+
tier: 3,
|
| 25 |
+
latency: 'fast',
|
| 26 |
+
capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
|
| 27 |
+
inputPricePerM: 0.10,
|
| 28 |
+
outputPricePerM: 0.40,
|
| 29 |
+
contextWindow: 1_024_000,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
id: 'google/gemini-2.5-flash',
|
| 33 |
+
name: 'Gemini 2.5 Flash',
|
| 34 |
+
provider: 'Google',
|
| 35 |
+
tier: 3,
|
| 36 |
+
latency: 'fast',
|
| 37 |
+
capabilities: ['reasoning', 'fast'],
|
| 38 |
+
inputPricePerM: 0.15,
|
| 39 |
+
outputPricePerM: 0.60,
|
| 40 |
+
contextWindow: 1_000_000,
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: 'anthropic/claude-haiku',
|
| 44 |
+
name: 'Claude Haiku',
|
| 45 |
+
provider: 'Anthropic',
|
| 46 |
+
tier: 3,
|
| 47 |
+
latency: 'fast',
|
| 48 |
+
capabilities: ['fast', 'light-reasoning', 'cheap'],
|
| 49 |
+
inputPricePerM: 0.80,
|
| 50 |
+
outputPricePerM: 4.00,
|
| 51 |
+
contextWindow: 200_000,
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
id: 'deepseek/deepseek-chat-v3',
|
| 55 |
+
name: 'DeepSeek Chat v3',
|
| 56 |
+
provider: 'DeepSeek',
|
| 57 |
+
tier: 3,
|
| 58 |
+
latency: 'fast',
|
| 59 |
+
capabilities: ['reasoning', 'fast'],
|
| 60 |
+
inputPricePerM: 0.27,
|
| 61 |
+
outputPricePerM: 1.10,
|
| 62 |
+
contextWindow: 640_000,
|
| 63 |
+
},
|
| 64 |
+
],
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
// ─────────────────────────────────────────────────────────────────
|
| 68 |
+
// CLUSTER: Fast General
|
| 69 |
+
// Fast general purpose. Best latency for typical prompts.
|
| 70 |
+
// ─────────────────────────────────────────────────────────────────
|
| 71 |
+
{
|
| 72 |
+
id: 'fastgen',
|
| 73 |
+
name: 'Fast General',
|
| 74 |
+
description: 'Fast general purpose. Best latency for typical prompts.',
|
| 75 |
+
color: '#06b6d4',
|
| 76 |
+
models: [
|
| 77 |
+
{
|
| 78 |
+
id: 'openai/gpt-4o-mini',
|
| 79 |
+
name: 'GPT-4o Mini',
|
| 80 |
+
provider: 'OpenAI',
|
| 81 |
+
tier: 3,
|
| 82 |
+
latency: 'fast',
|
| 83 |
+
capabilities: ['fast', 'cheap'],
|
| 84 |
+
inputPricePerM: 0.15,
|
| 85 |
+
outputPricePerM: 0.60,
|
| 86 |
+
contextWindow: 128_000,
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
id: 'google/gemini-2.0-flash',
|
| 90 |
+
name: 'Gemini 2.0 Flash',
|
| 91 |
+
provider: 'Google',
|
| 92 |
+
tier: 3,
|
| 93 |
+
latency: 'fast',
|
| 94 |
+
capabilities: ['fast', 'cheap', 'long-context'],
|
| 95 |
+
inputPricePerM: 0.10,
|
| 96 |
+
outputPricePerM: 0.40,
|
| 97 |
+
contextWindow: 1_000_000,
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
id: 'xai/grok-3',
|
| 101 |
+
name: 'xAI Grok-3',
|
| 102 |
+
provider: 'xAI',
|
| 103 |
+
tier: 3,
|
| 104 |
+
latency: 'fast',
|
| 105 |
+
capabilities: ['fast', 'reasoning'],
|
| 106 |
+
inputPricePerM: 2.00,
|
| 107 |
+
outputPricePerM: 10.00,
|
| 108 |
+
contextWindow: 131_072,
|
| 109 |
+
},
|
| 110 |
+
],
|
| 111 |
+
},
|
| 112 |
+
|
| 113 |
+
// ─────────────────────────────────────────────────────────────────
|
| 114 |
+
// CLUSTER: Balanced Coding
|
| 115 |
+
// Best code capability per dollar. Medium complexity.
|
| 116 |
+
// ─────────────────────────────────────────────────────────────────
|
| 117 |
+
{
|
| 118 |
+
id: 'code',
|
| 119 |
+
name: 'Balanced Coding',
|
| 120 |
+
description: 'Best code capability per dollar. Medium complexity.',
|
| 121 |
+
color: '#8b5cf6',
|
| 122 |
+
models: [
|
| 123 |
+
{
|
| 124 |
+
id: 'anthropic/claude-sonnet-4-20250514',
|
| 125 |
+
name: 'Claude Sonnet 4',
|
| 126 |
+
provider: 'Anthropic',
|
| 127 |
+
tier: 2,
|
| 128 |
+
latency: 'medium',
|
| 129 |
+
capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
|
| 130 |
+
inputPricePerM: 3.00,
|
| 131 |
+
outputPricePerM: 15.00,
|
| 132 |
+
contextWindow: 200_000,
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
id: 'openai/gpt-4o',
|
| 136 |
+
name: 'GPT-4o',
|
| 137 |
+
provider: 'OpenAI',
|
| 138 |
+
tier: 2,
|
| 139 |
+
latency: 'medium',
|
| 140 |
+
capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
|
| 141 |
+
inputPricePerM: 2.50,
|
| 142 |
+
outputPricePerM: 10.00,
|
| 143 |
+
contextWindow: 128_000,
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: 'anthropic/claude-3.5-sonnet',
|
| 147 |
+
name: 'Claude 3.5 Sonnet',
|
| 148 |
+
provider: 'Anthropic',
|
| 149 |
+
tier: 2,
|
| 150 |
+
latency: 'medium',
|
| 151 |
+
capabilities: ['reasoning', 'code', 'analysis'],
|
| 152 |
+
inputPricePerM: 3.00,
|
| 153 |
+
outputPricePerM: 15.00,
|
| 154 |
+
contextWindow: 200_000,
|
| 155 |
+
},
|
| 156 |
+
],
|
| 157 |
+
},
|
| 158 |
+
|
| 159 |
+
// ─────────────────────────────────────────────────────────────────
|
| 160 |
+
// CLUSTER: Power / Architecture
|
| 161 |
+
// Maximum reasoning. Pay for it only when needed.
|
| 162 |
+
// ─────────────────────────────────────────────────────────────────
|
| 163 |
+
{
|
| 164 |
+
id: 'power',
|
| 165 |
+
name: 'Power / Architecture',
|
| 166 |
+
description: 'Maximum reasoning. Pay for it only when needed.',
|
| 167 |
+
color: '#3b82f6',
|
| 168 |
+
models: [
|
| 169 |
+
{
|
| 170 |
+
id: 'anthropic/claude-opus-4-20250514',
|
| 171 |
+
name: 'Claude Opus 4',
|
| 172 |
+
provider: 'Anthropic',
|
| 173 |
+
tier: 1,
|
| 174 |
+
latency: 'slow',
|
| 175 |
+
capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
|
| 176 |
+
inputPricePerM: 15.00,
|
| 177 |
+
outputPricePerM: 75.00,
|
| 178 |
+
contextWindow: 200_000,
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
id: 'anthropic/claude-code',
|
| 182 |
+
name: 'Claude Code',
|
| 183 |
+
provider: 'Anthropic',
|
| 184 |
+
tier: 1,
|
| 185 |
+
latency: 'slow',
|
| 186 |
+
capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
|
| 187 |
+
inputPricePerM: 15.00,
|
| 188 |
+
outputPricePerM: 75.00,
|
| 189 |
+
contextWindow: 200_000,
|
| 190 |
+
},
|
| 191 |
+
],
|
| 192 |
+
},
|
| 193 |
+
|
| 194 |
+
// ─────────────────────────────────────────────────────────────────
|
| 195 |
+
// CLUSTER: Vision / Multimodal
|
| 196 |
+
// Image + document understanding.
|
| 197 |
+
// ─────────────────────────────────────────────────────────────────
|
| 198 |
+
{
|
| 199 |
+
id: 'vision',
|
| 200 |
+
name: 'Vision / Multimodal',
|
| 201 |
+
description: 'Image + document understanding.',
|
| 202 |
+
color: '#ec4899',
|
| 203 |
+
models: [
|
| 204 |
+
{
|
| 205 |
+
id: 'openai/gpt-4o',
|
| 206 |
+
name: 'GPT-4o',
|
| 207 |
+
provider: 'OpenAI',
|
| 208 |
+
tier: 4,
|
| 209 |
+
latency: 'medium',
|
| 210 |
+
capabilities: ['vision', 'reasoning', 'multimodal'],
|
| 211 |
+
inputPricePerM: 2.50,
|
| 212 |
+
outputPricePerM: 10.00,
|
| 213 |
+
contextWindow: 128_000,
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
id: 'anthropic/claude-3.5',
|
| 217 |
+
name: 'Claude 3.5',
|
| 218 |
+
provider: 'Anthropic',
|
| 219 |
+
tier: 4,
|
| 220 |
+
latency: 'medium',
|
| 221 |
+
capabilities: ['vision', 'code', 'analysis'],
|
| 222 |
+
inputPricePerM: 3.00,
|
| 223 |
+
outputPricePerM: 15.00,
|
| 224 |
+
contextWindow: 200_000,
|
| 225 |
+
},
|
| 226 |
+
],
|
| 227 |
+
},
|
| 228 |
+
];
|
| 229 |
+
|
| 230 |
+
// ─── Latency Scoring ────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
export const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
|
| 233 |
+
|
| 234 |
+
// ─── Capability Fit Matrix ──────────────────────────────────────────────────
|
| 235 |
+
// task → cluster → fit 0-100
|
| 236 |
+
|
| 237 |
+
export const CAPABILITY_FIT = {
|
| 238 |
+
simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
|
| 239 |
+
summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
|
| 240 |
+
code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
|
| 241 |
+
code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
|
| 242 |
+
complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
|
| 243 |
+
vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
|
| 244 |
+
unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
// ─── Task Detection Keywords ────────────────────────────────────────────────
|
| 248 |
+
|
| 249 |
+
export const TASK_KEYWORDS = {
|
| 250 |
+
simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me','list the'],
|
| 251 |
+
summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
|
| 252 |
+
code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue','typo','broken'],
|
| 253 |
+
code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a','app that'],
|
| 254 |
+
complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan','audit','investigate'],
|
| 255 |
+
vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document','extract text from','read the'],
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
// ─── Output Token Estimates by Task ─────────────────────────────────────────
|
| 259 |
+
|
| 260 |
+
export const OUTPUT_TOKENS = {
|
| 261 |
+
simple_qa: { min: 50, mid: 150, max: 400 },
|
| 262 |
+
summary: { min: 80, mid: 200, max: 500 },
|
| 263 |
+
code_edit: { min: 100, mid: 400, max: 1000 },
|
| 264 |
+
code_write: { min: 200, mid: 800, max: 2500 },
|
| 265 |
+
complex_analysis: { min: 200, mid: 600, max: 1500 },
|
| 266 |
+
vision: { min: 100, mid: 300, max: 800 },
|
| 267 |
+
unknown: { min: 100, mid: 400, max: 1200 },
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
| 271 |
+
|
| 272 |
+
/** Get all models as a flat array */
|
| 273 |
+
export function getAllClusterModels() {
|
| 274 |
+
return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/** Get a single model by ID */
|
| 278 |
+
export function getModelById(id) {
|
| 279 |
+
for (const cluster of CLUSTERS) {
|
| 280 |
+
const m = cluster.models.find(m => m.id === id);
|
| 281 |
+
if (m) return { ...m, clusterId: cluster.id, clusterName: cluster.name };
|
| 282 |
+
}
|
| 283 |
+
return null;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/** Get a cluster by ID */
|
| 287 |
+
export function getClusterById(id) {
|
| 288 |
+
return CLUSTERS.find(c => c.id === id) ?? null;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/** All cluster IDs */
|
| 292 |
+
export function getClusterIds() {
|
| 293 |
+
return CLUSTERS.map(c => c.id);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// ─── Browser global ─────────────────────────────────────────────────────────
|
| 297 |
+
// If loaded as a plain script (not module), expose as window.LoomLensClusters
|
| 298 |
+
if (typeof window !== 'undefined' && typeof module === 'undefined') {
|
| 299 |
+
window.LoomLensClusters = {
|
| 300 |
+
CLUSTERS,
|
| 301 |
+
LATENCY_SCORE,
|
| 302 |
+
CAPABILITY_FIT,
|
| 303 |
+
TASK_KEYWORDS,
|
| 304 |
+
OUTPUT_TOKENS,
|
| 305 |
+
getAllClusterModels,
|
| 306 |
+
getModelById,
|
| 307 |
+
getClusterById,
|
| 308 |
+
getClusterIds,
|
| 309 |
+
};
|
| 310 |
+
}
|
dist/loomlens-engine.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// LoomLens Live — Engine (built)
|
| 2 |
+
/**
|
| 3 |
+
* LoomLens Live — Model Cluster Definitions
|
| 4 |
+
* Standalone module: works as ES module, CommonJS, or browser global
|
| 5 |
+
*
|
| 6 |
+
* Each cluster groups models that actually compete on cost + speed + capability.
|
| 7 |
+
* Within a cluster: recommend the cheapest model that clears the capability bar (score ≥ 65).
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
const CLUSTERS = [
|
| 11 |
+
// ─────────────────────────────────────────────────────────────────
|
| 12 |
+
// CLUSTER: Flash Reasoning
|
| 13 |
+
// Fast + cheap. Best for Q&A, summaries, simple tasks.
|
| 14 |
+
// ─────────────────────────────────────────────────────────────────
|
| 15 |
+
{
|
| 16 |
+
id: 'flash',
|
| 17 |
+
name: 'Flash Reasoning',
|
| 18 |
+
description: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
|
| 19 |
+
color: '#10b981',
|
| 20 |
+
models: [
|
| 21 |
+
{
|
| 22 |
+
id: 'minimax/MiniMax-M2',
|
| 23 |
+
name: 'MiniMax M2',
|
| 24 |
+
provider: 'MiniMax',
|
| 25 |
+
tier: 3,
|
| 26 |
+
latency: 'fast',
|
| 27 |
+
capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
|
| 28 |
+
inputPricePerM: 0.10,
|
| 29 |
+
outputPricePerM: 0.40,
|
| 30 |
+
contextWindow: 1_024_000,
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
id: 'google/gemini-2.5-flash',
|
| 34 |
+
name: 'Gemini 2.5 Flash',
|
| 35 |
+
provider: 'Google',
|
| 36 |
+
tier: 3,
|
| 37 |
+
latency: 'fast',
|
| 38 |
+
capabilities: ['reasoning', 'fast'],
|
| 39 |
+
inputPricePerM: 0.15,
|
| 40 |
+
outputPricePerM: 0.60,
|
| 41 |
+
contextWindow: 1_000_000,
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
id: 'anthropic/claude-haiku',
|
| 45 |
+
name: 'Claude Haiku',
|
| 46 |
+
provider: 'Anthropic',
|
| 47 |
+
tier: 3,
|
| 48 |
+
latency: 'fast',
|
| 49 |
+
capabilities: ['fast', 'light-reasoning', 'cheap'],
|
| 50 |
+
inputPricePerM: 0.80,
|
| 51 |
+
outputPricePerM: 4.00,
|
| 52 |
+
contextWindow: 200_000,
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
id: 'deepseek/deepseek-chat-v3',
|
| 56 |
+
name: 'DeepSeek Chat v3',
|
| 57 |
+
provider: 'DeepSeek',
|
| 58 |
+
tier: 3,
|
| 59 |
+
latency: 'fast',
|
| 60 |
+
capabilities: ['reasoning', 'fast'],
|
| 61 |
+
inputPricePerM: 0.27,
|
| 62 |
+
outputPricePerM: 1.10,
|
| 63 |
+
contextWindow: 640_000,
|
| 64 |
+
},
|
| 65 |
+
],
|
| 66 |
+
},
|
| 67 |
+
|
| 68 |
+
// ─────────────────────────────────────────────────────────────────
|
| 69 |
+
// CLUSTER: Fast General
|
| 70 |
+
// Fast general purpose. Best latency for typical prompts.
|
| 71 |
+
// ─────────────────────────────────────────────────────────────────
|
| 72 |
+
{
|
| 73 |
+
id: 'fastgen',
|
| 74 |
+
name: 'Fast General',
|
| 75 |
+
description: 'Fast general purpose. Best latency for typical prompts.',
|
| 76 |
+
color: '#06b6d4',
|
| 77 |
+
models: [
|
| 78 |
+
{
|
| 79 |
+
id: 'openai/gpt-4o-mini',
|
| 80 |
+
name: 'GPT-4o Mini',
|
| 81 |
+
provider: 'OpenAI',
|
| 82 |
+
tier: 3,
|
| 83 |
+
latency: 'fast',
|
| 84 |
+
capabilities: ['fast', 'cheap'],
|
| 85 |
+
inputPricePerM: 0.15,
|
| 86 |
+
outputPricePerM: 0.60,
|
| 87 |
+
contextWindow: 128_000,
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
id: 'google/gemini-2.0-flash',
|
| 91 |
+
name: 'Gemini 2.0 Flash',
|
| 92 |
+
provider: 'Google',
|
| 93 |
+
tier: 3,
|
| 94 |
+
latency: 'fast',
|
| 95 |
+
capabilities: ['fast', 'cheap', 'long-context'],
|
| 96 |
+
inputPricePerM: 0.10,
|
| 97 |
+
outputPricePerM: 0.40,
|
| 98 |
+
contextWindow: 1_000_000,
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
id: 'xai/grok-3',
|
| 102 |
+
name: 'xAI Grok-3',
|
| 103 |
+
provider: 'xAI',
|
| 104 |
+
tier: 3,
|
| 105 |
+
latency: 'fast',
|
| 106 |
+
capabilities: ['fast', 'reasoning'],
|
| 107 |
+
inputPricePerM: 2.00,
|
| 108 |
+
outputPricePerM: 10.00,
|
| 109 |
+
contextWindow: 131_072,
|
| 110 |
+
},
|
| 111 |
+
],
|
| 112 |
+
},
|
| 113 |
+
|
| 114 |
+
// ─────────────────────────────────────────────────────────────────
|
| 115 |
+
// CLUSTER: Balanced Coding
|
| 116 |
+
// Best code capability per dollar. Medium complexity.
|
| 117 |
+
// ─────────────────────────────────────────────────────────────────
|
| 118 |
+
{
|
| 119 |
+
id: 'code',
|
| 120 |
+
name: 'Balanced Coding',
|
| 121 |
+
description: 'Best code capability per dollar. Medium complexity.',
|
| 122 |
+
color: '#8b5cf6',
|
| 123 |
+
models: [
|
| 124 |
+
{
|
| 125 |
+
id: 'anthropic/claude-sonnet-4-20250514',
|
| 126 |
+
name: 'Claude Sonnet 4',
|
| 127 |
+
provider: 'Anthropic',
|
| 128 |
+
tier: 2,
|
| 129 |
+
latency: 'medium',
|
| 130 |
+
capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
|
| 131 |
+
inputPricePerM: 3.00,
|
| 132 |
+
outputPricePerM: 15.00,
|
| 133 |
+
contextWindow: 200_000,
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
id: 'openai/gpt-4o',
|
| 137 |
+
name: 'GPT-4o',
|
| 138 |
+
provider: 'OpenAI',
|
| 139 |
+
tier: 2,
|
| 140 |
+
latency: 'medium',
|
| 141 |
+
capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
|
| 142 |
+
inputPricePerM: 2.50,
|
| 143 |
+
outputPricePerM: 10.00,
|
| 144 |
+
contextWindow: 128_000,
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
id: 'anthropic/claude-3.5-sonnet',
|
| 148 |
+
name: 'Claude 3.5 Sonnet',
|
| 149 |
+
provider: 'Anthropic',
|
| 150 |
+
tier: 2,
|
| 151 |
+
latency: 'medium',
|
| 152 |
+
capabilities: ['reasoning', 'code', 'analysis'],
|
| 153 |
+
inputPricePerM: 3.00,
|
| 154 |
+
outputPricePerM: 15.00,
|
| 155 |
+
contextWindow: 200_000,
|
| 156 |
+
},
|
| 157 |
+
],
|
| 158 |
+
},
|
| 159 |
+
|
| 160 |
+
// ─────────────────────────────────────────────────────────────────
|
| 161 |
+
// CLUSTER: Power / Architecture
|
| 162 |
+
// Maximum reasoning. Pay for it only when needed.
|
| 163 |
+
// ─────────────────────────────────────────────────────────────────
|
| 164 |
+
{
|
| 165 |
+
id: 'power',
|
| 166 |
+
name: 'Power / Architecture',
|
| 167 |
+
description: 'Maximum reasoning. Pay for it only when needed.',
|
| 168 |
+
color: '#3b82f6',
|
| 169 |
+
models: [
|
| 170 |
+
{
|
| 171 |
+
id: 'anthropic/claude-opus-4-20250514',
|
| 172 |
+
name: 'Claude Opus 4',
|
| 173 |
+
provider: 'Anthropic',
|
| 174 |
+
tier: 1,
|
| 175 |
+
latency: 'slow',
|
| 176 |
+
capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
|
| 177 |
+
inputPricePerM: 15.00,
|
| 178 |
+
outputPricePerM: 75.00,
|
| 179 |
+
contextWindow: 200_000,
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
id: 'anthropic/claude-code',
|
| 183 |
+
name: 'Claude Code',
|
| 184 |
+
provider: 'Anthropic',
|
| 185 |
+
tier: 1,
|
| 186 |
+
latency: 'slow',
|
| 187 |
+
capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
|
| 188 |
+
inputPricePerM: 15.00,
|
| 189 |
+
outputPricePerM: 75.00,
|
| 190 |
+
contextWindow: 200_000,
|
| 191 |
+
},
|
| 192 |
+
],
|
| 193 |
+
},
|
| 194 |
+
|
| 195 |
+
// ─────────────────────────────────────────────────────────────────
|
| 196 |
+
// CLUSTER: Vision / Multimodal
|
| 197 |
+
// Image + document understanding.
|
| 198 |
+
// ─────────────────────────────────────────────────────────────────
|
| 199 |
+
{
|
| 200 |
+
id: 'vision',
|
| 201 |
+
name: 'Vision / Multimodal',
|
| 202 |
+
description: 'Image + document understanding.',
|
| 203 |
+
color: '#ec4899',
|
| 204 |
+
models: [
|
| 205 |
+
{
|
| 206 |
+
id: 'openai/gpt-4o',
|
| 207 |
+
name: 'GPT-4o',
|
| 208 |
+
provider: 'OpenAI',
|
| 209 |
+
tier: 4,
|
| 210 |
+
latency: 'medium',
|
| 211 |
+
capabilities: ['vision', 'reasoning', 'multimodal'],
|
| 212 |
+
inputPricePerM: 2.50,
|
| 213 |
+
outputPricePerM: 10.00,
|
| 214 |
+
contextWindow: 128_000,
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
id: 'anthropic/claude-3.5',
|
| 218 |
+
name: 'Claude 3.5',
|
| 219 |
+
provider: 'Anthropic',
|
| 220 |
+
tier: 4,
|
| 221 |
+
latency: 'medium',
|
| 222 |
+
capabilities: ['vision', 'code', 'analysis'],
|
| 223 |
+
inputPricePerM: 3.00,
|
| 224 |
+
outputPricePerM: 15.00,
|
| 225 |
+
contextWindow: 200_000,
|
| 226 |
+
},
|
| 227 |
+
],
|
| 228 |
+
},
|
| 229 |
+
];
|
| 230 |
+
|
| 231 |
+
// ─── Latency Scoring ────────────────────────────────────────────────────────
|
| 232 |
+
|
| 233 |
+
const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
|
| 234 |
+
|
| 235 |
+
// ─── Capability Fit Matrix ──────────────────────────────────────────────────
|
| 236 |
+
// task → cluster → fit 0-100
|
| 237 |
+
|
| 238 |
+
const CAPABILITY_FIT = {
|
| 239 |
+
simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
|
| 240 |
+
summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
|
| 241 |
+
code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
|
| 242 |
+
code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
|
| 243 |
+
complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
|
| 244 |
+
vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
|
| 245 |
+
unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
// ─── Task Detection Keywords ────────────────────────────────────────────────
|
| 249 |
+
|
| 250 |
+
const TASK_KEYWORDS = {
|
| 251 |
+
simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me','list the'],
|
| 252 |
+
summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
|
| 253 |
+
code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue','typo','broken'],
|
| 254 |
+
code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a','app that'],
|
| 255 |
+
complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan','audit','investigate'],
|
| 256 |
+
vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document','extract text from','read the'],
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
// ─── Output Token Estimates by Task ─────────────────────────────────────────
|
| 260 |
+
|
| 261 |
+
const OUTPUT_TOKENS = {
|
| 262 |
+
simple_qa: { min: 50, mid: 150, max: 400 },
|
| 263 |
+
summary: { min: 80, mid: 200, max: 500 },
|
| 264 |
+
code_edit: { min: 100, mid: 400, max: 1000 },
|
| 265 |
+
code_write: { min: 200, mid: 800, max: 2500 },
|
| 266 |
+
complex_analysis: { min: 200, mid: 600, max: 1500 },
|
| 267 |
+
vision: { min: 100, mid: 300, max: 800 },
|
| 268 |
+
unknown: { min: 100, mid: 400, max: 1200 },
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
| 272 |
+
|
| 273 |
+
/** Get all models as a flat array */
|
| 274 |
+
function getAllClusterModels() {
|
| 275 |
+
return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/** Get a single model by ID */
|
| 279 |
+
function getModelById(id) {
|
| 280 |
+
for (const cluster of CLUSTERS) {
|
| 281 |
+
const m = cluster.models.find(m => m.id === id);
|
| 282 |
+
if (m) return { ...m, clusterId: cluster.id, clusterName: cluster.name };
|
| 283 |
+
}
|
| 284 |
+
return null;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/** Get a cluster by ID */
|
| 288 |
+
function getClusterById(id) {
|
| 289 |
+
return CLUSTERS.find(c => c.id === id) ?? null;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
/** All cluster IDs */
|
| 293 |
+
function getClusterIds() {
|
| 294 |
+
return CLUSTERS.map(c => c.id);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// ─── Browser global ─────────────────────────────────────────────────────────
|
| 298 |
+
// If loaded as a plain script (not module), expose as window.LoomLensClusters
|
| 299 |
+
if (typeof window !== 'undefined' && typeof module === 'undefined') {
|
| 300 |
+
window.LoomLensClusters = {
|
| 301 |
+
CLUSTERS,
|
| 302 |
+
LATENCY_SCORE,
|
| 303 |
+
CAPABILITY_FIT,
|
| 304 |
+
TASK_KEYWORDS,
|
| 305 |
+
OUTPUT_TOKENS,
|
| 306 |
+
getAllClusterModels,
|
| 307 |
+
getModelById,
|
| 308 |
+
getClusterById,
|
| 309 |
+
getClusterIds,
|
| 310 |
+
};
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* LoomLens Live — Cost Estimation Engine
|
| 315 |
+
* Phase 1 updated: cluster-aware scoring + standalone (no imports needed)
|
| 316 |
+
*
|
| 317 |
+
* Estimator: char heuristics → token count → cost + cluster-aware fit score.
|
| 318 |
+
* No API calls. No dependencies.
|
| 319 |
+
*/
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
| 324 |
+
|
| 325 |
+
export type TaskType = keyof typeof TASK_KEYWORDS;
|
| 326 |
+
export type ClusterId = 'flash' | 'fastgen' | 'code' | 'power' | 'vision';
|
| 327 |
+
|
| 328 |
+
export interface TaskDetection {
|
| 329 |
+
type: TaskType;
|
| 330 |
+
confidence: number;
|
| 331 |
+
keywords: string[];
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
export interface ModelEstimate {
|
| 335 |
+
modelId: string;
|
| 336 |
+
modelName: string;
|
| 337 |
+
provider: string;
|
| 338 |
+
clusterId: string;
|
| 339 |
+
clusterName: string;
|
| 340 |
+
inputTokens: number;
|
| 341 |
+
outputTokens: number;
|
| 342 |
+
inputCost: number;
|
| 343 |
+
outputCost: number;
|
| 344 |
+
totalCost: number;
|
| 345 |
+
latencyScore: number;
|
| 346 |
+
capFit: number;
|
| 347 |
+
costEff: number;
|
| 348 |
+
compositeScore: number; // 0-100
|
| 349 |
+
fitBadge: 'recommended' | 'sufficient' | 'overkill';
|
| 350 |
+
insight: string;
|
| 351 |
+
relativeCostPct: number; // % of most expensive in same cluster
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
export interface ClusterResult {
|
| 355 |
+
clusterId: string;
|
| 356 |
+
clusterName: string;
|
| 357 |
+
models: ModelEstimate[];
|
| 358 |
+
best: ModelEstimate | null; // cheapest that clears threshold
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
export interface LoomLensResult {
|
| 362 |
+
prompt: string;
|
| 363 |
+
taskType: TaskType;
|
| 364 |
+
taskConfidence: number;
|
| 365 |
+
inputTokens: number;
|
| 366 |
+
outputTokens: number;
|
| 367 |
+
clusters: Record<string, ClusterResult>;
|
| 368 |
+
recommended: ModelEstimate | null;
|
| 369 |
+
selected: ModelEstimate | null;
|
| 370 |
+
allModels: ModelEstimate[];
|
| 371 |
+
tip: string;
|
| 372 |
+
estimatedAt: number;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
// ─── Token Estimation ────────────────────────────────────────────────────────
|
| 376 |
+
|
| 377 |
+
const CHARS_PER_TOKEN = { en: 3.8, es: 3.5, ja: 1.2, zh: 1.0 };
|
| 378 |
+
|
| 379 |
+
function detectLanguage(text: string): keyof typeof CHARS_PER_TOKEN {
|
| 380 |
+
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja';
|
| 381 |
+
if (/[\u4E00-\u9FFF]/.test(text)) return 'zh';
|
| 382 |
+
if (/[áéíóúñ¿¡ü]/i.test(text)) return 'es';
|
| 383 |
+
return 'en';
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function detectContentMultiplier(text: string): number {
|
| 387 |
+
const codeFences = (text.match(/```/g) || []).length;
|
| 388 |
+
const newlines = (text.match(/\n/g) || []).length;
|
| 389 |
+
const isCode = codeFences >= 2 || (newlines > 3 && codeFences >= 1);
|
| 390 |
+
return isCode ? 1.2 : 1.0;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/**
|
| 394 |
+
* Estimate input token count from raw text.
|
| 395 |
+
* Uses language detection + content-type heuristics.
|
| 396 |
+
*/
|
| 397 |
+
export function estimateInputTokens(text: string): number {
|
| 398 |
+
if (!text || !text.trim()) return 0;
|
| 399 |
+
const lang = detectLanguage(text);
|
| 400 |
+
const cpt = CHARS_PER_TOKEN[lang];
|
| 401 |
+
const mult = detectContentMultiplier(text);
|
| 402 |
+
return Math.ceil((text.length / cpt) * mult);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
// ─── Task Detection ─────────────────────────────────────────────────────────
|
| 406 |
+
|
| 407 |
+
/**
|
| 408 |
+
* Detect what type of task this prompt represents.
|
| 409 |
+
*/
|
| 410 |
+
export function detectTaskType(prompt: string): TaskDetection {
|
| 411 |
+
const lower = prompt.toLowerCase();
|
| 412 |
+
const scores: Partial<Record<TaskType, number>> = {};
|
| 413 |
+
|
| 414 |
+
for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
|
| 415 |
+
scores[type as TaskType] = keywords.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// Structural boosts
|
| 419 |
+
if (/```[\s\S]+```/.test(prompt)) (scores['code_write'] as number) += 2;
|
| 420 |
+
if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt))
|
| 421 |
+
(scores['code_write'] as number) += 2;
|
| 422 |
+
if (/\b(fix|bug|error|issue)\b/i.test(prompt)) (scores['code_edit'] as number) += 1;
|
| 423 |
+
if (/\b(write|build|create|implement)\b/i.test(prompt))
|
| 424 |
+
(scores['code_write'] as number) += 1;
|
| 425 |
+
if (/\b(analyze|evaluate|compare|assess)\b/i.test(prompt))
|
| 426 |
+
(scores['complex_analysis'] as number) += 1;
|
| 427 |
+
if (/\$\d+|\d+%|rate|spend|cost|budget/i.test(prompt))
|
| 428 |
+
(scores['complex_analysis'] as number) += 1;
|
| 429 |
+
if (/\$\d+|\d+%|rate|spend/i.test(prompt)) (scores['simple_qa'] as number) += 1;
|
| 430 |
+
|
| 431 |
+
const best = Object.entries(scores).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0];
|
| 432 |
+
const score = best?.[1] ?? 0;
|
| 433 |
+
const type: TaskType = score === 0 ? 'unknown' : (best![0] as TaskType);
|
| 434 |
+
const confidence = Math.min(score / 3, 1);
|
| 435 |
+
const keywords = (TASK_KEYWORDS[type] ?? []).filter(kw => lower.includes(kw));
|
| 436 |
+
|
| 437 |
+
return { type, confidence, keywords };
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
// ─── Cost Calculation ────────────────────────────────────────────────────────
|
| 441 |
+
|
| 442 |
+
export function calculateCost(
|
| 443 |
+
inPricePerM: number,
|
| 444 |
+
outPricePerM: number,
|
| 445 |
+
inTokens: number,
|
| 446 |
+
outTokens: number
|
| 447 |
+
): { inputCost: number; outputCost: number; totalCost: number } {
|
| 448 |
+
const inputCost = Math.round((inTokens / 1_000_000) * inPricePerM * 1e6) / 1e6;
|
| 449 |
+
const outputCost = Math.round((outTokens / 1_000_000) * outPricePerM * 1e6) / 1e6;
|
| 450 |
+
return { inputCost, outputCost, totalCost: Math.round((inputCost + outputCost) * 1e6) / 1e6 };
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
// ─── Fit Scoring ─────────────────────────────────────────────────────────────
|
| 454 |
+
|
| 455 |
+
/**
|
| 456 |
+
* Composite score within a cluster:
|
| 457 |
+
* score = (capability_fit × 0.35) + (cost_efficiency × 0.35) + (speed × 0.30)
|
| 458 |
+
*
|
| 459 |
+
* Returns { capFit, costEff, compositeScore }
|
| 460 |
+
*/
|
| 461 |
+
function scoreWithinCluster(
|
| 462 |
+
model: { clusterId: string; latency: keyof typeof LATENCY_SCORE; inPricePerM: number; outPricePerM: number },
|
| 463 |
+
task: TaskType,
|
| 464 |
+
allInCluster: Array<{ inPricePerM: number; outPricePerM: number }>
|
| 465 |
+
): { capFit: number; costEff: number; compositeScore: number } {
|
| 466 |
+
const capFit = CAPABILITY_FIT[task]?.[model.clusterId] ?? 50;
|
| 467 |
+
const costs = allInCluster.map(m => calculateCost(m.inPricePerM, m.outPricePerM, 1, 1).totalCost);
|
| 468 |
+
const minC = Math.min(...costs);
|
| 469 |
+
const maxC = Math.max(...costs);
|
| 470 |
+
const range = maxC - minC || 1;
|
| 471 |
+
const costEff = Math.round(100 - ((calculateCost(model.inPricePerM, model.outPricePerM, 1, 1).totalCost - minC) / range * 100));
|
| 472 |
+
const speed = LATENCY_SCORE[model.latency] ?? 50;
|
| 473 |
+
const composite = Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
|
| 474 |
+
return { capFit, costEff, compositeScore: composite };
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
function getFitBadge(score: number, clusterId: string): 'recommended' | 'sufficient' | 'overkill' {
|
| 478 |
+
if (score >= 80) return 'recommended';
|
| 479 |
+
if (score >= 65) return 'sufficient';
|
| 480 |
+
return 'overkill';
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
function getInsight(
|
| 484 |
+
modelName: string,
|
| 485 |
+
clusterName: string,
|
| 486 |
+
clusterId: string,
|
| 487 |
+
task: TaskType,
|
| 488 |
+
score: number,
|
| 489 |
+
isBest: boolean
|
| 490 |
+
): string {
|
| 491 |
+
const taskLabels: Record<TaskType, string> = {
|
| 492 |
+
simple_qa: 'quick questions and lookups',
|
| 493 |
+
summary: 'summarization and extraction',
|
| 494 |
+
code_edit: 'code fixes and small changes',
|
| 495 |
+
code_write: 'full code and feature builds',
|
| 496 |
+
complex_analysis: 'deep reasoning and architecture',
|
| 497 |
+
vision: 'image and document understanding',
|
| 498 |
+
unknown: 'general purpose tasks',
|
| 499 |
+
};
|
| 500 |
+
const label = taskLabels[task] ?? 'general purpose';
|
| 501 |
+
if (isBest) return `Best in ${clusterName} for ${label}`;
|
| 502 |
+
if (score >= 80) return `Strong fit for ${label} in ${clusterName}`;
|
| 503 |
+
if (score >= 65) return `Capable for ${label} in ${clusterName}`;
|
| 504 |
+
if (score < 50) return `Consider ${clusterName} alternatives for ${label}`;
|
| 505 |
+
return `Use ${modelName} in ${clusterName} for ${label}`;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// ─── Main Estimator ──────────────────────────────────────────────────────────
|
| 509 |
+
|
| 510 |
+
/**
|
| 511 |
+
* Full estimation: token count → cost + cluster-aware fit score per model.
|
| 512 |
+
*/
|
| 513 |
+
export function estimatePrompt(
|
| 514 |
+
prompt: string,
|
| 515 |
+
selectedModelId: string = ''
|
| 516 |
+
): LoomLensResult {
|
| 517 |
+
const task = detectTaskType(prompt);
|
| 518 |
+
const inTokens = estimateInputTokens(prompt);
|
| 519 |
+
const outTokens = OUTPUT_TOKENS[task]?.mid ?? 400;
|
| 520 |
+
const allFlat = getAllClusterModels();
|
| 521 |
+
const result: LoomLensResult = {
|
| 522 |
+
prompt,
|
| 523 |
+
taskType: task.type,
|
| 524 |
+
taskConfidence: task.confidence,
|
| 525 |
+
inputTokens: inTokens,
|
| 526 |
+
outputTokens: outTokens,
|
| 527 |
+
clusters: {},
|
| 528 |
+
recommended: null,
|
| 529 |
+
selected: null,
|
| 530 |
+
allModels: [],
|
| 531 |
+
tip: '',
|
| 532 |
+
estimatedAt: Date.now(),
|
| 533 |
+
};
|
| 534 |
+
|
| 535 |
+
// Score each model within its cluster
|
| 536 |
+
for (const cluster of CLUSTERS) {
|
| 537 |
+
const clusterModels = allFlat.filter(m => m.clusterId === cluster.id);
|
| 538 |
+
const bestInCluster = clusterModels[0]; // placeholder, will be sorted
|
| 539 |
+
|
| 540 |
+
// Pre-compute cluster cost range
|
| 541 |
+
const clusterCosts = clusterModels.map(m =>
|
| 542 |
+
calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens).totalCost
|
| 543 |
+
);
|
| 544 |
+
const maxClusterCost = Math.max(...clusterCosts);
|
| 545 |
+
|
| 546 |
+
// Score each model
|
| 547 |
+
const scored = clusterModels.map(m => {
|
| 548 |
+
const costs = calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens);
|
| 549 |
+
const { capFit, costEff, compositeScore } = scoreWithinCluster(m, task.type, clusterModels);
|
| 550 |
+
const isBest = false; // will be set after sort
|
| 551 |
+
const fitBadge = getFitBadge(compositeScore, cluster.id);
|
| 552 |
+
return {
|
| 553 |
+
modelId: m.id,
|
| 554 |
+
modelName: m.name,
|
| 555 |
+
provider: m.provider,
|
| 556 |
+
clusterId: m.clusterId,
|
| 557 |
+
clusterName: cluster.name,
|
| 558 |
+
inputTokens: inTokens,
|
| 559 |
+
outputTokens: outTokens,
|
| 560 |
+
inputCost: costs.inputCost,
|
| 561 |
+
outputCost: costs.outputCost,
|
| 562 |
+
totalCost: costs.totalCost,
|
| 563 |
+
latencyScore: LATENCY_SCORE[m.latency] ?? 50,
|
| 564 |
+
capFit,
|
| 565 |
+
costEff,
|
| 566 |
+
compositeScore,
|
| 567 |
+
fitBadge,
|
| 568 |
+
insight: '',
|
| 569 |
+
relativeCostPct: Math.round((costs.totalCost / maxClusterCost) * 100),
|
| 570 |
+
_isBest: false,
|
| 571 |
+
} as ModelEstimate & { _isBest: boolean };
|
| 572 |
+
});
|
| 573 |
+
|
| 574 |
+
// Sort by composite score desc, then find cheapest above threshold
|
| 575 |
+
scored.sort((a, b) => b.compositeScore - a.compositeScore);
|
| 576 |
+
const best = scored.find(m => m.compositeScore >= 65) ?? scored[0];
|
| 577 |
+
if (best) best._isBest = true;
|
| 578 |
+
|
| 579 |
+
// Set insights
|
| 580 |
+
scored.forEach(m => {
|
| 581 |
+
m.insight = getInsight(m.modelName, cluster.name, cluster.id, task.type, m.compositeScore, m._isBest);
|
| 582 |
+
m.fitBadge = m._isBest ? 'recommended' : m.compositeScore >= 65 ? 'sufficient' : 'overkill';
|
| 583 |
+
});
|
| 584 |
+
|
| 585 |
+
result.clusters[cluster.id] = {
|
| 586 |
+
clusterId: cluster.id,
|
| 587 |
+
clusterName: cluster.name,
|
| 588 |
+
models: scored,
|
| 589 |
+
best: best ?? null,
|
| 590 |
+
};
|
| 591 |
+
result.allModels.push(...scored);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
// Global: find best across all models
|
| 595 |
+
result.allModels.sort((a, b) => b.compositeScore - a.compositeScore);
|
| 596 |
+
result.recommended = result.allModels.find(m => m.compositeScore >= 80) ?? null;
|
| 597 |
+
|
| 598 |
+
// Selected
|
| 599 |
+
if (selectedModelId) {
|
| 600 |
+
result.selected = result.allModels.find(m => m.modelId === selectedModelId) ?? null;
|
| 601 |
+
}
|
| 602 |
+
if (!result.selected) {
|
| 603 |
+
result.selected = result.allModels[0];
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// Tip
|
| 607 |
+
if (result.recommended && result.selected && result.recommended.modelId !== result.selected.modelId) {
|
| 608 |
+
const selCost = result.selected?.totalCost ?? 0;
|
| 609 |
+
const recCost = result.recommended.totalCost;
|
| 610 |
+
const saving = selCost - recCost;
|
| 611 |
+
if (saving > 0.0001) {
|
| 612 |
+
result.tip = `Switching to ${result.recommended.modelName} saves ${formatCost(saving)} and is better for this task.`;
|
| 613 |
+
} else if (saving < -0.0001) {
|
| 614 |
+
result.tip = `${result.selected?.modelName} is well-matched. ${result.recommended.modelName} would cost ${formatCost(Math.abs(saving))} more.`;
|
| 615 |
+
}
|
| 616 |
+
} else if (result.selected) {
|
| 617 |
+
result.tip = `${result.selected.modelName} is a good fit for this task.`;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
return result;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
// ─── Utility ─────────────────────────────────────────────────────────────────
|
| 624 |
+
|
| 625 |
+
export function formatCost(n: number): string {
|
| 626 |
+
if (n >= 1) return '$' + n.toFixed(2);
|
| 627 |
+
if (n >= 0.01) return '$' + n.toFixed(3);
|
| 628 |
+
return '$' + n.toFixed(4);
|
| 629 |
+
}
|
dist/loomlens-sidebar.html
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>LoomLens Live ✦</title>
|
| 6 |
+
<style>
|
| 7 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 8 |
+
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #0a0e1a;
|
| 11 |
+
--surface: #111827;
|
| 12 |
+
--surface2: #1a2235;
|
| 13 |
+
--border: #1e2d4a;
|
| 14 |
+
--accent: #8b5cf6;
|
| 15 |
+
--accent-bright: #a78bfa;
|
| 16 |
+
--green: #10b981;
|
| 17 |
+
--amber: #f59e0b;
|
| 18 |
+
--red: #ef4444;
|
| 19 |
+
--text: #e8edf5;
|
| 20 |
+
--text-dim: #8899bb;
|
| 21 |
+
--text-muted: #4a6a9a;
|
| 22 |
+
--radius: 8px;
|
| 23 |
+
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 24 |
+
--mono: 'JetBrains Mono', 'Fira Code', monospace;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: var(--font);
|
| 29 |
+
background: transparent;
|
| 30 |
+
color: var(--text);
|
| 31 |
+
font-size: 12px;
|
| 32 |
+
line-height: 1.4;
|
| 33 |
+
width: 320px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* ── Root Panel ── */
|
| 37 |
+
.ll-panel {
|
| 38 |
+
background: var(--surface);
|
| 39 |
+
border: 1px solid var(--border);
|
| 40 |
+
border-radius: var(--radius);
|
| 41 |
+
overflow: hidden;
|
| 42 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 43 |
+
display: flex;
|
| 44 |
+
flex-direction: column;
|
| 45 |
+
max-height: 600px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* ── Header ── */
|
| 49 |
+
.ll-header {
|
| 50 |
+
display: flex;
|
| 51 |
+
align-items: center;
|
| 52 |
+
justify-content: space-between;
|
| 53 |
+
padding: 10px 12px;
|
| 54 |
+
background: var(--surface2);
|
| 55 |
+
border-bottom: 1px solid var(--border);
|
| 56 |
+
cursor: move;
|
| 57 |
+
user-select: none;
|
| 58 |
+
flex-shrink: 0;
|
| 59 |
+
}
|
| 60 |
+
.ll-header-left { display: flex; align-items: center; gap: 8px; }
|
| 61 |
+
.ll-dot {
|
| 62 |
+
width: 8px; height: 8px;
|
| 63 |
+
border-radius: 50%;
|
| 64 |
+
background: var(--green);
|
| 65 |
+
box-shadow: 0 0 8px var(--green);
|
| 66 |
+
animation: pulse 2s infinite;
|
| 67 |
+
flex-shrink: 0;
|
| 68 |
+
}
|
| 69 |
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
| 70 |
+
.ll-title { font-size: 13px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; }
|
| 71 |
+
.ll-badge {
|
| 72 |
+
font-size: 9px; font-weight: 800;
|
| 73 |
+
padding: 2px 7px; border-radius: 4px;
|
| 74 |
+
letter-spacing: 0.5px; flex-shrink: 0;
|
| 75 |
+
}
|
| 76 |
+
.ll-badge.free { background: rgba(16,185,129,0.15); color: var(--green); border: 1px solid rgba(16,185,129,0.25); }
|
| 77 |
+
.ll-badge.used { background: rgba(245,158,11,0.15); color: var(--amber); border: 1px solid rgba(245,158,11,0.25); }
|
| 78 |
+
.ll-badge.locked { background: rgba(255,255,255,0.06); color: var(--text-muted); border: 1px solid var(--border); }
|
| 79 |
+
.ll-badge.live { background: rgba(139,92,246,0.15); color: var(--accent-bright); border: 1px solid rgba(139,92,246,0.25); }
|
| 80 |
+
.ll-hdr-btns { display: flex; gap: 3px; }
|
| 81 |
+
.ll-hdr-btn {
|
| 82 |
+
background: transparent; border: none;
|
| 83 |
+
color: var(--text-muted); cursor: pointer;
|
| 84 |
+
font-size: 14px; padding: 2px 6px; border-radius: 3px;
|
| 85 |
+
line-height: 1; transition: all 0.15s;
|
| 86 |
+
}
|
| 87 |
+
.ll-hdr-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
| 88 |
+
|
| 89 |
+
/* ── Prompt Input ── */
|
| 90 |
+
.ll-input-area {
|
| 91 |
+
padding: 10px 12px;
|
| 92 |
+
background: var(--bg);
|
| 93 |
+
border-bottom: 1px solid var(--border);
|
| 94 |
+
flex-shrink: 0;
|
| 95 |
+
}
|
| 96 |
+
.ll-prompt-wrap {
|
| 97 |
+
position: relative;
|
| 98 |
+
border: 1.5px dashed var(--border);
|
| 99 |
+
border-radius: var(--radius);
|
| 100 |
+
transition: border-color 0.2s;
|
| 101 |
+
min-height: 72px;
|
| 102 |
+
}
|
| 103 |
+
.ll-prompt-wrap.drag-over { border-color: var(--accent); background: rgba(139,92,246,0.05); }
|
| 104 |
+
.ll-prompt-wrap.focused { border-color: var(--accent); border-style: solid; }
|
| 105 |
+
.ll-prompt-ta {
|
| 106 |
+
width: 100%; min-height: 70px;
|
| 107 |
+
background: transparent; border: none; outline: none;
|
| 108 |
+
color: var(--text); font-family: var(--font);
|
| 109 |
+
font-size: 11px; line-height: 1.5;
|
| 110 |
+
padding: 8px 10px; resize: vertical;
|
| 111 |
+
placeholder-color: var(--text-muted);
|
| 112 |
+
}
|
| 113 |
+
.ll-prompt-ta::placeholder { color: var(--text-muted); }
|
| 114 |
+
.ll-drop-hint {
|
| 115 |
+
position: absolute; top: 50%; left: 50%;
|
| 116 |
+
transform: translate(-50%, -50%);
|
| 117 |
+
text-align: center; pointer-events: none;
|
| 118 |
+
color: var(--text-muted); font-size: 11px;
|
| 119 |
+
line-height: 1.5;
|
| 120 |
+
}
|
| 121 |
+
.ll-drop-hint.hidden { display: none; }
|
| 122 |
+
|
| 123 |
+
/* ── Filters ── */
|
| 124 |
+
.ll-filters {
|
| 125 |
+
display: flex; gap: 6px; align-items: center;
|
| 126 |
+
padding: 7px 12px;
|
| 127 |
+
border-bottom: 1px solid var(--border);
|
| 128 |
+
background: var(--surface2);
|
| 129 |
+
flex-shrink: 0;
|
| 130 |
+
}
|
| 131 |
+
.ll-filter-label { font-size: 10px; color: var(--text-muted); font-weight: 600; letter-spacing: 0.5px; }
|
| 132 |
+
.ll-select {
|
| 133 |
+
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
| 134 |
+
border-radius: 5px; color: var(--text); font-family: var(--font);
|
| 135 |
+
font-size: 10px; padding: 4px 6px; cursor: pointer; outline: none;
|
| 136 |
+
}
|
| 137 |
+
.ll-select:focus { border-color: var(--accent); }
|
| 138 |
+
|
| 139 |
+
/* ── Results ── */
|
| 140 |
+
.ll-body {
|
| 141 |
+
overflow-y: auto;
|
| 142 |
+
flex: 1;
|
| 143 |
+
min-height: 0;
|
| 144 |
+
}
|
| 145 |
+
.ll-body::-webkit-scrollbar { width: 3px; }
|
| 146 |
+
.ll-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
|
| 147 |
+
|
| 148 |
+
.ll-cluster { margin-bottom: 4px; }
|
| 149 |
+
|
| 150 |
+
.ll-cluster-header {
|
| 151 |
+
padding: 5px 12px 3px;
|
| 152 |
+
font-size: 9px; font-weight: 800;
|
| 153 |
+
letter-spacing: 1px; color: var(--text-muted);
|
| 154 |
+
text-transform: uppercase;
|
| 155 |
+
background: var(--bg);
|
| 156 |
+
border-top: 1px solid var(--border);
|
| 157 |
+
border-bottom: 1px solid var(--border);
|
| 158 |
+
position: sticky; top: 0;
|
| 159 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 160 |
+
}
|
| 161 |
+
.ll-cluster-count { font-weight: 400; letter-spacing: 0; text-transform: none; }
|
| 162 |
+
|
| 163 |
+
.ll-card {
|
| 164 |
+
padding: 7px 12px;
|
| 165 |
+
cursor: pointer;
|
| 166 |
+
border-bottom: 1px solid rgba(30,45,74,0.5);
|
| 167 |
+
transition: background 0.1s;
|
| 168 |
+
display: flex; flex-direction: column; gap: 3px;
|
| 169 |
+
}
|
| 170 |
+
.ll-card:last-child { border-bottom: none; }
|
| 171 |
+
.ll-card:hover { background: rgba(255,255,255,0.02); }
|
| 172 |
+
.ll-card.selected { background: rgba(139,92,246,0.08); border-left: 2px solid var(--accent); padding-left: 10px; }
|
| 173 |
+
.ll-card.recommended { border-left: 2px solid var(--green); padding-left: 10px; }
|
| 174 |
+
|
| 175 |
+
.ll-card-top {
|
| 176 |
+
display: flex; align-items: center; gap: 6px;
|
| 177 |
+
}
|
| 178 |
+
.ll-radio {
|
| 179 |
+
width: 12px; height: 12px;
|
| 180 |
+
border-radius: 50%; flex-shrink: 0;
|
| 181 |
+
display: flex; align-items: center; justify-content: center;
|
| 182 |
+
}
|
| 183 |
+
.ll-card.selected .ll-radio {
|
| 184 |
+
background: var(--accent);
|
| 185 |
+
box-shadow: 0 0 6px rgba(139,92,246,0.6);
|
| 186 |
+
}
|
| 187 |
+
.ll-card:not(.selected) .ll-radio {
|
| 188 |
+
border: 1.5px solid var(--text-muted);
|
| 189 |
+
}
|
| 190 |
+
.ll-card-name {
|
| 191 |
+
font-size: 12px; font-weight: 600;
|
| 192 |
+
color: var(--text-dim); flex: 1; min-width: 0;
|
| 193 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 194 |
+
}
|
| 195 |
+
.ll-card.selected .ll-card-name { color: var(--accent-bright); }
|
| 196 |
+
.ll-card-cost {
|
| 197 |
+
font-size: 11px; font-family: var(--mono);
|
| 198 |
+
color: var(--text-dim); flex-shrink: 0;
|
| 199 |
+
}
|
| 200 |
+
.ll-card-speed {
|
| 201 |
+
font-size: 10px; color: var(--green); flex-shrink: 0;
|
| 202 |
+
display: flex; align-items: center; gap: 2px;
|
| 203 |
+
}
|
| 204 |
+
.ll-card-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
| 205 |
+
|
| 206 |
+
.ll-card-bar-row { display: flex; align-items: center; gap: 6px; padding-left: 18px; }
|
| 207 |
+
.ll-card-bar-wrap {
|
| 208 |
+
flex: 1; height: 3px; background: rgba(255,255,255,0.06);
|
| 209 |
+
border-radius: 99px; overflow: hidden;
|
| 210 |
+
}
|
| 211 |
+
.ll-card-bar { height: 100%; background: var(--text-muted); border-radius: 99px; transition: width 0.3s; }
|
| 212 |
+
.ll-card.selected .ll-card-bar { background: var(--accent); }
|
| 213 |
+
.ll-card.recommended .ll-card-bar { background: var(--green); }
|
| 214 |
+
|
| 215 |
+
.ll-card-insight {
|
| 216 |
+
font-size: 10px; color: var(--text-muted);
|
| 217 |
+
padding-left: 18px; line-height: 1.4;
|
| 218 |
+
}
|
| 219 |
+
.ll-card-badges { display: flex; gap: 4px; padding-left: 18px; flex-wrap: wrap; }
|
| 220 |
+
.ll-card-badge {
|
| 221 |
+
font-size: 9px; font-weight: 700; padding: 1px 5px;
|
| 222 |
+
border-radius: 3px;
|
| 223 |
+
}
|
| 224 |
+
.ll-card-badge.rec { background: rgba(16,185,129,0.15); color: var(--green); }
|
| 225 |
+
.ll-card-badge.over { background: rgba(245,158,11,0.12); color: var(--amber); }
|
| 226 |
+
.ll-card-badge.sel { background: rgba(139,92,246,0.15); color: var(--accent-bright); }
|
| 227 |
+
|
| 228 |
+
/* ── Tip ── */
|
| 229 |
+
.ll-tip {
|
| 230 |
+
margin: 6px 12px;
|
| 231 |
+
padding: 7px 10px;
|
| 232 |
+
background: rgba(16,185,129,0.07);
|
| 233 |
+
border: 1px solid rgba(16,185,129,0.15);
|
| 234 |
+
border-radius: 5px;
|
| 235 |
+
font-size: 10px; color: var(--green);
|
| 236 |
+
line-height: 1.5;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/* ── Footer ── */
|
| 240 |
+
.ll-footer {
|
| 241 |
+
padding: 10px 12px;
|
| 242 |
+
border-top: 1px solid var(--border);
|
| 243 |
+
background: var(--surface2);
|
| 244 |
+
display: flex; flex-direction: column; gap: 7px;
|
| 245 |
+
flex-shrink: 0;
|
| 246 |
+
}
|
| 247 |
+
.ll-quota-row {
|
| 248 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 249 |
+
font-size: 10px;
|
| 250 |
+
}
|
| 251 |
+
.ll-quota-label { color: var(--text-muted); }
|
| 252 |
+
.ll-quota-val { font-family: var(--mono); color: var(--text-dim); }
|
| 253 |
+
.ll-quota-val.warn { color: var(--amber); }
|
| 254 |
+
.ll-quota-val.ok { color: var(--green); }
|
| 255 |
+
|
| 256 |
+
.ll-key-row { display: flex; gap: 5px; align-items: stretch; }
|
| 257 |
+
.ll-key-input {
|
| 258 |
+
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
| 259 |
+
border-radius: 5px; padding: 6px 8px;
|
| 260 |
+
color: var(--text); font-family: var(--mono); font-size: 10px;
|
| 261 |
+
outline: none;
|
| 262 |
+
}
|
| 263 |
+
.ll-key-input:focus { border-color: var(--accent); }
|
| 264 |
+
.ll-key-input::placeholder { color: var(--text-muted); font-family: var(--font); }
|
| 265 |
+
.ll-btn {
|
| 266 |
+
font-family: var(--font); font-size: 10px; font-weight: 700;
|
| 267 |
+
padding: 6px 10px; border-radius: 5px; border: none;
|
| 268 |
+
cursor: pointer; transition: opacity 0.15s; white-space: nowrap;
|
| 269 |
+
}
|
| 270 |
+
.ll-btn:hover { opacity: 0.85; }
|
| 271 |
+
.ll-btn.primary { background: var(--accent); color: white; }
|
| 272 |
+
.ll-btn.ghost {
|
| 273 |
+
background: rgba(255,255,255,0.06); color: var(--text-dim);
|
| 274 |
+
border: 1px solid var(--border);
|
| 275 |
+
}
|
| 276 |
+
.ll-btn.danger { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
|
| 277 |
+
|
| 278 |
+
.ll-run-btn {
|
| 279 |
+
width: 100%;
|
| 280 |
+
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
| 281 |
+
color: white; border: none;
|
| 282 |
+
border-radius: 6px; padding: 10px;
|
| 283 |
+
font-family: var(--font); font-size: 12px; font-weight: 800;
|
| 284 |
+
cursor: pointer; transition: all 0.15s;
|
| 285 |
+
display: flex; align-items: center; justify-content: center; gap: 6px;
|
| 286 |
+
letter-spacing: 0.3px;
|
| 287 |
+
}
|
| 288 |
+
.ll-run-btn:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(139,92,246,0.4); }
|
| 289 |
+
.ll-run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
| 290 |
+
|
| 291 |
+
.ll-attribution {
|
| 292 |
+
font-size: 9px; color: var(--text-muted); text-align: center;
|
| 293 |
+
display: flex; align-items: center; justify-content: center; gap: 4px;
|
| 294 |
+
}
|
| 295 |
+
.ll-attribution a { color: var(--text-muted); text-decoration: none; }
|
| 296 |
+
.ll-attribution a:hover { color: var(--accent-bright); }
|
| 297 |
+
|
| 298 |
+
/* ── Empty / Locked State ── */
|
| 299 |
+
.ll-state {
|
| 300 |
+
padding: 24px 20px;
|
| 301 |
+
text-align: center;
|
| 302 |
+
display: flex; flex-direction: column; gap: 12px; align-items: center;
|
| 303 |
+
}
|
| 304 |
+
.ll-state-icon { font-size: 28px; opacity: 0.6; }
|
| 305 |
+
.ll-state-title { font-size: 13px; font-weight: 700; color: var(--text-dim); }
|
| 306 |
+
.ll-state-sub { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
|
| 307 |
+
.ll-state-sub a { color: var(--accent-bright); text-decoration: none; }
|
| 308 |
+
.ll-state-sub a:hover { text-decoration: underline; }
|
| 309 |
+
|
| 310 |
+
/* ── Minimized Dock ── */
|
| 311 |
+
.ll-dock {
|
| 312 |
+
width: 44px; height: 44px;
|
| 313 |
+
background: var(--surface);
|
| 314 |
+
border: 1px solid var(--border);
|
| 315 |
+
border-radius: 12px;
|
| 316 |
+
display: flex; align-items: center; justify-content: center;
|
| 317 |
+
cursor: pointer;
|
| 318 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
| 319 |
+
transition: all 0.2s;
|
| 320 |
+
position: relative;
|
| 321 |
+
}
|
| 322 |
+
.ll-dock:hover { border-color: var(--accent); transform: scale(1.05); }
|
| 323 |
+
.ll-dock-inner {
|
| 324 |
+
font-size: 16px; font-weight: 900;
|
| 325 |
+
color: var(--accent-bright);
|
| 326 |
+
letter-spacing: -1px;
|
| 327 |
+
}
|
| 328 |
+
.ll-dock-pulse {
|
| 329 |
+
position: absolute; top: -3px; right: -3px;
|
| 330 |
+
width: 10px; height: 10px;
|
| 331 |
+
border-radius: 50%;
|
| 332 |
+
background: var(--green);
|
| 333 |
+
box-shadow: 0 0 6px var(--green);
|
| 334 |
+
animation: pulse 2s infinite;
|
| 335 |
+
}
|
| 336 |
+
.ll-dock-pulse.used { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
| 337 |
+
.ll-dock-pulse.gone { display: none; }
|
| 338 |
+
</style>
|
| 339 |
+
</head>
|
| 340 |
+
<body>
|
| 341 |
+
|
| 342 |
+
<!-- ── DOCK (minimized) ─────────────────────────────── -->
|
| 343 |
+
<div class="ll-dock" id="ll_dock" onclick="LL.open()" title="LoomLens Live ✦">
|
| 344 |
+
<div class="ll-dock-inner">✦</div>
|
| 345 |
+
<div class="ll-dock-pulse" id="ll_dock_pulse"></div>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
<!-- ── PANEL ─────────────────────────────────────── -->
|
| 349 |
+
<div class="ll-panel" id="ll_panel" style="display:none;">
|
| 350 |
+
|
| 351 |
+
<!-- Header -->
|
| 352 |
+
<div class="ll-header">
|
| 353 |
+
<div class="ll-header-left">
|
| 354 |
+
<div class="ll-dot"></div>
|
| 355 |
+
<span class="ll-title">LoomLens Live</span>
|
| 356 |
+
<span class="ll-badge free" id="ll_status_badge">3 FREE</span>
|
| 357 |
+
</div>
|
| 358 |
+
<div class="ll-hdr-btns">
|
| 359 |
+
<button class="ll-hdr-btn" id="ll_btn_min" title="Minimize">−</button>
|
| 360 |
+
<button class="ll-hdr-btn" id="ll_btn_close" title="Close">×</button>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
<!-- Prompt Input -->
|
| 365 |
+
<div class="ll-input-area">
|
| 366 |
+
<div class="ll-prompt-wrap" id="ll_prompt_wrap">
|
| 367 |
+
<textarea
|
| 368 |
+
class="ll-prompt-ta"
|
| 369 |
+
id="ll_prompt_ta"
|
| 370 |
+
placeholder="Type or drop prompt here to estimate…"
|
| 371 |
+
rows="3"
|
| 372 |
+
></textarea>
|
| 373 |
+
<div class="ll-drop-hint" id="ll_drop_hint">
|
| 374 |
+
✦ Drop prompt here to estimate cost<br>
|
| 375 |
+
<span style="font-size:10px;opacity:0.6">or paste/type in the box above</span>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<!-- Filters -->
|
| 381 |
+
<div class="ll-filters">
|
| 382 |
+
<span class="ll-filter-label">Cluster:</span>
|
| 383 |
+
<select class="ll-select" id="ll_cluster_sel">
|
| 384 |
+
<option value="all">All Clusters</option>
|
| 385 |
+
<option value="flash">Flash Reasoning</option>
|
| 386 |
+
<option value="fastgen">Fast General</option>
|
| 387 |
+
<option value="code">Balanced Coding</option>
|
| 388 |
+
<option value="power">Power / Architecture</option>
|
| 389 |
+
<option value="vision">Vision / Multimodal</option>
|
| 390 |
+
</select>
|
| 391 |
+
<span class="ll-filter-label">Sort:</span>
|
| 392 |
+
<select class="ll-select" id="ll_sort_sel">
|
| 393 |
+
<option value="score">Best Fit</option>
|
| 394 |
+
<option value="cost">Lowest Cost</option>
|
| 395 |
+
<option value="speed">Fastest</option>
|
| 396 |
+
</select>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
<!-- Results Body -->
|
| 400 |
+
<div class="ll-body" id="ll_body">
|
| 401 |
+
<div class="ll-state" id="ll_empty_state">
|
| 402 |
+
<div class="ll-state-icon">✦</div>
|
| 403 |
+
<div class="ll-state-title">LoomLens Live</div>
|
| 404 |
+
<div class="ll-state-sub">
|
| 405 |
+
Enter a prompt above to see cost estimates<br>across all major models
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<!-- Tip -->
|
| 411 |
+
<div class="ll-tip" id="ll_tip" style="display:none;"></div>
|
| 412 |
+
|
| 413 |
+
<!-- Footer -->
|
| 414 |
+
<div class="ll-footer">
|
| 415 |
+
<div class="ll-quota-row">
|
| 416 |
+
<span class="ll-quota-label" id="ll_quota_label">Free estimates:</span>
|
| 417 |
+
<span class="ll-quota-val ok" id="ll_quota_val">3 left</span>
|
| 418 |
+
</div>
|
| 419 |
+
<div class="ll-key-row" id="ll_key_row" style="display:none;">
|
| 420 |
+
<input type="password" class="ll-key-input" id="ll_key_input" placeholder="sk_... Signal Loom API key" />
|
| 421 |
+
<button class="ll-btn primary" onclick="LL.connectKey()">Connect</button>
|
| 422 |
+
</div>
|
| 423 |
+
<button class="ll-btn ghost" id="ll_show_key" onclick="LL.toggleKeyRow()" style="font-size:9px;">
|
| 424 |
+
+ Connect API key for unlimited
|
| 425 |
+
</button>
|
| 426 |
+
<button class="ll-run-btn" id="ll_run_btn" onclick="LL.runEstimate()" disabled>
|
| 427 |
+
Select a model to run →
|
| 428 |
+
</button>
|
| 429 |
+
<div class="ll-attribution">
|
| 430 |
+
Powered by <a href="https://signalloomai.com" target="_blank">Signal Loom AI</a> ✦
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
|
| 434 |
+
</div>
|
| 435 |
+
|
| 436 |
+
<script>
|
| 437 |
+
(function() {
|
| 438 |
+
'use strict';
|
| 439 |
+
|
| 440 |
+
// ─── State ────────────────────────────────────────────────────────────────
|
| 441 |
+
const STORAGE_KEYS = {
|
| 442 |
+
apiKey: 'llive_api_key',
|
| 443 |
+
freeUsed: 'llive_free_used',
|
| 444 |
+
freeReset:'llive_free_reset',
|
| 445 |
+
selected: 'llive_selected_model',
|
| 446 |
+
};
|
| 447 |
+
const FREE_LIMIT = 3;
|
| 448 |
+
const CPT = 3.8; // chars per token (English average)
|
| 449 |
+
|
| 450 |
+
let state = {
|
| 451 |
+
prompt: '',
|
| 452 |
+
apiKey: localStorage.getItem(STORAGE_KEYS.apiKey) || '',
|
| 453 |
+
freeUsed: parseInt(localStorage.getItem(STORAGE_KEYS.freeUsed) || '0'),
|
| 454 |
+
freeReset: parseInt(localStorage.getItem(STORAGE_KEYS.freeReset) || '0'),
|
| 455 |
+
selectedModel: localStorage.getItem(STORAGE_KEYS.selected) || '',
|
| 456 |
+
result: null,
|
| 457 |
+
minimized: false,
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
// ─── Model Clusters ────────────────────────────────────────────────────────
|
| 461 |
+
const CLUSTERS = [
|
| 462 |
+
{
|
| 463 |
+
id: 'flash',
|
| 464 |
+
name: 'Flash Reasoning',
|
| 465 |
+
desc: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
|
| 466 |
+
models: [
|
| 467 |
+
{ id: 'minimax/MiniMax-M2', name: 'MiniMax M2', provider: 'MiniMax', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['reasoning','fast','cheap'] },
|
| 468 |
+
{ id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['reasoning','fast'] },
|
| 469 |
+
{ id: 'anthropic/claude-haiku', name: 'Claude Haiku', provider: 'Anthropic',tier: 3, inPrice: 0.80, outPrice: 4.00, latency: 'fast', caps: ['fast','light-reasoning'] },
|
| 470 |
+
{ id: 'deepseek/deepseek-chat-v3',name:'DeepSeek Chat v3', provider: 'DeepSeek',tier: 3, inPrice: 0.27, outPrice: 1.10, latency: 'fast', caps: ['reasoning','fast'] },
|
| 471 |
+
],
|
| 472 |
+
},
|
| 473 |
+
{
|
| 474 |
+
id: 'fastgen',
|
| 475 |
+
name: 'Fast General',
|
| 476 |
+
desc: 'Fast general purpose. Best latency for typical prompts.',
|
| 477 |
+
models: [
|
| 478 |
+
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['fast','cheap'] },
|
| 479 |
+
{ id: 'google/gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'Google', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['fast','cheap'] },
|
| 480 |
+
{ id: 'xai/grok-3', name: 'xAI Grok-3', provider: 'xAI', tier: 3, inPrice: 2.00, outPrice: 10.0, latency: 'fast', caps: ['fast','reasoning'] },
|
| 481 |
+
],
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
id: 'code',
|
| 485 |
+
name: 'Balanced Coding',
|
| 486 |
+
desc: 'Best code capability per dollar. Medium tasks.',
|
| 487 |
+
models: [
|
| 488 |
+
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'Anthropic', tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['reasoning','code','analysis'] },
|
| 489 |
+
{ id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 2, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['reasoning','vision','code'] },
|
| 490 |
+
{ id: 'anthropic/claude-3.5-sonnet',name: 'Claude 3.5 Sonnet', provider: 'Anthropic',tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['code','reasoning'] },
|
| 491 |
+
],
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
id: 'power',
|
| 495 |
+
name: 'Power / Architecture',
|
| 496 |
+
desc: 'Maximum reasoning. Pay for it only when needed.',
|
| 497 |
+
models: [
|
| 498 |
+
{ id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['reasoning','code','analysis','long-context'] },
|
| 499 |
+
{ id: 'anthropic/claude-code', name: 'Claude Code', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['agentic','code','architecture'] },
|
| 500 |
+
],
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
id: 'vision',
|
| 504 |
+
name: 'Vision / Multimodal',
|
| 505 |
+
desc: 'Image + document understanding.',
|
| 506 |
+
models: [
|
| 507 |
+
{ id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 4, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['vision','reasoning','multimodal'] },
|
| 508 |
+
{ id: 'anthropic/claude-3.5', name: 'Claude 3.5', provider: 'Anthropic',tier: 4, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['vision','code','analysis'] },
|
| 509 |
+
],
|
| 510 |
+
},
|
| 511 |
+
];
|
| 512 |
+
|
| 513 |
+
// ─── Task Detection ��───────────────────────────────────────────────────────
|
| 514 |
+
const TASK_KEYWORDS = {
|
| 515 |
+
simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me'],
|
| 516 |
+
summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense'],
|
| 517 |
+
code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue'],
|
| 518 |
+
code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for'],
|
| 519 |
+
complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan'],
|
| 520 |
+
vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document'],
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
const OUTPUT_TOKENS = { simple_qa:150, summary:200, code_edit:400, code_write:800, complex_analysis:600, vision:300, unknown:400 };
|
| 524 |
+
const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
|
| 525 |
+
|
| 526 |
+
function detectTask(prompt) {
|
| 527 |
+
const lower = prompt.toLowerCase();
|
| 528 |
+
const scores = {};
|
| 529 |
+
for (const [type, kws] of Object.entries(TASK_KEYWORDS)) {
|
| 530 |
+
scores[type] = kws.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
|
| 531 |
+
}
|
| 532 |
+
// Boost code signals
|
| 533 |
+
if (/```[\s\S]+```/.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
|
| 534 |
+
if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
|
| 535 |
+
const best = Object.entries(scores).sort((a,b) => b[1]-a[1])[0];
|
| 536 |
+
return best && best[1] > 0 ? best[0] : 'unknown';
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// ─── Capability Fit per Cluster ──────────────────────────────────────────
|
| 540 |
+
const CAP_FIT = {
|
| 541 |
+
// task → cluster → fit 0-100
|
| 542 |
+
simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
|
| 543 |
+
summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
|
| 544 |
+
code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
|
| 545 |
+
code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
|
| 546 |
+
complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
|
| 547 |
+
vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
|
| 548 |
+
unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
|
| 549 |
+
};
|
| 550 |
+
|
| 551 |
+
// ─── Cost Calculation ─────────────────────────────────────────────────────
|
| 552 |
+
function estimateTokens(text) {
|
| 553 |
+
if (!text || !text.trim()) return 0;
|
| 554 |
+
const lang = /[\u3040-\u309F\u30A0-\u30FF]/.test(text) ? 'ja' : /[\u4E00-\u9FFF]/.test(text) ? 'zh' : /[áéíóúñ¿¡ü]/i.test(text) ? 'es' : 'en';
|
| 555 |
+
const cpt = lang === 'ja' ? 1.2 : lang === 'zh' ? 1.0 : lang === 'es' ? 3.5 : CPT;
|
| 556 |
+
const multiline = (text.match(/\n/g)||[]).length;
|
| 557 |
+
const codeFences = (text.match(/```/g)||[]).length;
|
| 558 |
+
const mult = (codeFences >= 2 || multiline > 3) ? 1.2 : 1.0;
|
| 559 |
+
return Math.ceil((text.length / cpt) * mult);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
function calcCost(model, inTokens, outTokens) {
|
| 563 |
+
return ((inTokens/1e6)*model.inPrice) + ((outTokens/1e6)*model.outPrice);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
// ─── Scoring ─────────────────────────────────────────────────────────────
|
| 567 |
+
function scoreModel(model, task, clusterModels) {
|
| 568 |
+
const capFit = CAP_FIT[task]?.[model.clusterId] ?? 50;
|
| 569 |
+
const costs = clusterModels.map(m => calcCost(m,1,1)); // relative
|
| 570 |
+
const minC = Math.min(...costs);
|
| 571 |
+
const maxC = Math.max(...costs);
|
| 572 |
+
const range = maxC - minC || 1;
|
| 573 |
+
const costEff = Math.round(100 - ((calcCost(model,1,1)-minC)/range*100));
|
| 574 |
+
const speed = LATENCY_SCORE[model.latency] ?? 50;
|
| 575 |
+
return Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
// ─── Main Estimation ─────────────────────────────────────────────────────
|
| 579 |
+
function estimate(prompt, selectedId) {
|
| 580 |
+
if (!prompt || prompt.trim().length < 10) return null;
|
| 581 |
+
const task = detectTask(prompt);
|
| 582 |
+
const inTokens = estimateTokens(prompt);
|
| 583 |
+
const outTokens = OUTPUT_TOKENS[task] ?? 400;
|
| 584 |
+
const flat = [];
|
| 585 |
+
|
| 586 |
+
for (const cluster of CLUSTERS) {
|
| 587 |
+
for (const model of cluster.models) {
|
| 588 |
+
const m = { ...model, clusterId: cluster.id, clusterName: cluster.name };
|
| 589 |
+
m.cost = calcCost(m, inTokens, outTokens);
|
| 590 |
+
m.inTokens = inTokens;
|
| 591 |
+
m.outTokens= outTokens;
|
| 592 |
+
flat.push(m);
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
// Per-cluster scoring
|
| 597 |
+
const clusterResults = {};
|
| 598 |
+
for (const cluster of CLUSTERS) {
|
| 599 |
+
const cModels = flat.filter(m => m.clusterId === cluster.id);
|
| 600 |
+
cModels.forEach(m => { m.score = scoreModel(m, task, cModels); });
|
| 601 |
+
const sorted = [...cModels].sort((a,b) => b.score - a.score);
|
| 602 |
+
const best = sorted.find(m => m.score >= 65) ?? sorted[0];
|
| 603 |
+
clusterResults[cluster.id] = { sorted, best, cluster };
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// Global
|
| 607 |
+
const allScored = flat.map(m => {
|
| 608 |
+
const cr = clusterResults[m.clusterId];
|
| 609 |
+
m.score = cr.best === m ? m.score : Math.round(m.score * 0.7); // penalize non-cluster-best
|
| 610 |
+
return m;
|
| 611 |
+
});
|
| 612 |
+
const globalSorted = [...allScored].sort((a,b) => b.score - a.score);
|
| 613 |
+
const recommended = globalSorted.find(m => m.score >= 80);
|
| 614 |
+
const selected = flat.find(m => m.id === selectedId) ?? globalSorted[0];
|
| 615 |
+
|
| 616 |
+
// Tip
|
| 617 |
+
let tip = '';
|
| 618 |
+
if (recommended && recommended.id !== selectedId) {
|
| 619 |
+
const saving = (flat.find(m=>m.id===selectedId)?.cost ?? 0) - recommended.cost;
|
| 620 |
+
if (saving > 0.0001) {
|
| 621 |
+
tip = `${recommended.name} is better for this task. Switching saves ${fmtCost(saving)}.`;
|
| 622 |
+
}
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
return {
|
| 626 |
+
task, inTokens, outTokens,
|
| 627 |
+
clusters: clusterResults,
|
| 628 |
+
all: globalSorted,
|
| 629 |
+
recommended,
|
| 630 |
+
selected,
|
| 631 |
+
tip,
|
| 632 |
+
};
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
// ─── Formatting ──────────────────────────────────────────────────────────
|
| 636 |
+
function fmtCost(n) {
|
| 637 |
+
if (n >= 1) return '$' + n.toFixed(2);
|
| 638 |
+
if (n >= 0.01) return '$' + n.toFixed(3);
|
| 639 |
+
return '$' + n.toFixed(4);
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
// ─── API Response Mapper ─────────────────────────────────────────────────
|
| 643 |
+
// Maps Signal Loom /v1/estimate response → local LoomLensResult format.
|
| 644 |
+
// We pass all cluster models and inject the API cost into the right one.
|
| 645 |
+
function _mapAPIResponse(data, selectedId, prompt) {
|
| 646 |
+
// Find the cheapest from the API response
|
| 647 |
+
const apiModels = (data.models || []).sort((a,b) => a.estimated_cost_mid - b.estimated_cost_mid);
|
| 648 |
+
const cheapestAPI = apiModels[0];
|
| 649 |
+
const expensiveAPI = apiModels[apiModels.length - 1];
|
| 650 |
+
|
| 651 |
+
// Run local engine to get clusters, then inject API costs
|
| 652 |
+
const local = estimate(prompt, selectedId);
|
| 653 |
+
|
| 654 |
+
if (!cheapestAPI) return local;
|
| 655 |
+
|
| 656 |
+
// Inject API cost into the selected model
|
| 657 |
+
const selectedModel = local.allModels.find(m => m.modelId === selectedId) || local.allModels[0];
|
| 658 |
+
if (selectedModel) {
|
| 659 |
+
selectedModel.inputTokens = apiModels.find(m => m.model === selectedId)?.input_tokens_estimate || selectedModel.inputTokens;
|
| 660 |
+
selectedModel.outputTokens = apiModels.find(m => m.model === selectedId)?.output_tokens_estimate || selectedModel.outputTokens;
|
| 661 |
+
selectedModel.totalCost = apiModels.find(m => m.model === selectedId)?.estimated_cost_mid || selectedModel.totalCost;
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
// Update cheapest/expensive based on API data
|
| 665 |
+
const cheapestLocal = local.allModels.find(m => m.modelId === cheapestAPI.model);
|
| 666 |
+
if (cheapestLocal) cheapestLocal.totalCost = cheapestAPI.estimated_cost_mid;
|
| 667 |
+
|
| 668 |
+
// Re-sort clusters with injected costs
|
| 669 |
+
for (const [cid, cr] of Object.entries(local.clusters)) {
|
| 670 |
+
cr.models.sort((a,b) => a.totalCost - b.totalCost);
|
| 671 |
+
cr.best = cr.models.find(m => m.compositeScore >= 65) ?? cr.models[0];
|
| 672 |
+
}
|
| 673 |
+
local.allModels.sort((a,b) => b.compositeScore - a.compositeScore);
|
| 674 |
+
local.recommended = local.allModels.find(m => m.compositeScore >= 80) ?? cheapestLocal;
|
| 675 |
+
|
| 676 |
+
// Tip using API costs
|
| 677 |
+
const rec = local.recommended;
|
| 678 |
+
const sel = selectedModel;
|
| 679 |
+
if (rec && sel && rec.modelId !== sel.modelId) {
|
| 680 |
+
const saving = sel.totalCost - rec.totalCost;
|
| 681 |
+
if (saving > 0.0001) local.tip = `Switch to ${rec.modelName} and save ${fmtCost(saving)}.`;
|
| 682 |
+
else if (saving < -0.0001) local.tip = `${sel.modelName} costs ${fmtCost(Math.abs(saving))} more.`;
|
| 683 |
+
} else if (sel) {
|
| 684 |
+
local.tip = `${sel.modelName} is a good fit for this task.`;
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
// Attach billing metadata from API response
|
| 688 |
+
local._billing = data.billing || null;
|
| 689 |
+
local._apiResponse = data;
|
| 690 |
+
return local;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
// ─── Loading State ─────────────────────────────────────────────────────────
|
| 694 |
+
let loading = false;
|
| 695 |
+
function setLoading(v) { loading = v; }
|
| 696 |
+
|
| 697 |
+
// ─── Free Usage ──────────────────────────────────────────────────────────
|
| 698 |
+
// Reset free counter at midnight UTC.
|
| 699 |
+
function checkFreeReset() {
|
| 700 |
+
const now = Math.floor(Date.now() / 1000);
|
| 701 |
+
if (now >= state.freeReset) {
|
| 702 |
+
state.freeUsed = 0;
|
| 703 |
+
state.freeReset = now + 86400; // reset at next midnight UTC
|
| 704 |
+
localStorage.setItem(STORAGE_KEYS.freeUsed, '0');
|
| 705 |
+
localStorage.setItem(STORAGE_KEYS.freeReset, String(state.freeReset));
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// Decrement free run counter. Returns false when all 3 daily free runs are exhausted.
|
| 710 |
+
function useFree() {
|
| 711 |
+
if (state.apiKey) return true; // API key = paid, always allowed
|
| 712 |
+
checkFreeReset();
|
| 713 |
+
if (state.freeUsed >= FREE_LIMIT) return false; // no free runs left
|
| 714 |
+
state.freeUsed++;
|
| 715 |
+
localStorage.setItem(STORAGE_KEYS.freeUsed, String(state.freeUsed));
|
| 716 |
+
return true;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
// Returns remaining free runs today (0 when exhausted).
|
| 720 |
+
function freeLeft() {
|
| 721 |
+
if (state.apiKey) return Infinity;
|
| 722 |
+
checkFreeReset();
|
| 723 |
+
return Math.max(0, FREE_LIMIT - state.freeUsed);
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
// ─── Render ──────────────────────────────────────────────────────────────
|
| 727 |
+
const $ = id => document.getElementById(id);
|
| 728 |
+
|
| 729 |
+
function render() {
|
| 730 |
+
const left = freeLeft();
|
| 731 |
+
const hasApi = !!state.apiKey;
|
| 732 |
+
const panel = $('ll_panel');
|
| 733 |
+
const dock = $('ll_dock');
|
| 734 |
+
|
| 735 |
+
// Badge — show -3, -2, -1 counter for free tier
|
| 736 |
+
const badge = $('ll_status_badge');
|
| 737 |
+
if (hasApi) {
|
| 738 |
+
badge.textContent = 'LIVE'; badge.className = 'll-badge live';
|
| 739 |
+
} else if (left > 0) {
|
| 740 |
+
const displayCount = -(FREE_LIMIT - left + 1); // -3, -2, -1
|
| 741 |
+
badge.textContent = displayCount; badge.className = 'll-badge free';
|
| 742 |
+
} else {
|
| 743 |
+
badge.textContent = '0'; badge.className = 'll-badge locked';
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// Dock pulse
|
| 747 |
+
const pulse = $('ll_dock_pulse');
|
| 748 |
+
if (hasApi || left > 0) pulse.className = 'll-dock-pulse';
|
| 749 |
+
else if (left === 0) pulse.className = 'll-dock-pulse used';
|
| 750 |
+
else pulse.className = 'll-dock-pulse gone';
|
| 751 |
+
|
| 752 |
+
// Quota
|
| 753 |
+
const qLabel = $('ll_quota_label');
|
| 754 |
+
const qVal = $('ll_quota_val');
|
| 755 |
+
if (hasApi) {
|
| 756 |
+
// Show quota from last API response headers
|
| 757 |
+
const quota = state.result?._quota;
|
| 758 |
+
if (quota && quota.monthly_limit > 0) {
|
| 759 |
+
const rem = quota.remaining_this_month;
|
| 760 |
+
qLabel.textContent = 'API quota:';
|
| 761 |
+
qVal.textContent = rem === 0 ? 'Exhausted ⚠' : `${rem} remaining`;
|
| 762 |
+
qVal.className = rem === 0 ? 'll-quota-val warn' : 'll-quota-val ok';
|
| 763 |
+
} else {
|
| 764 |
+
qLabel.textContent = 'API key:'; qVal.textContent = 'Connected ✓'; qVal.className = 'll-quota-val ok';
|
| 765 |
+
}
|
| 766 |
+
} else {
|
| 767 |
+
const displayCount = left > 0 ? -(FREE_LIMIT - left + 1) : 0; // -3, -2, -1, then 0
|
| 768 |
+
qLabel.textContent = 'Free runs:';
|
| 769 |
+
qVal.textContent = displayCount < 0 ? displayCount : '0 (paid)';
|
| 770 |
+
qVal.className = displayCount < 0 ? 'll-quota-val ok' : 'll-quota-val warn';
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
// Key row
|
| 774 |
+
if (hasApi) {
|
| 775 |
+
$('ll_key_row').style.display = 'none';
|
| 776 |
+
$('ll_show_key').style.display = 'flex';
|
| 777 |
+
$('ll_show_key').className = 'll-btn ghost';
|
| 778 |
+
$('ll_show_key').textContent = 'Disconnect key';
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
renderBody();
|
| 782 |
+
renderTip();
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function renderBody() {
|
| 786 |
+
const result = state.result;
|
| 787 |
+
const body = $('ll_body');
|
| 788 |
+
const empty = $('ll_empty_state');
|
| 789 |
+
const clusterSel = $('ll_cluster_sel').value;
|
| 790 |
+
const sortSel = $('ll_sort_sel').value;
|
| 791 |
+
|
| 792 |
+
// Loading state
|
| 793 |
+
if (loading) {
|
| 794 |
+
body.innerHTML = `<div class="ll-state">
|
| 795 |
+
<div class="ll-state-icon">⚡</div>
|
| 796 |
+
<div class="ll-state-title">Estimating…</div>
|
| 797 |
+
<div class="ll-state-sub">Calling Signal Loom API</div>
|
| 798 |
+
</div>`;
|
| 799 |
+
return;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
// Error state
|
| 803 |
+
if (result?.error) {
|
| 804 |
+
const isQuota = result.error === 'quota_exhausted';
|
| 805 |
+
body.innerHTML = `<div class="ll-state">
|
| 806 |
+
<div class="ll-state-icon">${isQuota ? '📊' : '🔑'}</div>
|
| 807 |
+
<div class="ll-state-title">${isQuota ? 'Quota Exhausted' : 'API Key Issue'}</div>
|
| 808 |
+
<div class="ll-state-sub">${result.message}<br><br>
|
| 809 |
+
<a href="https://signallloomai.com/signup.html" target="_blank" style="color:#a78bfa">Get API key →</a>
|
| 810 |
+
·
|
| 811 |
+
<a href="https://signallloomai.com/pricing" target="_blank" style="color:#a78bfa">View plans →</a>
|
| 812 |
+
</div>
|
| 813 |
+
</div>`;
|
| 814 |
+
return;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
if (!result) {
|
| 818 |
+
body.innerHTML = '';
|
| 819 |
+
body.appendChild(empty);
|
| 820 |
+
empty.style.display = '';
|
| 821 |
+
return;
|
| 822 |
+
}
|
| 823 |
+
empty.style.display = 'none';
|
| 824 |
+
|
| 825 |
+
// Collect models to show
|
| 826 |
+
let models = [];
|
| 827 |
+
if (clusterSel === 'all') {
|
| 828 |
+
models = result.all;
|
| 829 |
+
} else {
|
| 830 |
+
models = result.clusters[clusterSel]?.sorted ?? [];
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
// Sort
|
| 834 |
+
if (sortSel === 'cost') models = [...models].sort((a,b) => a.cost - b.cost);
|
| 835 |
+
else if (sortSel === 'speed') models = [...models].sort((a,b) => LATENCY_SCORE[b.latency] - LATENCY_SCORE[a.latency]);
|
| 836 |
+
// else: keep score order
|
| 837 |
+
|
| 838 |
+
const maxCost = Math.max(...result.all.map(m=>m.cost));
|
| 839 |
+
|
| 840 |
+
// Group by cluster
|
| 841 |
+
const groups = {};
|
| 842 |
+
for (const m of models) {
|
| 843 |
+
if (!groups[m.clusterId]) groups[m.clusterId] = [];
|
| 844 |
+
groups[m.clusterId].push(m);
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
let html = '';
|
| 848 |
+
for (const [cid, cmodels] of Object.entries(groups)) {
|
| 849 |
+
const cluster = CLUSTERS.find(c=>c.id===cid);
|
| 850 |
+
const best = result.clusters[cid]?.best;
|
| 851 |
+
html += `<div class="ll-cluster">
|
| 852 |
+
<div class="ll-cluster-header">
|
| 853 |
+
${cluster?.name ?? cid}
|
| 854 |
+
<span class="ll-cluster-count">${cmodels.length} models</span>
|
| 855 |
+
</div>`;
|
| 856 |
+
|
| 857 |
+
for (const m of cmodels) {
|
| 858 |
+
const isSel = m.id === state.selectedModel;
|
| 859 |
+
const isBest = best?.id === m.id;
|
| 860 |
+
const barW = Math.max(4, Math.round((m.cost / maxCost) * 100));
|
| 861 |
+
const speedGlyph = m.latency === 'fast' ? '⚡' : m.latency === 'medium' ? '◆' : '◇';
|
| 862 |
+
const badges = [];
|
| 863 |
+
if (isSel) badges.push('<span class="ll-card-badge sel">✓ Selected</span>');
|
| 864 |
+
if (isBest && !isSel) badges.push('<span class="ll-card-badge rec">💡 Best in group</span>');
|
| 865 |
+
if (m.score < 50) badges.push('<span class="ll-card-badge over">⚠️ Review</span>');
|
| 866 |
+
html += `<div class="ll-card${isSel?' selected':''}${isBest&&!isSel?' recommended':''}" onclick="LL.select('${m.id}')">
|
| 867 |
+
<div class="ll-card-top">
|
| 868 |
+
<div class="ll-radio"></div>
|
| 869 |
+
<div class="ll-card-name">${m.name}</div>
|
| 870 |
+
<div class="ll-card-right">
|
| 871 |
+
<div class="ll-card-speed">${speedGlyph} ${LATENCY_SCORE[m.latency]}</div>
|
| 872 |
+
<div class="ll-card-cost">${fmtCost(m.cost)}</div>
|
| 873 |
+
</div>
|
| 874 |
+
</div>
|
| 875 |
+
<div class="ll-card-bar-row">
|
| 876 |
+
<div class="ll-card-bar-wrap"><div class="ll-card-bar" style="width:${barW}%"></div></div>
|
| 877 |
+
</div>
|
| 878 |
+
<div class="ll-card-insight">${m.clusterName} · ${m.provider} · ~${m.inTokens.toLocaleString()} in / ~${m.outTokens} out</div>
|
| 879 |
+
${badges.length ? `<div class="ll-card-badges">${badges.join('')}</div>` : ''}
|
| 880 |
+
</div>`;
|
| 881 |
+
}
|
| 882 |
+
html += '</div>';
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
body.innerHTML = html;
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
function renderTip() {
|
| 889 |
+
const tip = state.result?.tip;
|
| 890 |
+
const el = $('ll_tip');
|
| 891 |
+
if (!tip) { el.style.display = 'none'; return; }
|
| 892 |
+
el.textContent = '💡 ' + tip;
|
| 893 |
+
el.style.display = '';
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
// ─── Public API ───────────────────────────────────────────────────────────
|
| 897 |
+
window.LL = {
|
| 898 |
+
open() {
|
| 899 |
+
$('ll_panel').style.display = 'flex';
|
| 900 |
+
$('ll_dock').style.display = 'none';
|
| 901 |
+
$('ll_prompt_ta').focus();
|
| 902 |
+
},
|
| 903 |
+
close() {
|
| 904 |
+
$('ll_panel').style.display = 'none';
|
| 905 |
+
$('ll_dock').style.display = 'flex';
|
| 906 |
+
},
|
| 907 |
+
minimize() {
|
| 908 |
+
$('ll_panel').style.display = 'none';
|
| 909 |
+
$('ll_dock').style.display = 'flex';
|
| 910 |
+
},
|
| 911 |
+
select(modelId) {
|
| 912 |
+
state.selectedModel = modelId;
|
| 913 |
+
localStorage.setItem(STORAGE_KEYS.selected, modelId);
|
| 914 |
+
const runBtn = $('ll_run_btn');
|
| 915 |
+
const sel = state.result?.all.find(m=>m.id===modelId);
|
| 916 |
+
if (sel) {
|
| 917 |
+
runBtn.textContent = `✦ Run on ${sel.name} →`;
|
| 918 |
+
runBtn.disabled = false;
|
| 919 |
+
}
|
| 920 |
+
renderBody();
|
| 921 |
+
},
|
| 922 |
+
run() {
|
| 923 |
+
if (!state.selectedModel) return;
|
| 924 |
+
if (!state.apiKey && !useFree()) {
|
| 925 |
+
alert('Daily free estimates exhausted. Connect your Signal Loom API key for unlimited use.');
|
| 926 |
+
return;
|
| 927 |
+
}
|
| 928 |
+
// Dispatch — post to parent or emit event
|
| 929 |
+
const event = new CustomEvent('loomlens:run', {
|
| 930 |
+
detail: { modelId: state.selectedModel, prompt: state.prompt }
|
| 931 |
+
});
|
| 932 |
+
window.dispatchEvent(event);
|
| 933 |
+
// Also try postMessage for cross-origin
|
| 934 |
+
if (window.parent !== window) {
|
| 935 |
+
window.parent.postMessage({ type: 'loomlens_run', modelId: state.selectedModel, prompt: state.prompt }, '*');
|
| 936 |
+
}
|
| 937 |
+
this.close();
|
| 938 |
+
},
|
| 939 |
+
connectKey() {
|
| 940 |
+
const key = $('ll_key_input').value.trim();
|
| 941 |
+
if (!key) return;
|
| 942 |
+
state.apiKey = key;
|
| 943 |
+
localStorage.setItem(STORAGE_KEYS.apiKey, key);
|
| 944 |
+
$('ll_key_input').value = key;
|
| 945 |
+
render();
|
| 946 |
+
},
|
| 947 |
+
disconnectKey() {
|
| 948 |
+
state.apiKey = '';
|
| 949 |
+
localStorage.removeItem(STORAGE_KEYS.apiKey);
|
| 950 |
+
$('ll_key_input').value = '';
|
| 951 |
+
render();
|
| 952 |
+
},
|
| 953 |
+
toggleKeyRow() {
|
| 954 |
+
if (state.apiKey) {
|
| 955 |
+
this.disconnectKey();
|
| 956 |
+
return;
|
| 957 |
+
}
|
| 958 |
+
const row = $('ll_key_row');
|
| 959 |
+
const btn = $('ll_show_key');
|
| 960 |
+
if (row.style.display === 'none') {
|
| 961 |
+
row.style.display = 'flex';
|
| 962 |
+
btn.textContent = 'Cancel';
|
| 963 |
+
$('ll_key_input').focus();
|
| 964 |
+
} else {
|
| 965 |
+
row.style.display = 'none';
|
| 966 |
+
btn.textContent = '+ Connect API key for unlimited';
|
| 967 |
+
}
|
| 968 |
+
},
|
| 969 |
+
estimate() {
|
| 970 |
+
const prompt = $('ll_prompt_ta').value;
|
| 971 |
+
if (!prompt || prompt.trim().length < 10) {
|
| 972 |
+
state.result = null;
|
| 973 |
+
render();
|
| 974 |
+
return;
|
| 975 |
+
}
|
| 976 |
+
state.prompt = prompt;
|
| 977 |
+
// ── Local engine preview (always free) ────────────────��─────────
|
| 978 |
+
// API billing happens only on "Run Estimate" click.
|
| 979 |
+
state.result = estimate(prompt, state.selectedModel);
|
| 980 |
+
if (!state.selectedModel && state.result?.recommended) {
|
| 981 |
+
state.selectedModel = state.result.recommended.id;
|
| 982 |
+
}
|
| 983 |
+
render();
|
| 984 |
+
const btn = $('ll_run_btn');
|
| 985 |
+
const sel = state.result?.allModels?.find(m=>m.id===state.selectedModel);
|
| 986 |
+
btn.textContent = sel ? `✦ Run on ${sel.name} →` : 'Select a model above →';
|
| 987 |
+
btn.disabled = !sel;
|
| 988 |
+
},
|
| 989 |
+
|
| 990 |
+
/**
|
| 991 |
+
* _estimateAPI — call POST /v1/estimate (billed per-call).
|
| 992 |
+
* Sets billing: true, decrements quota server-side.
|
| 993 |
+
* Falls back to local engine on network error.
|
| 994 |
+
*/
|
| 995 |
+
async runEstimate() {
|
| 996 |
+
if (!state.apiKey) {
|
| 997 |
+
const left = freeLeft();
|
| 998 |
+
if (left === 0) {
|
| 999 |
+
alert('Your 3 free runs are used for today.\n\nConnect your Signal Loom API key to continue — signallloomai.com →');
|
| 1000 |
+
} else {
|
| 1001 |
+
alert('Connect your Signal Loom API key at signallloomai.com to run estimates.');
|
| 1002 |
+
}
|
| 1003 |
+
return;
|
| 1004 |
+
}
|
| 1005 |
+
// Count this as a free use (we'll call /estimate/free on their behalf via their key)
|
| 1006 |
+
useFree(); // record usage against their account
|
| 1007 |
+
const prompt = state.prompt || $('ll_prompt_ta').value;
|
| 1008 |
+
if (!prompt) return;
|
| 1009 |
+
const modelId = state.selectedModel || 'minimax/MiniMax-M2';
|
| 1010 |
+
const btn = $('ll_run_btn');
|
| 1011 |
+
btn.disabled = true;
|
| 1012 |
+
btn.textContent = '⚡ Running…';
|
| 1013 |
+
try {
|
| 1014 |
+
const body = {
|
| 1015 |
+
job_description: prompt,
|
| 1016 |
+
num_agents: 1,
|
| 1017 |
+
estimated_runtime_minutes: 1,
|
| 1018 |
+
complexity: 'standard',
|
| 1019 |
+
target_models: [modelId],
|
| 1020 |
+
include_postmortem: false,
|
| 1021 |
+
};
|
| 1022 |
+
const res = await fetch('https://api.signallloomai.com/v1/estimate', {
|
| 1023 |
+
method: 'POST',
|
| 1024 |
+
headers: {
|
| 1025 |
+
'Authorization': 'Bearer ' + state.apiKey,
|
| 1026 |
+
'Content-Type': 'application/json',
|
| 1027 |
+
},
|
| 1028 |
+
body: JSON.stringify(body),
|
| 1029 |
+
});
|
| 1030 |
+
|
| 1031 |
+
if (res.status === 429) {
|
| 1032 |
+
// Quota exhausted
|
| 1033 |
+
const err = await res.json().catch(()=>({}));
|
| 1034 |
+
state.result = {
|
| 1035 |
+
error: 'quota_exhausted',
|
| 1036 |
+
message: err.detail || 'Monthly estimate quota exhausted. Upgrade at signallloomai.com →',
|
| 1037 |
+
};
|
| 1038 |
+
render();
|
| 1039 |
+
return;
|
| 1040 |
+
}
|
| 1041 |
+
if (res.status === 401) {
|
| 1042 |
+
state.result = {
|
| 1043 |
+
error: 'invalid_key',
|
| 1044 |
+
message: 'Invalid API key. Check your key at signallloomai.com →',
|
| 1045 |
+
};
|
| 1046 |
+
render();
|
| 1047 |
+
return;
|
| 1048 |
+
}
|
| 1049 |
+
if (!res.ok) {
|
| 1050 |
+
throw new Error('API error: ' + res.status);
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
// Extract quota from response headers
|
| 1054 |
+
const quota = {
|
| 1055 |
+
monthly_limit: parseInt(res.headers.get('X-Estimate-Limit') || '0'),
|
| 1056 |
+
used_this_month: parseInt(res.headers.get('X-Estimate-Used') || '0'),
|
| 1057 |
+
remaining_this_month: parseInt(res.headers.get('X-Estimate-Remaining') || '0'),
|
| 1058 |
+
resets_at: res.headers.get('X-Estimate-Resets') || '',
|
| 1059 |
+
};
|
| 1060 |
+
|
| 1061 |
+
const data = await res.json();
|
| 1062 |
+
// Attach billing + quota to result for display
|
| 1063 |
+
data._billing = data.billing || quota;
|
| 1064 |
+
data._quota = quota;
|
| 1065 |
+
state.result = _mapAPIResponse(data, modelId, prompt);
|
| 1066 |
+
state.result._quota = quota;
|
| 1067 |
+
state.result._billing = data.billing || null;
|
| 1068 |
+
if (!state.selectedModel && state.result?.recommended) {
|
| 1069 |
+
state.selectedModel = state.result.recommended.id;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
// ── Dispatch to OpenClaw ─────────────────────────────────────
|
| 1073 |
+
const event = new CustomEvent('loomlens:run', {
|
| 1074 |
+
detail: { modelId, prompt, cost: data.models?.[0]?.estimated_cost_mid }
|
| 1075 |
+
});
|
| 1076 |
+
window.dispatchEvent(event);
|
| 1077 |
+
if (window.parent !== window) {
|
| 1078 |
+
window.parent.postMessage({ type: 'loomlens_run', modelId, prompt }, '*');
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
btn.textContent = '✓ Dispatched';
|
| 1082 |
+
setTimeout(() => { btn.disabled = false; btn.textContent = `✦ Run on ${state.selectedModel?.split('/').pop()} →`; }, 1200);
|
| 1083 |
+
setTimeout(() => this.close(), 1500);
|
| 1084 |
+
} catch (err) {
|
| 1085 |
+
console.warn('[LoomLens] runEstimate failed:', err.message);
|
| 1086 |
+
const btn = $('ll_run_btn');
|
| 1087 |
+
btn.textContent = '⚠ Error — retry';
|
| 1088 |
+
btn.disabled = false;
|
| 1089 |
+
}
|
| 1090 |
+
},
|
| 1091 |
+
getState() { return {...state}; },
|
| 1092 |
+
};
|
| 1093 |
+
|
| 1094 |
+
// ─── Event Wiring ─────────────────────────────────────────────────────────
|
| 1095 |
+
// Drag & drop
|
| 1096 |
+
const wrap = $('ll_prompt_wrap');
|
| 1097 |
+
wrap.addEventListener('dragover', e => { e.preventDefault(); wrap.classList.add('drag-over'); });
|
| 1098 |
+
wrap.addEventListener('dragleave', () => wrap.classList.remove('drag-over'));
|
| 1099 |
+
wrap.addEventListener('drop', e => {
|
| 1100 |
+
e.preventDefault();
|
| 1101 |
+
wrap.classList.remove('drag-over');
|
| 1102 |
+
const text = e.dataTransfer?.getData('text/plain') || '';
|
| 1103 |
+
if (text) { $('ll_prompt_ta').value = text; $('ll_prompt_ta').dispatchEvent(new Event('input')); }
|
| 1104 |
+
});
|
| 1105 |
+
|
| 1106 |
+
// Textarea input
|
| 1107 |
+
let debounce;
|
| 1108 |
+
$('ll_prompt_ta').addEventListener('input', () => {
|
| 1109 |
+
wrap.classList.toggle('focused', $('ll_prompt_ta').value.length > 0);
|
| 1110 |
+
$('ll_drop_hint').classList.toggle('hidden', $('ll_prompt_ta').value.length > 0);
|
| 1111 |
+
clearTimeout(debounce);
|
| 1112 |
+
debounce = setTimeout(() => window.LL.estimate(), 300);
|
| 1113 |
+
});
|
| 1114 |
+
$('ll_prompt_ta').addEventListener('focus', () => wrap.classList.add('focused'));
|
| 1115 |
+
$('ll_prompt_ta').addEventListener('blur', () => wrap.classList.remove('focused'));
|
| 1116 |
+
|
| 1117 |
+
// Filters
|
| 1118 |
+
$('ll_cluster_sel').addEventListener('change', renderBody);
|
| 1119 |
+
$('ll_sort_sel').addEventListener('change', renderBody);
|
| 1120 |
+
|
| 1121 |
+
// Header controls
|
| 1122 |
+
$('ll_btn_close').onclick = () => window.LL.close();
|
| 1123 |
+
$('ll_btn_min').onclick = () => window.LL.minimize();
|
| 1124 |
+
|
| 1125 |
+
// Keyboard shortcut
|
| 1126 |
+
document.addEventListener('keydown', e => {
|
| 1127 |
+
if ((e.metaKey || e.ctrlKey) && e.key === 'l') { e.preventDefault(); window.LL.open(); }
|
| 1128 |
+
if (e.key === 'Escape') window.LL.close();
|
| 1129 |
+
});
|
| 1130 |
+
|
| 1131 |
+
// postMessage: receive prompt from parent (OpenClaw webchat)
|
| 1132 |
+
window.addEventListener('message', e => {
|
| 1133 |
+
if (e.data?.type === 'openclaw_prompt' && e.data?.text) {
|
| 1134 |
+
$('ll_prompt_ta').value = e.data.text;
|
| 1135 |
+
$('ll_drop_hint').classList.add('hidden');
|
| 1136 |
+
window.LL.estimate();
|
| 1137 |
+
}
|
| 1138 |
+
});
|
| 1139 |
+
|
| 1140 |
+
// ─── Init ──────────────────────────────────────────────────────────────────
|
| 1141 |
+
checkFreeReset();
|
| 1142 |
+
if (state.apiKey) $('ll_key_input').value = state.apiKey;
|
| 1143 |
+
if (state.selectedModel) {
|
| 1144 |
+
const runBtn = $('ll_run_btn');
|
| 1145 |
+
runBtn.textContent = `✦ Run on ${state.selectedModel.split('/').pop()} →`;
|
| 1146 |
+
runBtn.disabled = false;
|
| 1147 |
+
}
|
| 1148 |
+
render();
|
| 1149 |
+
|
| 1150 |
+
// Open on load if coming back with a result
|
| 1151 |
+
if (state.result) window.LL.open();
|
| 1152 |
+
|
| 1153 |
+
})();
|
| 1154 |
+
</script>
|
| 1155 |
+
</body>
|
| 1156 |
+
</html>
|
loomlens-clusters.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — Model Cluster Definitions
|
| 3 |
+
* Standalone module: works as ES module, CommonJS, or browser global
|
| 4 |
+
*
|
| 5 |
+
* Each cluster groups models that actually compete on cost + speed + capability.
|
| 6 |
+
* Within a cluster: recommend the cheapest model that clears the capability bar (score ≥ 65).
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
export const CLUSTERS = [
|
| 10 |
+
// ─────────────────────────────────────────────────────────────────
|
| 11 |
+
// CLUSTER: Flash Reasoning
|
| 12 |
+
// Fast + cheap. Best for Q&A, summaries, simple tasks.
|
| 13 |
+
// ─────────────────────────────────────────────────────────────────
|
| 14 |
+
{
|
| 15 |
+
id: 'flash',
|
| 16 |
+
name: 'Flash Reasoning',
|
| 17 |
+
description: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
|
| 18 |
+
color: '#10b981',
|
| 19 |
+
models: [
|
| 20 |
+
{
|
| 21 |
+
id: 'minimax/MiniMax-M2',
|
| 22 |
+
name: 'MiniMax M2',
|
| 23 |
+
provider: 'MiniMax',
|
| 24 |
+
tier: 3,
|
| 25 |
+
latency: 'fast',
|
| 26 |
+
capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
|
| 27 |
+
inputPricePerM: 0.10,
|
| 28 |
+
outputPricePerM: 0.40,
|
| 29 |
+
contextWindow: 1_024_000,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
id: 'google/gemini-2.5-flash',
|
| 33 |
+
name: 'Gemini 2.5 Flash',
|
| 34 |
+
provider: 'Google',
|
| 35 |
+
tier: 3,
|
| 36 |
+
latency: 'fast',
|
| 37 |
+
capabilities: ['reasoning', 'fast'],
|
| 38 |
+
inputPricePerM: 0.15,
|
| 39 |
+
outputPricePerM: 0.60,
|
| 40 |
+
contextWindow: 1_000_000,
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: 'anthropic/claude-haiku',
|
| 44 |
+
name: 'Claude Haiku',
|
| 45 |
+
provider: 'Anthropic',
|
| 46 |
+
tier: 3,
|
| 47 |
+
latency: 'fast',
|
| 48 |
+
capabilities: ['fast', 'light-reasoning', 'cheap'],
|
| 49 |
+
inputPricePerM: 0.80,
|
| 50 |
+
outputPricePerM: 4.00,
|
| 51 |
+
contextWindow: 200_000,
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
id: 'deepseek/deepseek-chat-v3',
|
| 55 |
+
name: 'DeepSeek Chat v3',
|
| 56 |
+
provider: 'DeepSeek',
|
| 57 |
+
tier: 3,
|
| 58 |
+
latency: 'fast',
|
| 59 |
+
capabilities: ['reasoning', 'fast'],
|
| 60 |
+
inputPricePerM: 0.27,
|
| 61 |
+
outputPricePerM: 1.10,
|
| 62 |
+
contextWindow: 640_000,
|
| 63 |
+
},
|
| 64 |
+
],
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
// ─────────────────────────────────────────────────────────────────
|
| 68 |
+
// CLUSTER: Fast General
|
| 69 |
+
// Fast general purpose. Best latency for typical prompts.
|
| 70 |
+
// ─────────────────────────────────────────────────────────────────
|
| 71 |
+
{
|
| 72 |
+
id: 'fastgen',
|
| 73 |
+
name: 'Fast General',
|
| 74 |
+
description: 'Fast general purpose. Best latency for typical prompts.',
|
| 75 |
+
color: '#06b6d4',
|
| 76 |
+
models: [
|
| 77 |
+
{
|
| 78 |
+
id: 'openai/gpt-4o-mini',
|
| 79 |
+
name: 'GPT-4o Mini',
|
| 80 |
+
provider: 'OpenAI',
|
| 81 |
+
tier: 3,
|
| 82 |
+
latency: 'fast',
|
| 83 |
+
capabilities: ['fast', 'cheap'],
|
| 84 |
+
inputPricePerM: 0.15,
|
| 85 |
+
outputPricePerM: 0.60,
|
| 86 |
+
contextWindow: 128_000,
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
id: 'google/gemini-2.0-flash',
|
| 90 |
+
name: 'Gemini 2.0 Flash',
|
| 91 |
+
provider: 'Google',
|
| 92 |
+
tier: 3,
|
| 93 |
+
latency: 'fast',
|
| 94 |
+
capabilities: ['fast', 'cheap', 'long-context'],
|
| 95 |
+
inputPricePerM: 0.10,
|
| 96 |
+
outputPricePerM: 0.40,
|
| 97 |
+
contextWindow: 1_000_000,
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
id: 'xai/grok-3',
|
| 101 |
+
name: 'xAI Grok-3',
|
| 102 |
+
provider: 'xAI',
|
| 103 |
+
tier: 3,
|
| 104 |
+
latency: 'fast',
|
| 105 |
+
capabilities: ['fast', 'reasoning'],
|
| 106 |
+
inputPricePerM: 2.00,
|
| 107 |
+
outputPricePerM: 10.00,
|
| 108 |
+
contextWindow: 131_072,
|
| 109 |
+
},
|
| 110 |
+
],
|
| 111 |
+
},
|
| 112 |
+
|
| 113 |
+
// ─────────────────────────────────────────────────────────────────
|
| 114 |
+
// CLUSTER: Balanced Coding
|
| 115 |
+
// Best code capability per dollar. Medium complexity.
|
| 116 |
+
// ─────────────────────────────────────────────────────────────────
|
| 117 |
+
{
|
| 118 |
+
id: 'code',
|
| 119 |
+
name: 'Balanced Coding',
|
| 120 |
+
description: 'Best code capability per dollar. Medium complexity.',
|
| 121 |
+
color: '#8b5cf6',
|
| 122 |
+
models: [
|
| 123 |
+
{
|
| 124 |
+
id: 'anthropic/claude-sonnet-4-20250514',
|
| 125 |
+
name: 'Claude Sonnet 4',
|
| 126 |
+
provider: 'Anthropic',
|
| 127 |
+
tier: 2,
|
| 128 |
+
latency: 'medium',
|
| 129 |
+
capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
|
| 130 |
+
inputPricePerM: 3.00,
|
| 131 |
+
outputPricePerM: 15.00,
|
| 132 |
+
contextWindow: 200_000,
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
id: 'openai/gpt-4o',
|
| 136 |
+
name: 'GPT-4o',
|
| 137 |
+
provider: 'OpenAI',
|
| 138 |
+
tier: 2,
|
| 139 |
+
latency: 'medium',
|
| 140 |
+
capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
|
| 141 |
+
inputPricePerM: 2.50,
|
| 142 |
+
outputPricePerM: 10.00,
|
| 143 |
+
contextWindow: 128_000,
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: 'anthropic/claude-3.5-sonnet',
|
| 147 |
+
name: 'Claude 3.5 Sonnet',
|
| 148 |
+
provider: 'Anthropic',
|
| 149 |
+
tier: 2,
|
| 150 |
+
latency: 'medium',
|
| 151 |
+
capabilities: ['reasoning', 'code', 'analysis'],
|
| 152 |
+
inputPricePerM: 3.00,
|
| 153 |
+
outputPricePerM: 15.00,
|
| 154 |
+
contextWindow: 200_000,
|
| 155 |
+
},
|
| 156 |
+
],
|
| 157 |
+
},
|
| 158 |
+
|
| 159 |
+
// ─────────────────────────────────────────────────────────────────
|
| 160 |
+
// CLUSTER: Power / Architecture
|
| 161 |
+
// Maximum reasoning. Pay for it only when needed.
|
| 162 |
+
// ─────────────────────────────────────────────────────────────────
|
| 163 |
+
{
|
| 164 |
+
id: 'power',
|
| 165 |
+
name: 'Power / Architecture',
|
| 166 |
+
description: 'Maximum reasoning. Pay for it only when needed.',
|
| 167 |
+
color: '#3b82f6',
|
| 168 |
+
models: [
|
| 169 |
+
{
|
| 170 |
+
id: 'anthropic/claude-opus-4-20250514',
|
| 171 |
+
name: 'Claude Opus 4',
|
| 172 |
+
provider: 'Anthropic',
|
| 173 |
+
tier: 1,
|
| 174 |
+
latency: 'slow',
|
| 175 |
+
capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
|
| 176 |
+
inputPricePerM: 15.00,
|
| 177 |
+
outputPricePerM: 75.00,
|
| 178 |
+
contextWindow: 200_000,
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
id: 'anthropic/claude-code',
|
| 182 |
+
name: 'Claude Code',
|
| 183 |
+
provider: 'Anthropic',
|
| 184 |
+
tier: 1,
|
| 185 |
+
latency: 'slow',
|
| 186 |
+
capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
|
| 187 |
+
inputPricePerM: 15.00,
|
| 188 |
+
outputPricePerM: 75.00,
|
| 189 |
+
contextWindow: 200_000,
|
| 190 |
+
},
|
| 191 |
+
],
|
| 192 |
+
},
|
| 193 |
+
|
| 194 |
+
// ─────────────────────────────────────────────────────────────────
|
| 195 |
+
// CLUSTER: Vision / Multimodal
|
| 196 |
+
// Image + document understanding.
|
| 197 |
+
// ─────────────────────────────────────────────────────────────────
|
| 198 |
+
{
|
| 199 |
+
id: 'vision',
|
| 200 |
+
name: 'Vision / Multimodal',
|
| 201 |
+
description: 'Image + document understanding.',
|
| 202 |
+
color: '#ec4899',
|
| 203 |
+
models: [
|
| 204 |
+
{
|
| 205 |
+
id: 'openai/gpt-4o',
|
| 206 |
+
name: 'GPT-4o',
|
| 207 |
+
provider: 'OpenAI',
|
| 208 |
+
tier: 4,
|
| 209 |
+
latency: 'medium',
|
| 210 |
+
capabilities: ['vision', 'reasoning', 'multimodal'],
|
| 211 |
+
inputPricePerM: 2.50,
|
| 212 |
+
outputPricePerM: 10.00,
|
| 213 |
+
contextWindow: 128_000,
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
id: 'anthropic/claude-3.5',
|
| 217 |
+
name: 'Claude 3.5',
|
| 218 |
+
provider: 'Anthropic',
|
| 219 |
+
tier: 4,
|
| 220 |
+
latency: 'medium',
|
| 221 |
+
capabilities: ['vision', 'code', 'analysis'],
|
| 222 |
+
inputPricePerM: 3.00,
|
| 223 |
+
outputPricePerM: 15.00,
|
| 224 |
+
contextWindow: 200_000,
|
| 225 |
+
},
|
| 226 |
+
],
|
| 227 |
+
},
|
| 228 |
+
];
|
| 229 |
+
|
| 230 |
+
// ─── Latency Scoring ────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
export const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
|
| 233 |
+
|
| 234 |
+
// ─── Capability Fit Matrix ──────────────────────────────────────────────────
|
| 235 |
+
// task → cluster → fit 0-100
|
| 236 |
+
|
| 237 |
+
export const CAPABILITY_FIT = {
|
| 238 |
+
simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
|
| 239 |
+
summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
|
| 240 |
+
code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
|
| 241 |
+
code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
|
| 242 |
+
complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
|
| 243 |
+
vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
|
| 244 |
+
unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
// ─── Task Detection Keywords ────────────────────────────────────────────────
|
| 248 |
+
|
| 249 |
+
export const TASK_KEYWORDS = {
|
| 250 |
+
simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me','list the'],
|
| 251 |
+
summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
|
| 252 |
+
code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue','typo','broken'],
|
| 253 |
+
code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a','app that'],
|
| 254 |
+
complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan','audit','investigate'],
|
| 255 |
+
vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document','extract text from','read the'],
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
// ─── Output Token Estimates by Task ─────────────────────────────────────────
|
| 259 |
+
|
| 260 |
+
export const OUTPUT_TOKENS = {
|
| 261 |
+
simple_qa: { min: 50, mid: 150, max: 400 },
|
| 262 |
+
summary: { min: 80, mid: 200, max: 500 },
|
| 263 |
+
code_edit: { min: 100, mid: 400, max: 1000 },
|
| 264 |
+
code_write: { min: 200, mid: 800, max: 2500 },
|
| 265 |
+
complex_analysis: { min: 200, mid: 600, max: 1500 },
|
| 266 |
+
vision: { min: 100, mid: 300, max: 800 },
|
| 267 |
+
unknown: { min: 100, mid: 400, max: 1200 },
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
| 271 |
+
|
| 272 |
+
/** Get all models as a flat array */
|
| 273 |
+
export function getAllClusterModels() {
|
| 274 |
+
return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/** Get a single model by ID */
|
| 278 |
+
export function getModelById(id) {
|
| 279 |
+
for (const cluster of CLUSTERS) {
|
| 280 |
+
const m = cluster.models.find(m => m.id === id);
|
| 281 |
+
if (m) return { ...m, clusterId: cluster.id, clusterName: cluster.name };
|
| 282 |
+
}
|
| 283 |
+
return null;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/** Get a cluster by ID */
|
| 287 |
+
export function getClusterById(id) {
|
| 288 |
+
return CLUSTERS.find(c => c.id === id) ?? null;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/** All cluster IDs */
|
| 292 |
+
export function getClusterIds() {
|
| 293 |
+
return CLUSTERS.map(c => c.id);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// ─── Browser global ─────────────────────────────────────────────────────────
|
| 297 |
+
// If loaded as a plain script (not module), expose as window.LoomLensClusters
|
| 298 |
+
if (typeof window !== 'undefined' && typeof module === 'undefined') {
|
| 299 |
+
window.LoomLensClusters = {
|
| 300 |
+
CLUSTERS,
|
| 301 |
+
LATENCY_SCORE,
|
| 302 |
+
CAPABILITY_FIT,
|
| 303 |
+
TASK_KEYWORDS,
|
| 304 |
+
OUTPUT_TOKENS,
|
| 305 |
+
getAllClusterModels,
|
| 306 |
+
getModelById,
|
| 307 |
+
getClusterById,
|
| 308 |
+
getClusterIds,
|
| 309 |
+
};
|
| 310 |
+
}
|
loomlens-engine.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — Cost Estimation Engine
|
| 3 |
+
* Pure JavaScript: no TypeScript, no build step required.
|
| 4 |
+
* Works as ES module, CommonJS, or browser global.
|
| 5 |
+
*
|
| 6 |
+
* Token estimation → cost calculation → cluster-aware fit scoring.
|
| 7 |
+
* Zero API calls. Zero dependencies.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
// ─── Inline Cluster Data ────────────────────────────────────────────────────
|
| 11 |
+
// (Cluster definitions are inlined at bottom of this file or injected by build)
|
| 12 |
+
|
| 13 |
+
const CLUSTERS = [
|
| 14 |
+
{
|
| 15 |
+
id: 'flash', name: 'Flash Reasoning',
|
| 16 |
+
models: [
|
| 17 |
+
{ id:'minimax/MiniMax-M2', name:'MiniMax M2', provider:'MiniMax', tier:3, latency:'fast', inputPricePerM:0.10, outputPricePerM:0.40 },
|
| 18 |
+
{ id:'google/gemini-2.5-flash', name:'Gemini 2.5 Flash', provider:'Google', tier:3, latency:'fast', inputPricePerM:0.15, outputPricePerM:0.60 },
|
| 19 |
+
{ id:'anthropic/claude-haiku', name:'Claude Haiku', provider:'Anthropic', tier:3, latency:'fast', inputPricePerM:0.80, outputPricePerM:4.00 },
|
| 20 |
+
{ id:'deepseek/deepseek-chat-v3',name:'DeepSeek Chat v3',provider:'DeepSeek', tier:3, latency:'fast', inputPricePerM:0.27, outputPricePerM:1.10 },
|
| 21 |
+
],
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
id: 'fastgen', name: 'Fast General',
|
| 25 |
+
models: [
|
| 26 |
+
{ id:'openai/gpt-4o-mini', name:'GPT-4o Mini', provider:'OpenAI', tier:3, latency:'fast', inputPricePerM:0.15, outputPricePerM:0.60 },
|
| 27 |
+
{ id:'google/gemini-2.0-flash', name:'Gemini 2.0 Flash', provider:'Google', tier:3, latency:'fast', inputPricePerM:0.10, outputPricePerM:0.40 },
|
| 28 |
+
{ id:'xai/grok-3', name:'xAI Grok-3', provider:'xAI', tier:3, latency:'fast', inputPricePerM:2.00, outputPricePerM:10.0 },
|
| 29 |
+
],
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
id: 'code', name: 'Balanced Coding',
|
| 33 |
+
models: [
|
| 34 |
+
{ id:'anthropic/claude-sonnet-4-20250514', name:'Claude Sonnet 4', provider:'Anthropic', tier:2, latency:'medium', inputPricePerM:3.00, outputPricePerM:15.0 },
|
| 35 |
+
{ id:'openai/gpt-4o', name:'GPT-4o', provider:'OpenAI', tier:2, latency:'medium', inputPricePerM:2.50, outputPricePerM:10.0 },
|
| 36 |
+
{ id:'anthropic/claude-3.5-sonnet', name:'Claude 3.5 Sonnet', provider:'Anthropic',tier:2, latency:'medium', inputPricePerM:3.00, outputPricePerM:15.0 },
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: 'power', name: 'Power / Architecture',
|
| 41 |
+
models: [
|
| 42 |
+
{ id:'anthropic/claude-opus-4-20250514', name:'Claude Opus 4', provider:'Anthropic', tier:1, latency:'slow', inputPricePerM:15.0, outputPricePerM:75.0 },
|
| 43 |
+
{ id:'anthropic/claude-code', name:'Claude Code', provider:'Anthropic', tier:1, latency:'slow', inputPricePerM:15.0, outputPricePerM:75.0 },
|
| 44 |
+
],
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'vision', name: 'Vision / Multimodal',
|
| 48 |
+
models: [
|
| 49 |
+
{ id:'openai/gpt-4o', name:'GPT-4o', provider:'OpenAI', tier:4, latency:'medium', inputPricePerM:2.50, outputPricePerM:10.0 },
|
| 50 |
+
{ id:'anthropic/claude-3.5', name:'Claude 3.5', provider:'Anthropic', tier:4, latency:'medium', inputPricePerM:3.00, outputPricePerM:15.0 },
|
| 51 |
+
],
|
| 52 |
+
},
|
| 53 |
+
];
|
| 54 |
+
|
| 55 |
+
const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
|
| 56 |
+
|
| 57 |
+
const CAPABILITY_FIT = {
|
| 58 |
+
simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
|
| 59 |
+
summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
|
| 60 |
+
code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
|
| 61 |
+
code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
|
| 62 |
+
complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
|
| 63 |
+
vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
|
| 64 |
+
unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const TASK_KEYWORDS = {
|
| 68 |
+
simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me','give me'],
|
| 69 |
+
summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense','shorten'],
|
| 70 |
+
code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue'],
|
| 71 |
+
code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for','make a'],
|
| 72 |
+
complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan'],
|
| 73 |
+
vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document'],
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const OUTPUT_TOKENS = { simple_qa:150, summary:200, code_edit:400, code_write:800, complex_analysis:600, vision:300, unknown:400 };
|
| 77 |
+
|
| 78 |
+
const CHARS_PER_TOKEN = { en: 3.8, es: 3.5, ja: 1.2, zh: 1.0 };
|
| 79 |
+
|
| 80 |
+
// ─── Token Estimation ────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
export function detectLanguage(text) {
|
| 83 |
+
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja';
|
| 84 |
+
if (/[\u4E00-\u9FFF]/.test(text)) return 'zh';
|
| 85 |
+
if (/[áéíóúñ¿¡ü]/i.test(text)) return 'es';
|
| 86 |
+
return 'en';
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export function estimateInputTokens(text) {
|
| 90 |
+
if (!text || !text.trim()) return 0;
|
| 91 |
+
const lang = detectLanguage(text);
|
| 92 |
+
const cpt = CHARS_PER_TOKEN[lang];
|
| 93 |
+
const multiline = (text.match(/\n/g)||[]).length;
|
| 94 |
+
const codeFences = (text.match(/```/g)||[]).length;
|
| 95 |
+
const mult = (codeFences >= 2 || multiline > 3) ? 1.2 : 1.0;
|
| 96 |
+
return Math.ceil((text.length / cpt) * mult);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// ─── Task Detection ─────────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
export function detectTaskType(prompt) {
|
| 102 |
+
const lower = prompt.toLowerCase();
|
| 103 |
+
const scores = {};
|
| 104 |
+
for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
|
| 105 |
+
scores[type] = keywords.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
|
| 106 |
+
}
|
| 107 |
+
if (/```[\s\S]+```/.test(prompt)) scores['code_write'] = (scores['code_write']||0) + 2;
|
| 108 |
+
if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt)) scores['code_write'] = (scores['code_write']||0) + 2;
|
| 109 |
+
if (/\b(fix|bug|error|issue)\b/i.test(prompt)) scores['code_edit'] = (scores['code_edit']||0) + 1;
|
| 110 |
+
if (/\b(write|build|create|implement)\b/i.test(prompt)) scores['code_write'] = (scores['code_write']||0) + 1;
|
| 111 |
+
if (/\b(analyze|evaluate|compare|assess)\b/i.test(prompt)) scores['complex_analysis'] = (scores['complex_analysis']||0) + 1;
|
| 112 |
+
|
| 113 |
+
const best = Object.entries(scores).sort((a,b) => (b[1]??0) - (a[1]??0))[0];
|
| 114 |
+
const score = best?.[1] ?? 0;
|
| 115 |
+
const type = score === 0 ? 'unknown' : best[0];
|
| 116 |
+
return { type, confidence: Math.min(score/3, 1), keywords: (TASK_KEYWORDS[type]??[]).filter(kw=>lower.includes(kw)) };
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// ─── Cost ──────────────────────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
export function calcCost(inPrice, outPrice, inTokens, outTokens) {
|
| 122 |
+
return { inputCost: Math.round((inTokens/1e6)*inPrice*1e6)/1e6, outputCost: Math.round((outTokens/1e6)*outPrice*1e6)/1e6,
|
| 123 |
+
totalCost: Math.round(((inTokens/1e6)*inPrice + (outTokens/1e6)*outPrice)*1e6)/1e6 };
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// ─── Fit Scoring ────────────────────────────────────────────────────────────
|
| 127 |
+
|
| 128 |
+
export function scoreWithinCluster(model, task, clusterModels) {
|
| 129 |
+
const capFit = CAPABILITY_FIT[task]?.[model.clusterId] ?? 50;
|
| 130 |
+
const costs = clusterModels.map(m => calcCost(m.inputPricePerM, m.outputPricePerM, 1, 1).totalCost);
|
| 131 |
+
const minC = Math.min(...costs);
|
| 132 |
+
const maxC = Math.max(...costs);
|
| 133 |
+
const range = maxC - minC || 1;
|
| 134 |
+
const costEff = Math.round(100 - ((calcCost(model.inputPricePerM, model.outputPricePerM, 1, 1).totalCost - minC) / range * 100));
|
| 135 |
+
const speed = LATENCY_SCORE[model.latency] ?? 50;
|
| 136 |
+
return Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
export function getInsight(modelName, clusterName, clusterId, task, score, isBest) {
|
| 140 |
+
const labels = { simple_qa:'quick Q&A', summary:'summarization', code_edit:'code fixes', code_write:'code builds',
|
| 141 |
+
complex_analysis:'deep analysis', vision:'image/doc understanding', unknown:'general purpose' };
|
| 142 |
+
const label = labels[task] ?? 'general purpose';
|
| 143 |
+
if (isBest) return `Best in ${clusterName} for ${label}`;
|
| 144 |
+
if (score >= 80) return `Strong fit for ${label}`;
|
| 145 |
+
if (score >= 65) return `Capable for ${label}`;
|
| 146 |
+
return `Consider alternatives for ${label}`;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ─── Main Estimator ─────────────────────────────────────────────────────────
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* estimatePrompt(prompt, selectedModelId?) → LoomLensResult
|
| 153 |
+
*/
|
| 154 |
+
export function estimatePrompt(prompt, selectedModelId = '') {
|
| 155 |
+
const task = detectTaskType(prompt);
|
| 156 |
+
const inTokens = estimateInputTokens(prompt);
|
| 157 |
+
const outTokens = OUTPUT_TOKENS[task.type] ?? 400;
|
| 158 |
+
|
| 159 |
+
const allFlat = CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
|
| 160 |
+
const clusters = {};
|
| 161 |
+
const allModels = [];
|
| 162 |
+
|
| 163 |
+
for (const cluster of CLUSTERS) {
|
| 164 |
+
const clusterModels = allFlat.filter(m => m.clusterId === cluster.id);
|
| 165 |
+
const clusterCosts = clusterModels.map(m => calcCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens).totalCost);
|
| 166 |
+
const maxCost = Math.max(...clusterCosts);
|
| 167 |
+
|
| 168 |
+
const scored = clusterModels.map(m => {
|
| 169 |
+
const costs = calcCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens);
|
| 170 |
+
const score = scoreWithinCluster(m, task.type, clusterModels);
|
| 171 |
+
const isBest = false;
|
| 172 |
+
return {
|
| 173 |
+
modelId: m.id, modelName: m.name, provider: m.provider,
|
| 174 |
+
clusterId: m.clusterId, clusterName: cluster.name,
|
| 175 |
+
inputTokens: inTokens, outputTokens: outTokens,
|
| 176 |
+
inputCost: costs.inputCost, outputCost: costs.outputCost, totalCost: costs.totalCost,
|
| 177 |
+
latencyScore: LATENCY_SCORE[m.latency] ?? 50,
|
| 178 |
+
compositeScore: score,
|
| 179 |
+
relativeCostPct: Math.round((costs.totalCost / maxCost) * 100),
|
| 180 |
+
_isBest: false,
|
| 181 |
+
};
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
scored.sort((a,b) => b.compositeScore - a.compositeScore);
|
| 185 |
+
const best = scored.find(m => m.compositeScore >= 65) ?? scored[0];
|
| 186 |
+
if (best) best._isBest = true;
|
| 187 |
+
scored.forEach(m => {
|
| 188 |
+
m.fitBadge = m._isBest ? 'recommended' : m.compositeScore >= 65 ? 'sufficient' : 'overkill';
|
| 189 |
+
m.insight = getInsight(m.modelName, cluster.name, cluster.id, task.type, m.compositeScore, m._isBest);
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
clusters[cluster.id] = { clusterId: cluster.id, clusterName: cluster.name, models: scored, best: best??null };
|
| 193 |
+
allModels.push(...scored);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
allModels.sort((a,b) => b.compositeScore - a.compositeScore);
|
| 197 |
+
const recommended = allModels.find(m => m.compositeScore >= 80) ?? null;
|
| 198 |
+
const selected = allModels.find(m => m.modelId === selectedModelId) ?? allModels[0];
|
| 199 |
+
|
| 200 |
+
let tip = '';
|
| 201 |
+
if (recommended && selected && recommended.modelId !== selected.modelId) {
|
| 202 |
+
const saving = selected.totalCost - recommended.totalCost;
|
| 203 |
+
if (saving > 0.0001) tip = `Switch to ${recommended.modelName} and save ${formatCost(saving)}.`;
|
| 204 |
+
else if (saving < -0.0001) tip = `${selected.modelName} costs ${formatCost(Math.abs(saving))} more than ${recommended.modelName}.`;
|
| 205 |
+
} else if (selected) {
|
| 206 |
+
tip = `${selected.modelName} is a good fit for this task.`;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
return { prompt, taskType: task.type, taskConfidence: task.confidence, inputTokens: inTokens, outputTokens: outTokens,
|
| 210 |
+
clusters, recommended, selected, allModels, tip, estimatedAt: Date.now() };
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
export function formatCost(n) {
|
| 214 |
+
if (n >= 1) return '$' + n.toFixed(2);
|
| 215 |
+
if (n >= 0.01) return '$' + n.toFixed(3);
|
| 216 |
+
return '$' + n.toFixed(4);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
export function getAllClusterModels() {
|
| 223 |
+
return CLUSTERS.flatMap(c => c.models.map(m => ({ ...m, clusterId: c.id, clusterName: c.name })));
|
| 224 |
+
}
|
| 225 |
+
export function getModelById(id) {
|
| 226 |
+
for (const c of CLUSTERS) { const m = c.models.find(m => m.id === id); if (m) return { ...m, clusterId: c.id, clusterName: c.name }; }
|
| 227 |
+
return null;
|
| 228 |
+
}
|
| 229 |
+
export function getClusterById(id) { return CLUSTERS.find(c => c.id === id) ?? null; }
|
| 230 |
+
export function getClusterIds() { return CLUSTERS.map(c => c.id); }
|
| 231 |
+
|
| 232 |
+
// ─── Browser global ─────────────────────────────────────────────────────────
|
| 233 |
+
if (typeof window !== 'undefined' && typeof module === 'undefined') {
|
| 234 |
+
window.LoomLensEngine = { estimatePrompt, formatCost, estimateInputTokens, detectTaskType,
|
| 235 |
+
CLUSTERS, LATENCY_SCORE, CAPABILITY_FIT, TASK_KEYWORDS, OUTPUT_TOKENS,
|
| 236 |
+
getAllClusterModels, getModelById, getClusterById, getClusterIds };
|
| 237 |
+
}
|
loomlens-engine.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — Cost Estimation Engine
|
| 3 |
+
* Phase 1 updated: cluster-aware scoring + standalone (no imports needed)
|
| 4 |
+
*
|
| 5 |
+
* Estimator: char heuristics → token count → cost + cluster-aware fit score.
|
| 6 |
+
* No API calls. No dependencies.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import {
|
| 10 |
+
CLUSTERS,
|
| 11 |
+
LATENCY_SCORE,
|
| 12 |
+
CAPABILITY_FIT,
|
| 13 |
+
TASK_KEYWORDS,
|
| 14 |
+
OUTPUT_TOKENS,
|
| 15 |
+
getAllClusterModels,
|
| 16 |
+
} from './loomlens-clusters.js';
|
| 17 |
+
|
| 18 |
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
export type TaskType = keyof typeof TASK_KEYWORDS;
|
| 21 |
+
export type ClusterId = 'flash' | 'fastgen' | 'code' | 'power' | 'vision';
|
| 22 |
+
|
| 23 |
+
export interface TaskDetection {
|
| 24 |
+
type: TaskType;
|
| 25 |
+
confidence: number;
|
| 26 |
+
keywords: string[];
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface ModelEstimate {
|
| 30 |
+
modelId: string;
|
| 31 |
+
modelName: string;
|
| 32 |
+
provider: string;
|
| 33 |
+
clusterId: string;
|
| 34 |
+
clusterName: string;
|
| 35 |
+
inputTokens: number;
|
| 36 |
+
outputTokens: number;
|
| 37 |
+
inputCost: number;
|
| 38 |
+
outputCost: number;
|
| 39 |
+
totalCost: number;
|
| 40 |
+
latencyScore: number;
|
| 41 |
+
capFit: number;
|
| 42 |
+
costEff: number;
|
| 43 |
+
compositeScore: number; // 0-100
|
| 44 |
+
fitBadge: 'recommended' | 'sufficient' | 'overkill';
|
| 45 |
+
insight: string;
|
| 46 |
+
relativeCostPct: number; // % of most expensive in same cluster
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export interface ClusterResult {
|
| 50 |
+
clusterId: string;
|
| 51 |
+
clusterName: string;
|
| 52 |
+
models: ModelEstimate[];
|
| 53 |
+
best: ModelEstimate | null; // cheapest that clears threshold
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface LoomLensResult {
|
| 57 |
+
prompt: string;
|
| 58 |
+
taskType: TaskType;
|
| 59 |
+
taskConfidence: number;
|
| 60 |
+
inputTokens: number;
|
| 61 |
+
outputTokens: number;
|
| 62 |
+
clusters: Record<string, ClusterResult>;
|
| 63 |
+
recommended: ModelEstimate | null;
|
| 64 |
+
selected: ModelEstimate | null;
|
| 65 |
+
allModels: ModelEstimate[];
|
| 66 |
+
tip: string;
|
| 67 |
+
estimatedAt: number;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// ─── Token Estimation ────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
const CHARS_PER_TOKEN = { en: 3.8, es: 3.5, ja: 1.2, zh: 1.0 };
|
| 73 |
+
|
| 74 |
+
function detectLanguage(text: string): keyof typeof CHARS_PER_TOKEN {
|
| 75 |
+
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja';
|
| 76 |
+
if (/[\u4E00-\u9FFF]/.test(text)) return 'zh';
|
| 77 |
+
if (/[áéíóúñ¿¡ü]/i.test(text)) return 'es';
|
| 78 |
+
return 'en';
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function detectContentMultiplier(text: string): number {
|
| 82 |
+
const codeFences = (text.match(/```/g) || []).length;
|
| 83 |
+
const newlines = (text.match(/\n/g) || []).length;
|
| 84 |
+
const isCode = codeFences >= 2 || (newlines > 3 && codeFences >= 1);
|
| 85 |
+
return isCode ? 1.2 : 1.0;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Estimate input token count from raw text.
|
| 90 |
+
* Uses language detection + content-type heuristics.
|
| 91 |
+
*/
|
| 92 |
+
export function estimateInputTokens(text: string): number {
|
| 93 |
+
if (!text || !text.trim()) return 0;
|
| 94 |
+
const lang = detectLanguage(text);
|
| 95 |
+
const cpt = CHARS_PER_TOKEN[lang];
|
| 96 |
+
const mult = detectContentMultiplier(text);
|
| 97 |
+
return Math.ceil((text.length / cpt) * mult);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ─── Task Detection ─────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Detect what type of task this prompt represents.
|
| 104 |
+
*/
|
| 105 |
+
export function detectTaskType(prompt: string): TaskDetection {
|
| 106 |
+
const lower = prompt.toLowerCase();
|
| 107 |
+
const scores: Partial<Record<TaskType, number>> = {};
|
| 108 |
+
|
| 109 |
+
for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
|
| 110 |
+
scores[type as TaskType] = keywords.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Structural boosts
|
| 114 |
+
if (/```[\s\S]+```/.test(prompt)) (scores['code_write'] as number) += 2;
|
| 115 |
+
if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt))
|
| 116 |
+
(scores['code_write'] as number) += 2;
|
| 117 |
+
if (/\b(fix|bug|error|issue)\b/i.test(prompt)) (scores['code_edit'] as number) += 1;
|
| 118 |
+
if (/\b(write|build|create|implement)\b/i.test(prompt))
|
| 119 |
+
(scores['code_write'] as number) += 1;
|
| 120 |
+
if (/\b(analyze|evaluate|compare|assess)\b/i.test(prompt))
|
| 121 |
+
(scores['complex_analysis'] as number) += 1;
|
| 122 |
+
if (/\$\d+|\d+%|rate|spend|cost|budget/i.test(prompt))
|
| 123 |
+
(scores['complex_analysis'] as number) += 1;
|
| 124 |
+
if (/\$\d+|\d+%|rate|spend/i.test(prompt)) (scores['simple_qa'] as number) += 1;
|
| 125 |
+
|
| 126 |
+
const best = Object.entries(scores).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0];
|
| 127 |
+
const score = best?.[1] ?? 0;
|
| 128 |
+
const type: TaskType = score === 0 ? 'unknown' : (best![0] as TaskType);
|
| 129 |
+
const confidence = Math.min(score / 3, 1);
|
| 130 |
+
const keywords = (TASK_KEYWORDS[type] ?? []).filter(kw => lower.includes(kw));
|
| 131 |
+
|
| 132 |
+
return { type, confidence, keywords };
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// ─── Cost Calculation ────────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
export function calculateCost(
|
| 138 |
+
inPricePerM: number,
|
| 139 |
+
outPricePerM: number,
|
| 140 |
+
inTokens: number,
|
| 141 |
+
outTokens: number
|
| 142 |
+
): { inputCost: number; outputCost: number; totalCost: number } {
|
| 143 |
+
const inputCost = Math.round((inTokens / 1_000_000) * inPricePerM * 1e6) / 1e6;
|
| 144 |
+
const outputCost = Math.round((outTokens / 1_000_000) * outPricePerM * 1e6) / 1e6;
|
| 145 |
+
return { inputCost, outputCost, totalCost: Math.round((inputCost + outputCost) * 1e6) / 1e6 };
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ─── Fit Scoring ─────────────────────────────────────────────────────────────
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Composite score within a cluster:
|
| 152 |
+
* score = (capability_fit × 0.35) + (cost_efficiency × 0.35) + (speed × 0.30)
|
| 153 |
+
*
|
| 154 |
+
* Returns { capFit, costEff, compositeScore }
|
| 155 |
+
*/
|
| 156 |
+
function scoreWithinCluster(
|
| 157 |
+
model: { clusterId: string; latency: keyof typeof LATENCY_SCORE; inPricePerM: number; outPricePerM: number },
|
| 158 |
+
task: TaskType,
|
| 159 |
+
allInCluster: Array<{ inPricePerM: number; outPricePerM: number }>
|
| 160 |
+
): { capFit: number; costEff: number; compositeScore: number } {
|
| 161 |
+
const capFit = CAPABILITY_FIT[task]?.[model.clusterId] ?? 50;
|
| 162 |
+
const costs = allInCluster.map(m => calculateCost(m.inPricePerM, m.outPricePerM, 1, 1).totalCost);
|
| 163 |
+
const minC = Math.min(...costs);
|
| 164 |
+
const maxC = Math.max(...costs);
|
| 165 |
+
const range = maxC - minC || 1;
|
| 166 |
+
const costEff = Math.round(100 - ((calculateCost(model.inPricePerM, model.outPricePerM, 1, 1).totalCost - minC) / range * 100));
|
| 167 |
+
const speed = LATENCY_SCORE[model.latency] ?? 50;
|
| 168 |
+
const composite = Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
|
| 169 |
+
return { capFit, costEff, compositeScore: composite };
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function getFitBadge(score: number, clusterId: string): 'recommended' | 'sufficient' | 'overkill' {
|
| 173 |
+
if (score >= 80) return 'recommended';
|
| 174 |
+
if (score >= 65) return 'sufficient';
|
| 175 |
+
return 'overkill';
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function getInsight(
|
| 179 |
+
modelName: string,
|
| 180 |
+
clusterName: string,
|
| 181 |
+
clusterId: string,
|
| 182 |
+
task: TaskType,
|
| 183 |
+
score: number,
|
| 184 |
+
isBest: boolean
|
| 185 |
+
): string {
|
| 186 |
+
const taskLabels: Record<TaskType, string> = {
|
| 187 |
+
simple_qa: 'quick questions and lookups',
|
| 188 |
+
summary: 'summarization and extraction',
|
| 189 |
+
code_edit: 'code fixes and small changes',
|
| 190 |
+
code_write: 'full code and feature builds',
|
| 191 |
+
complex_analysis: 'deep reasoning and architecture',
|
| 192 |
+
vision: 'image and document understanding',
|
| 193 |
+
unknown: 'general purpose tasks',
|
| 194 |
+
};
|
| 195 |
+
const label = taskLabels[task] ?? 'general purpose';
|
| 196 |
+
if (isBest) return `Best in ${clusterName} for ${label}`;
|
| 197 |
+
if (score >= 80) return `Strong fit for ${label} in ${clusterName}`;
|
| 198 |
+
if (score >= 65) return `Capable for ${label} in ${clusterName}`;
|
| 199 |
+
if (score < 50) return `Consider ${clusterName} alternatives for ${label}`;
|
| 200 |
+
return `Use ${modelName} in ${clusterName} for ${label}`;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// ─── Main Estimator ──────────────────────────────────────────────────────────
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* Full estimation: token count → cost + cluster-aware fit score per model.
|
| 207 |
+
*/
|
| 208 |
+
export function estimatePrompt(
|
| 209 |
+
prompt: string,
|
| 210 |
+
selectedModelId: string = ''
|
| 211 |
+
): LoomLensResult {
|
| 212 |
+
const task = detectTaskType(prompt);
|
| 213 |
+
const inTokens = estimateInputTokens(prompt);
|
| 214 |
+
const outTokens = OUTPUT_TOKENS[task]?.mid ?? 400;
|
| 215 |
+
const allFlat = getAllClusterModels();
|
| 216 |
+
const result: LoomLensResult = {
|
| 217 |
+
prompt,
|
| 218 |
+
taskType: task.type,
|
| 219 |
+
taskConfidence: task.confidence,
|
| 220 |
+
inputTokens: inTokens,
|
| 221 |
+
outputTokens: outTokens,
|
| 222 |
+
clusters: {},
|
| 223 |
+
recommended: null,
|
| 224 |
+
selected: null,
|
| 225 |
+
allModels: [],
|
| 226 |
+
tip: '',
|
| 227 |
+
estimatedAt: Date.now(),
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
// Score each model within its cluster
|
| 231 |
+
for (const cluster of CLUSTERS) {
|
| 232 |
+
const clusterModels = allFlat.filter(m => m.clusterId === cluster.id);
|
| 233 |
+
const bestInCluster = clusterModels[0]; // placeholder, will be sorted
|
| 234 |
+
|
| 235 |
+
// Pre-compute cluster cost range
|
| 236 |
+
const clusterCosts = clusterModels.map(m =>
|
| 237 |
+
calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens).totalCost
|
| 238 |
+
);
|
| 239 |
+
const maxClusterCost = Math.max(...clusterCosts);
|
| 240 |
+
|
| 241 |
+
// Score each model
|
| 242 |
+
const scored = clusterModels.map(m => {
|
| 243 |
+
const costs = calculateCost(m.inputPricePerM, m.outputPricePerM, inTokens, outTokens);
|
| 244 |
+
const { capFit, costEff, compositeScore } = scoreWithinCluster(m, task.type, clusterModels);
|
| 245 |
+
const isBest = false; // will be set after sort
|
| 246 |
+
const fitBadge = getFitBadge(compositeScore, cluster.id);
|
| 247 |
+
return {
|
| 248 |
+
modelId: m.id,
|
| 249 |
+
modelName: m.name,
|
| 250 |
+
provider: m.provider,
|
| 251 |
+
clusterId: m.clusterId,
|
| 252 |
+
clusterName: cluster.name,
|
| 253 |
+
inputTokens: inTokens,
|
| 254 |
+
outputTokens: outTokens,
|
| 255 |
+
inputCost: costs.inputCost,
|
| 256 |
+
outputCost: costs.outputCost,
|
| 257 |
+
totalCost: costs.totalCost,
|
| 258 |
+
latencyScore: LATENCY_SCORE[m.latency] ?? 50,
|
| 259 |
+
capFit,
|
| 260 |
+
costEff,
|
| 261 |
+
compositeScore,
|
| 262 |
+
fitBadge,
|
| 263 |
+
insight: '',
|
| 264 |
+
relativeCostPct: Math.round((costs.totalCost / maxClusterCost) * 100),
|
| 265 |
+
_isBest: false,
|
| 266 |
+
} as ModelEstimate & { _isBest: boolean };
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// Sort by composite score desc, then find cheapest above threshold
|
| 270 |
+
scored.sort((a, b) => b.compositeScore - a.compositeScore);
|
| 271 |
+
const best = scored.find(m => m.compositeScore >= 65) ?? scored[0];
|
| 272 |
+
if (best) best._isBest = true;
|
| 273 |
+
|
| 274 |
+
// Set insights
|
| 275 |
+
scored.forEach(m => {
|
| 276 |
+
m.insight = getInsight(m.modelName, cluster.name, cluster.id, task.type, m.compositeScore, m._isBest);
|
| 277 |
+
m.fitBadge = m._isBest ? 'recommended' : m.compositeScore >= 65 ? 'sufficient' : 'overkill';
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
result.clusters[cluster.id] = {
|
| 281 |
+
clusterId: cluster.id,
|
| 282 |
+
clusterName: cluster.name,
|
| 283 |
+
models: scored,
|
| 284 |
+
best: best ?? null,
|
| 285 |
+
};
|
| 286 |
+
result.allModels.push(...scored);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// Global: find best across all models
|
| 290 |
+
result.allModels.sort((a, b) => b.compositeScore - a.compositeScore);
|
| 291 |
+
result.recommended = result.allModels.find(m => m.compositeScore >= 80) ?? null;
|
| 292 |
+
|
| 293 |
+
// Selected
|
| 294 |
+
if (selectedModelId) {
|
| 295 |
+
result.selected = result.allModels.find(m => m.modelId === selectedModelId) ?? null;
|
| 296 |
+
}
|
| 297 |
+
if (!result.selected) {
|
| 298 |
+
result.selected = result.allModels[0];
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Tip
|
| 302 |
+
if (result.recommended && result.selected && result.recommended.modelId !== result.selected.modelId) {
|
| 303 |
+
const selCost = result.selected?.totalCost ?? 0;
|
| 304 |
+
const recCost = result.recommended.totalCost;
|
| 305 |
+
const saving = selCost - recCost;
|
| 306 |
+
if (saving > 0.0001) {
|
| 307 |
+
result.tip = `Switching to ${result.recommended.modelName} saves ${formatCost(saving)} and is better for this task.`;
|
| 308 |
+
} else if (saving < -0.0001) {
|
| 309 |
+
result.tip = `${result.selected?.modelName} is well-matched. ${result.recommended.modelName} would cost ${formatCost(Math.abs(saving))} more.`;
|
| 310 |
+
}
|
| 311 |
+
} else if (result.selected) {
|
| 312 |
+
result.tip = `${result.selected.modelName} is a good fit for this task.`;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return result;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// ─── Utility ─────────────────────────────────────────────────────────────────
|
| 319 |
+
|
| 320 |
+
export function formatCost(n: number): string {
|
| 321 |
+
if (n >= 1) return '$' + n.toFixed(2);
|
| 322 |
+
if (n >= 0.01) return '$' + n.toFixed(3);
|
| 323 |
+
return '$' + n.toFixed(4);
|
| 324 |
+
}
|
loomlens-models.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — Model Registry
|
| 3 |
+
*
|
| 4 |
+
* Canonical model definitions used by the estimation engine.
|
| 5 |
+
* Cluster assignments and full model metadata live in loomlens-clusters.js.
|
| 6 |
+
* This file provides supplementary metadata (provider URLs, descriptions, etc.)
|
| 7 |
+
*
|
| 8 |
+
* Note: For the standalone sidebar, cluster definitions in loomlens-clusters.js
|
| 9 |
+
* are the source of truth for all pricing, latency, and cluster membership.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
export interface ModelInfo {
|
| 13 |
+
id: string;
|
| 14 |
+
name: string;
|
| 15 |
+
provider: string;
|
| 16 |
+
tier: 1 | 2 | 3 | 4 | 5;
|
| 17 |
+
tierName: string;
|
| 18 |
+
inputPricePerM: number;
|
| 19 |
+
outputPricePerM: number;
|
| 20 |
+
contextWindow: number;
|
| 21 |
+
latency: 'slow' | 'medium' | 'fast';
|
| 22 |
+
capabilities: string[];
|
| 23 |
+
/** Short one-line description for tooltips/cards */
|
| 24 |
+
tagline: string;
|
| 25 |
+
/** URL to provider docs */
|
| 26 |
+
docsUrl?: string;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export const ModelDescriptions: Record<string, ModelInfo> = {
|
| 30 |
+
|
| 31 |
+
'minimax/MiniMax-M2': {
|
| 32 |
+
id: 'minimax/MiniMax-M2',
|
| 33 |
+
name: 'MiniMax M2',
|
| 34 |
+
provider: 'MiniMax',
|
| 35 |
+
tier: 3,
|
| 36 |
+
tierName: 'Fast',
|
| 37 |
+
inputPricePerM: 0.10,
|
| 38 |
+
outputPricePerM: 0.40,
|
| 39 |
+
contextWindow: 1_024_000,
|
| 40 |
+
latency: 'fast',
|
| 41 |
+
capabilities: ['reasoning', 'fast', 'cheap', 'long-context'],
|
| 42 |
+
tagline: 'Best value reasoning model available',
|
| 43 |
+
docsUrl: 'https://www.minimaxi.com/',
|
| 44 |
+
},
|
| 45 |
+
'google/gemini-2.5-flash': {
|
| 46 |
+
id: 'google/gemini-2.5-flash',
|
| 47 |
+
name: 'Gemini 2.5 Flash',
|
| 48 |
+
provider: 'Google',
|
| 49 |
+
tier: 3,
|
| 50 |
+
tierName: 'Fast',
|
| 51 |
+
inputPricePerM: 0.15,
|
| 52 |
+
outputPricePerM: 0.60,
|
| 53 |
+
contextWindow: 1_000_000,
|
| 54 |
+
latency: 'fast',
|
| 55 |
+
capabilities: ['reasoning', 'fast'],
|
| 56 |
+
tagline: 'Google\'s fastest reasoning model',
|
| 57 |
+
docsUrl: 'https://ai.google.dev/',
|
| 58 |
+
},
|
| 59 |
+
'anthropic/claude-haiku': {
|
| 60 |
+
id: 'anthropic/claude-haiku',
|
| 61 |
+
name: 'Claude Haiku',
|
| 62 |
+
provider: 'Anthropic',
|
| 63 |
+
tier: 3,
|
| 64 |
+
tierName: 'Fast',
|
| 65 |
+
inputPricePerM: 0.80,
|
| 66 |
+
outputPricePerM: 4.00,
|
| 67 |
+
contextWindow: 200_000,
|
| 68 |
+
latency: 'fast',
|
| 69 |
+
capabilities: ['fast', 'light-reasoning', 'cheap'],
|
| 70 |
+
tagline: 'Anthropic\'s fastest, most affordable',
|
| 71 |
+
docsUrl: 'https://www.anthropic.com/',
|
| 72 |
+
},
|
| 73 |
+
'deepseek/deepseek-chat-v3': {
|
| 74 |
+
id: 'deepseek/deepseek-chat-v3',
|
| 75 |
+
name: 'DeepSeek Chat v3',
|
| 76 |
+
provider: 'DeepSeek',
|
| 77 |
+
tier: 3,
|
| 78 |
+
tierName: 'Fast',
|
| 79 |
+
inputPricePerM: 0.27,
|
| 80 |
+
outputPricePerM: 1.10,
|
| 81 |
+
contextWindow: 640_000,
|
| 82 |
+
latency: 'fast',
|
| 83 |
+
capabilities: ['reasoning', 'fast'],
|
| 84 |
+
tagline: 'Strong open-weights reasoning at low cost',
|
| 85 |
+
docsUrl: 'https://www.deepseek.com/',
|
| 86 |
+
},
|
| 87 |
+
'openai/gpt-4o-mini': {
|
| 88 |
+
id: 'openai/gpt-4o-mini',
|
| 89 |
+
name: 'GPT-4o Mini',
|
| 90 |
+
provider: 'OpenAI',
|
| 91 |
+
tier: 3,
|
| 92 |
+
tierName: 'Fast',
|
| 93 |
+
inputPricePerM: 0.15,
|
| 94 |
+
outputPricePerM: 0.60,
|
| 95 |
+
contextWindow: 128_000,
|
| 96 |
+
latency: 'fast',
|
| 97 |
+
capabilities: ['fast', 'cheap'],
|
| 98 |
+
tagline: 'OpenAI\'s cheapest GPT-4 class model',
|
| 99 |
+
docsUrl: 'https://platform.openai.com/',
|
| 100 |
+
},
|
| 101 |
+
'google/gemini-2.0-flash': {
|
| 102 |
+
id: 'google/gemini-2.0-flash',
|
| 103 |
+
name: 'Gemini 2.0 Flash',
|
| 104 |
+
provider: 'Google',
|
| 105 |
+
tier: 3,
|
| 106 |
+
tierName: 'Fast',
|
| 107 |
+
inputPricePerM: 0.10,
|
| 108 |
+
outputPricePerM: 0.40,
|
| 109 |
+
contextWindow: 1_000_000,
|
| 110 |
+
latency: 'fast',
|
| 111 |
+
capabilities: ['fast', 'cheap', 'long-context'],
|
| 112 |
+
tagline: 'Google\'s original Flash speed at lowest price',
|
| 113 |
+
docsUrl: 'https://ai.google.dev/',
|
| 114 |
+
},
|
| 115 |
+
'xai/grok-3': {
|
| 116 |
+
id: 'xai/grok-3',
|
| 117 |
+
name: 'xAI Grok-3',
|
| 118 |
+
provider: 'xAI',
|
| 119 |
+
tier: 3,
|
| 120 |
+
tierName: 'Fast',
|
| 121 |
+
inputPricePerM: 2.00,
|
| 122 |
+
outputPricePerM: 10.00,
|
| 123 |
+
contextWindow: 131_072,
|
| 124 |
+
latency: 'fast',
|
| 125 |
+
capabilities: ['fast', 'reasoning'],
|
| 126 |
+
tagline: 'xAI\'s fastest model with real-time data access',
|
| 127 |
+
docsUrl: 'https://x.ai/',
|
| 128 |
+
},
|
| 129 |
+
'anthropic/claude-sonnet-4-20250514': {
|
| 130 |
+
id: 'anthropic/claude-sonnet-4-20250514',
|
| 131 |
+
name: 'Claude Sonnet 4',
|
| 132 |
+
provider: 'Anthropic',
|
| 133 |
+
tier: 2,
|
| 134 |
+
tierName: 'Balanced',
|
| 135 |
+
inputPricePerM: 3.00,
|
| 136 |
+
outputPricePerM: 15.00,
|
| 137 |
+
contextWindow: 200_000,
|
| 138 |
+
latency: 'medium',
|
| 139 |
+
capabilities: ['reasoning', 'code', 'analysis', 'fast-reasoning'],
|
| 140 |
+
tagline: 'Best overall code + reasoning value from Anthropic',
|
| 141 |
+
docsUrl: 'https://www.anthropic.com/',
|
| 142 |
+
},
|
| 143 |
+
'openai/gpt-4o': {
|
| 144 |
+
id: 'openai/gpt-4o',
|
| 145 |
+
name: 'GPT-4o',
|
| 146 |
+
provider: 'OpenAI',
|
| 147 |
+
tier: 2,
|
| 148 |
+
tierName: 'Balanced',
|
| 149 |
+
inputPricePerM: 2.50,
|
| 150 |
+
outputPricePerM: 10.00,
|
| 151 |
+
contextWindow: 128_000,
|
| 152 |
+
latency: 'medium',
|
| 153 |
+
capabilities: ['reasoning', 'vision', 'code', 'multimodal'],
|
| 154 |
+
tagline: 'OpenAI\'s flagship — vision + reasoning in one',
|
| 155 |
+
docsUrl: 'https://platform.openai.com/',
|
| 156 |
+
},
|
| 157 |
+
'anthropic/claude-3.5-sonnet': {
|
| 158 |
+
id: 'anthropic/claude-3.5-sonnet',
|
| 159 |
+
name: 'Claude 3.5 Sonnet',
|
| 160 |
+
provider: 'Anthropic',
|
| 161 |
+
tier: 2,
|
| 162 |
+
tierName: 'Balanced',
|
| 163 |
+
inputPricePerM: 3.00,
|
| 164 |
+
outputPricePerM: 15.00,
|
| 165 |
+
contextWindow: 200_000,
|
| 166 |
+
latency: 'medium',
|
| 167 |
+
capabilities: ['reasoning', 'code', 'analysis'],
|
| 168 |
+
tagline: 'Battle-tested coding and reasoning',
|
| 169 |
+
docsUrl: 'https://www.anthropic.com/',
|
| 170 |
+
},
|
| 171 |
+
'anthropic/claude-opus-4-20250514': {
|
| 172 |
+
id: 'anthropic/claude-opus-4-20250514',
|
| 173 |
+
name: 'Claude Opus 4',
|
| 174 |
+
provider: 'Anthropic',
|
| 175 |
+
tier: 1,
|
| 176 |
+
tierName: 'Power',
|
| 177 |
+
inputPricePerM: 15.00,
|
| 178 |
+
outputPricePerM: 75.00,
|
| 179 |
+
contextWindow: 200_000,
|
| 180 |
+
latency: 'slow',
|
| 181 |
+
capabilities: ['reasoning', 'code', 'analysis', 'long-context'],
|
| 182 |
+
tagline: 'Anthropic\'s most capable model',
|
| 183 |
+
docsUrl: 'https://www.anthropic.com/',
|
| 184 |
+
},
|
| 185 |
+
'anthropic/claude-code': {
|
| 186 |
+
id: 'anthropic/claude-code',
|
| 187 |
+
name: 'Claude Code',
|
| 188 |
+
provider: 'Anthropic',
|
| 189 |
+
tier: 1,
|
| 190 |
+
tierName: 'Power',
|
| 191 |
+
inputPricePerM: 15.00,
|
| 192 |
+
outputPricePerM: 75.00,
|
| 193 |
+
contextWindow: 200_000,
|
| 194 |
+
latency: 'slow',
|
| 195 |
+
capabilities: ['agentic', 'code', 'architecture', 'full-reasoning'],
|
| 196 |
+
tagline: 'Agentic coding — runs in your codebase',
|
| 197 |
+
docsUrl: 'https://www.anthropic.com/',
|
| 198 |
+
},
|
| 199 |
+
'anthropic/claude-3.5': {
|
| 200 |
+
id: 'anthropic/claude-3.5',
|
| 201 |
+
name: 'Claude 3.5',
|
| 202 |
+
provider: 'Anthropic',
|
| 203 |
+
tier: 4,
|
| 204 |
+
tierName: 'Vision',
|
| 205 |
+
inputPricePerM: 3.00,
|
| 206 |
+
outputPricePerM: 15.00,
|
| 207 |
+
contextWindow: 200_000,
|
| 208 |
+
latency: 'medium',
|
| 209 |
+
capabilities: ['vision', 'code', 'analysis'],
|
| 210 |
+
tagline: 'Anthropic\'s strongest vision + document understanding',
|
| 211 |
+
docsUrl: 'https://www.anthropic.com/',
|
| 212 |
+
},
|
| 213 |
+
};
|
loomlens-openclaw-plugin.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — OpenClaw Plugin Integration
|
| 3 |
+
* Phase 3: Wire into OpenClaw's hook system
|
| 4 |
+
*
|
| 5 |
+
* This plugin:
|
| 6 |
+
* 1. Listens for `before_model_resolve` — intercepts model selection
|
| 7 |
+
* 2. Listens for `before_prompt_build` — can modify prompt context
|
| 8 |
+
* 3. Exposes a `/loomlens` command for manual estimation
|
| 9 |
+
* 4. Provides HTTP route for the panel to query estimates
|
| 10 |
+
*
|
| 11 |
+
* Install: copy this dir to ~/.openclaw/plugins/loomlens/
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { registerPlugin, HookHandler } from 'openclaw/plugin-sdk';
|
| 15 |
+
|
| 16 |
+
// ─── Plugin Manifest ────────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
export const manifest = {
|
| 19 |
+
id: 'loomlens-live',
|
| 20 |
+
name: 'LoomLens Live',
|
| 21 |
+
description: 'Pre-flight cost intelligence — see model costs before you spend',
|
| 22 |
+
version: '1.0.0',
|
| 23 |
+
author: 'Aster Vale / AEHS',
|
| 24 |
+
events: [
|
| 25 |
+
'before_model_resolve', // intercept + override model selection
|
| 26 |
+
'before_prompt_build', // read prompt context before agent reply
|
| 27 |
+
],
|
| 28 |
+
commands: ['/loomlens'],
|
| 29 |
+
routes: [
|
| 30 |
+
{
|
| 31 |
+
method: 'GET',
|
| 32 |
+
path: '/loomlens/estimate',
|
| 33 |
+
description: 'Returns cost estimates for a prompt (for panel use)',
|
| 34 |
+
},
|
| 35 |
+
],
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
// ─── Hook Handlers ────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* before_model_resolve
|
| 42 |
+
* Fires just before the model is selected for a prompt.
|
| 43 |
+
* Return a different modelId in event.model to override.
|
| 44 |
+
*/
|
| 45 |
+
const handleBeforeModelResolve: HookHandler = async (event) => {
|
| 46 |
+
// event.context contains: sessionKey, messages, preferredModel, modelHint, cfg
|
| 47 |
+
// If loomlens has a model override pending for this session, apply it here.
|
| 48 |
+
const override = await getPendingModelOverride(event.context.sessionKey);
|
| 49 |
+
if (override) {
|
| 50 |
+
event.model = override;
|
| 51 |
+
}
|
| 52 |
+
return event;
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* before_prompt_build
|
| 57 |
+
* Fires after preprocessing, before the prompt is built for the model.
|
| 58 |
+
* Use this to read the prompt content for loomlens estimation.
|
| 59 |
+
*/
|
| 60 |
+
const handleBeforePromptBuild: HookHandler = async (event) => {
|
| 61 |
+
// event.context.bodyForAgent = the final enriched prompt body
|
| 62 |
+
// We could emit this to connected LoomLens panels via SSE or broadcast.
|
| 63 |
+
// For now, just store as pending for the estimation endpoint.
|
| 64 |
+
const sessionKey = event.context.sessionKey;
|
| 65 |
+
const body = event.context.bodyForAgent;
|
| 66 |
+
if (sessionKey && body) {
|
| 67 |
+
await setPendingPrompt(sessionKey, body);
|
| 68 |
+
}
|
| 69 |
+
return event;
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// ─── Session State ─────────────────────────────────────────────────────────────
|
| 73 |
+
// In-memory store for pending prompts / model overrides (per session)
|
| 74 |
+
// In production, use Redis or the Signal Loom session store.
|
| 75 |
+
|
| 76 |
+
const pendingPrompts = new Map<string, string>();
|
| 77 |
+
const pendingOverrides = new Map<string, string>();
|
| 78 |
+
|
| 79 |
+
async function setPendingPrompt(sessionKey: string, prompt: string): Promise<void> {
|
| 80 |
+
pendingPrompts.set(sessionKey, prompt);
|
| 81 |
+
// Expire after 60s
|
| 82 |
+
setTimeout(() => pendingPrompts.delete(sessionKey), 60_000);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async function getPendingPrompt(sessionKey: string): Promise<string | undefined> {
|
| 86 |
+
return pendingPrompts.get(sessionKey);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function setPendingModelOverride(sessionKey: string, modelId: string): Promise<void> {
|
| 90 |
+
pendingOverrides.set(sessionKey, modelId);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async function getPendingModelOverride(sessionKey: string): Promise<string | undefined> {
|
| 94 |
+
return pendingOverrides.get(sessionKey);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// ─── Plugin Registration ──────────────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
export default registerPlugin(manifest.id, (api) => {
|
| 100 |
+
api.registerHook('before_model_resolve', handleBeforeModelResolve);
|
| 101 |
+
api.registerHook('before_prompt_build', handleBeforePromptBuild);
|
| 102 |
+
|
| 103 |
+
api.registerCommand('/loomlens', async (event) => {
|
| 104 |
+
// Manual trigger: estimate the last prompt in this session
|
| 105 |
+
const sessionKey = event.sessionKey;
|
| 106 |
+
const prompt = await getPendingPrompt(sessionKey) ?? '';
|
| 107 |
+
if (!prompt) {
|
| 108 |
+
event.messages.push('LoomLens: No pending prompt to estimate. Type a message first.');
|
| 109 |
+
return event;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Import the engine dynamically (avoid circular deps)
|
| 113 |
+
const { estimatePrompt } = await import('../signal-loom-loomlens/loomlens-engine');
|
| 114 |
+
const result = estimatePrompt(prompt);
|
| 115 |
+
|
| 116 |
+
const lines = [
|
| 117 |
+
`✦ LoomLens Live Estimate`,
|
| 118 |
+
``,
|
| 119 |
+
`Task: ${result.taskType} (~${Math.round(result.taskConfidence * 100)}% confidence)`,
|
| 120 |
+
`Tokens: ~${result.inputTokens} in / ~${result.outputTokens} out`,
|
| 121 |
+
``,
|
| 122 |
+
`Top alternatives:`,
|
| 123 |
+
...result.estimates.slice(0, 4).map(
|
| 124 |
+
(m, i) => ` ${i + 1}. ${m.modelName} — $${m.estimatedCost.toFixed(4)} [${m.fitBadge}]`
|
| 125 |
+
),
|
| 126 |
+
``,
|
| 127 |
+
result.tip ? `💡 ${result.tip}` : '',
|
| 128 |
+
].filter(Boolean);
|
| 129 |
+
|
| 130 |
+
event.messages.push(lines.join('\n'));
|
| 131 |
+
return event;
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
api.registerRoute('GET', '/loomlens/estimate', async (req, res) => {
|
| 135 |
+
// Query param: ?prompt=...
|
| 136 |
+
const prompt = req.query?.prompt as string;
|
| 137 |
+
if (!prompt) {
|
| 138 |
+
res.status(400).json({ error: 'prompt query param required' });
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const { estimatePrompt } = await import('../signal-loom-loomlens/loomlens-engine');
|
| 143 |
+
const result = estimatePrompt(prompt);
|
| 144 |
+
res.json(result);
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
// Expose setter so the panel can push model overrides
|
| 148 |
+
api.registerRoute('POST', '/loomlens/override', async (req, res) => {
|
| 149 |
+
const { sessionKey, modelId } = req.body ?? {};
|
| 150 |
+
if (!sessionKey || !modelId) {
|
| 151 |
+
res.status(400).json({ error: 'sessionKey and modelId required' });
|
| 152 |
+
return;
|
| 153 |
+
}
|
| 154 |
+
await setPendingModelOverride(sessionKey, modelId);
|
| 155 |
+
res.json({ ok: true, modelId });
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
console.log('[LoomLens Live] Plugin loaded — pre-flight cost intelligence active');
|
| 159 |
+
return manifest;
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
// ─── Notes ────────────────────────────────────────────────────────────────────
|
| 163 |
+
//
|
| 164 |
+
// HOOK TIMING FLOW (from OpenClaw docs):
|
| 165 |
+
//
|
| 166 |
+
// message:received
|
| 167 |
+
// → message:preprocessed (body enriched with media/link data)
|
| 168 |
+
// → before_prompt_build ← we can read prompt here
|
| 169 |
+
// → before_model_resolve ← we can override model here
|
| 170 |
+
// → before_agent_reply
|
| 171 |
+
// → [model inference]
|
| 172 |
+
// → after_agent_reply
|
| 173 |
+
// → message:sent
|
| 174 |
+
//
|
| 175 |
+
// PANEL ↔ PLUGIN COMMUNICATION (for pre-flight panel):
|
| 176 |
+
//
|
| 177 |
+
// The React panel (loomlens-panel.tsx) needs to:
|
| 178 |
+
// 1. Get the current prompt text (from OpenClaw input state)
|
| 179 |
+
// 2. Call estimatePrompt() locally (no API needed — Phase 1 engine)
|
| 180 |
+
// 3. When user selects a model, call POST /loomlens/override
|
| 181 |
+
// to set a pending override for the session
|
| 182 |
+
// 4. The before_model_resolve hook picks up the override
|
| 183 |
+
// and applies it to event.model
|
| 184 |
+
//
|
| 185 |
+
// For step 1 (reading the prompt), the panel has two options:
|
| 186 |
+
// a. Read directly from DOM (textarea value) — simplest, works today
|
| 187 |
+
// b. Use window.postMessage('openclaw_prompt', { text }) event from OpenClaw
|
| 188 |
+
// (requires OpenClaw to emit this — not confirmed built-in yet)
|
| 189 |
+
//
|
| 190 |
+
// The existing panel.html uses window.addEventListener('message', handler)
|
| 191 |
+
// to receive openclaw_prompt events. This is the expected integration path.
|
| 192 |
+
// If OpenClaw doesn't emit openclaw_prompt natively yet, we can add a small
|
| 193 |
+
// workspace hook that emits it via postMessage to the parent window.
|
| 194 |
+
//
|
loomlens-panel.tsx
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoomLens Live — Pre-Flight React Panel
|
| 3 |
+
* Phase 2: Model selection UI
|
| 4 |
+
*
|
| 5 |
+
* Shows cost estimates and alternatives before prompt dispatch.
|
| 6 |
+
* Designed to mount in OpenClaw's prompt input area.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
| 10 |
+
import { estimatePrompt, LoomLensResult, TaskType } from './loomlens-engine';
|
| 11 |
+
import { getAllModels, ModelInfo } from './loomlens-models';
|
| 12 |
+
|
| 13 |
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
interface LoomLensPanelProps {
|
| 16 |
+
/** Current prompt text */
|
| 17 |
+
prompt: string;
|
| 18 |
+
/** Currently selected model ID (from OpenClaw context) */
|
| 19 |
+
selectedModel?: string;
|
| 20 |
+
/** Called when user selects a different model */
|
| 21 |
+
onModelChange?: (modelId: string) => void;
|
| 22 |
+
/** Called when user clicks "Run Prompt" */
|
| 23 |
+
onRun?: (modelId: string) => void;
|
| 24 |
+
/** Debounce ms before estimating (default 300) */
|
| 25 |
+
debounceMs?: number;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface ModelCardProps {
|
| 29 |
+
model: LoomLensResult['estimates'][0];
|
| 30 |
+
isSelected: boolean;
|
| 31 |
+
isRecommended: boolean;
|
| 32 |
+
onSelect: (id: string) => void;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
function formatCost(n: number): string {
|
| 38 |
+
if (n >= 1) return '$' + n.toFixed(2);
|
| 39 |
+
if (n >= 0.01) return '$' + n.toFixed(3);
|
| 40 |
+
return '$' + n.toFixed(4);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function taskLabel(type: TaskType): string {
|
| 44 |
+
const labels: Record<TaskType, string> = {
|
| 45 |
+
simple_qa: 'Quick Q&A',
|
| 46 |
+
summary: 'Summary',
|
| 47 |
+
code_edit: 'Code Edit',
|
| 48 |
+
code_write: 'Code Write',
|
| 49 |
+
complex_analysis: 'Analysis',
|
| 50 |
+
vision: 'Vision / Doc',
|
| 51 |
+
conversation: 'Conversation',
|
| 52 |
+
unknown: 'General',
|
| 53 |
+
};
|
| 54 |
+
return labels[type] ?? type;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function taskColor(type: TaskType): string {
|
| 58 |
+
const colors: Record<TaskType, string> = {
|
| 59 |
+
simple_qa: '#10b981',
|
| 60 |
+
summary: '#06b6d4',
|
| 61 |
+
code_edit: '#f59e0b',
|
| 62 |
+
code_write: '#8b5cf6',
|
| 63 |
+
complex_analysis: '#3b82f6',
|
| 64 |
+
vision: '#ec4899',
|
| 65 |
+
conversation: '#6b7280',
|
| 66 |
+
unknown: '#9ca3af',
|
| 67 |
+
};
|
| 68 |
+
return colors[type] ?? '#9ca3af';
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// ─── Model Card ───────────────────────────────────────────────────────────────
|
| 72 |
+
|
| 73 |
+
const ModelCard: React.FC<ModelCardProps> = ({ model, isSelected, isRecommended, onSelect }) => {
|
| 74 |
+
const barWidth = Math.max(4, model.relativeCostPct);
|
| 75 |
+
|
| 76 |
+
return (
|
| 77 |
+
<div
|
| 78 |
+
className={`ll-card ${isSelected ? 'll-card--selected' : ''} ${isRecommended ? 'll-card--recommended' : ''}`}
|
| 79 |
+
onClick={() => onSelect(model.modelId)}
|
| 80 |
+
role="button"
|
| 81 |
+
tabIndex={0}
|
| 82 |
+
onKeyDown={e => e.key === 'Enter' && onSelect(model.modelId)}
|
| 83 |
+
>
|
| 84 |
+
<div className="ll-card__header">
|
| 85 |
+
<div className="ll-card__radio">
|
| 86 |
+
{isSelected ? (
|
| 87 |
+
<div className="ll-card__radio-dot" />
|
| 88 |
+
) : (
|
| 89 |
+
<div className="ll-card__radio-empty" />
|
| 90 |
+
)}
|
| 91 |
+
</div>
|
| 92 |
+
<div className="ll-card__name">{model.modelName}</div>
|
| 93 |
+
<div className="ll-card__tier">{model.tierName}</div>
|
| 94 |
+
<div className="ll-card__cost">{formatCost(model.estimatedCost)}</div>
|
| 95 |
+
<div className="ll-card__bar-wrap">
|
| 96 |
+
<div className="ll-card__bar" style={{ width: `${barWidth}%` }} />
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="ll-card__insight">{model.insight}</div>
|
| 101 |
+
|
| 102 |
+
<div className="ll-card__badges">
|
| 103 |
+
{isSelected && <span className="ll-badge ll-badge--selected">✓ Selected</span>}
|
| 104 |
+
{isRecommended && !isSelected && <span className="ll-badge ll-badge--recommended">💡 Recommended</span>}
|
| 105 |
+
{model.fitBadge === 'overkill' && !isRecommended && (
|
| 106 |
+
<span className="ll-badge ll-badge--overkill">⚠️ Overkill</span>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
// ─── Main Panel ───────────────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
export const LoomLensPanel: React.FC<LoomLensPanelProps> = ({
|
| 116 |
+
prompt,
|
| 117 |
+
selectedModel = 'minimax/MiniMax-M2',
|
| 118 |
+
onModelChange,
|
| 119 |
+
onRun,
|
| 120 |
+
debounceMs = 300,
|
| 121 |
+
}) => {
|
| 122 |
+
const [active, setActive] = useState(false);
|
| 123 |
+
const [result, setResult] = useState<LoomLensResult | null>(null);
|
| 124 |
+
const [selected, setSelected] = useState(selectedModel);
|
| 125 |
+
const [running, setRunning] = useState(false);
|
| 126 |
+
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
| 127 |
+
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
| 128 |
+
|
| 129 |
+
// Sync selected model with prop
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
setSelected(selectedModel);
|
| 132 |
+
}, [selectedModel]);
|
| 133 |
+
|
| 134 |
+
// Debounced estimation
|
| 135 |
+
useEffect(() => {
|
| 136 |
+
if (!active || prompt.trim().length < 10) {
|
| 137 |
+
setResult(null);
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 141 |
+
debounceRef.current = setTimeout(() => {
|
| 142 |
+
try {
|
| 143 |
+
const res = estimatePrompt(prompt, selected);
|
| 144 |
+
setResult(res);
|
| 145 |
+
} catch (e) {
|
| 146 |
+
console.error('[LoomLens] estimate error:', e);
|
| 147 |
+
}
|
| 148 |
+
}, debounceMs);
|
| 149 |
+
return () => {
|
| 150 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 151 |
+
};
|
| 152 |
+
}, [prompt, active, debounceMs, selected]);
|
| 153 |
+
|
| 154 |
+
const handleSelect = useCallback((modelId: string) => {
|
| 155 |
+
setSelected(modelId);
|
| 156 |
+
onModelChange?.(modelId);
|
| 157 |
+
}, [onModelChange]);
|
| 158 |
+
|
| 159 |
+
const handleRun = useCallback(() => {
|
| 160 |
+
setRunning(true);
|
| 161 |
+
onRun?.(selected);
|
| 162 |
+
setTimeout(() => setRunning(false), 500);
|
| 163 |
+
}, [selected, onRun]);
|
| 164 |
+
|
| 165 |
+
const selectedResult = result?.estimates.find(e => e.modelId === selected);
|
| 166 |
+
const recommended = result?.recommended;
|
| 167 |
+
|
| 168 |
+
return (
|
| 169 |
+
<div className="ll-root">
|
| 170 |
+
{/* Toggle */}
|
| 171 |
+
<label className="ll-toggle">
|
| 172 |
+
<input
|
| 173 |
+
type="checkbox"
|
| 174 |
+
checked={active}
|
| 175 |
+
onChange={e => setActive(e.target.checked)}
|
| 176 |
+
/>
|
| 177 |
+
<span>LoomLens Live ✦</span>
|
| 178 |
+
</label>
|
| 179 |
+
|
| 180 |
+
{/* Panel */}
|
| 181 |
+
{active && (
|
| 182 |
+
<div className="ll-panel">
|
| 183 |
+
<div className="ll-panel__header">
|
| 184 |
+
<div className="ll-panel__title">LOOMLENS LIVE — PRE-FLIGHT</div>
|
| 185 |
+
{result && (
|
| 186 |
+
<div className="ll-panel__task-badge" style={{ borderColor: taskColor(result.taskType) }}>
|
| 187 |
+
{taskLabel(result.taskType)}
|
| 188 |
+
{result.taskConfidence < 0.5 && <span className="ll-panel__uncertainty">? {Math.round(result.taskConfidence * 100)}%</span>}
|
| 189 |
+
</div>
|
| 190 |
+
)}
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
{prompt.trim().length < 10 ? (
|
| 194 |
+
<div className="ll-panel__empty">
|
| 195 |
+
Enter more to estimate…
|
| 196 |
+
</div>
|
| 197 |
+
) : !result ? (
|
| 198 |
+
<div className="ll-panel__empty">
|
| 199 |
+
Estimating…
|
| 200 |
+
</div>
|
| 201 |
+
) : (
|
| 202 |
+
<>
|
| 203 |
+
{/* Selected model summary */}
|
| 204 |
+
<div className="ll-panel__selected">
|
| 205 |
+
<div className="ll-panel__sel-model">{selectedResult?.modelName}</div>
|
| 206 |
+
<div className="ll-panel__sel-cost">
|
| 207 |
+
~{formatCost(selectedResult?.estimatedCost ?? 0)}
|
| 208 |
+
<span className="ll-panel__sel-tokens">
|
| 209 |
+
{' '}({result.inputTokens.toLocaleString()} in / ~{result.outputTokens} out)
|
| 210 |
+
</span>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
{/* Alternatives */}
|
| 215 |
+
<div className="ll-panel__section-label">
|
| 216 |
+
Alternatives <span className="ll-panel__count">(ranked by fit)</span>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div className="ll-cards">
|
| 220 |
+
{result.estimates.slice(0, 5).map(model => (
|
| 221 |
+
<ModelCard
|
| 222 |
+
key={model.modelId}
|
| 223 |
+
model={model}
|
| 224 |
+
isSelected={model.modelId === selected}
|
| 225 |
+
isRecommended={model.modelId === recommended?.modelId}
|
| 226 |
+
onSelect={handleSelect}
|
| 227 |
+
/>
|
| 228 |
+
))}
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
{/* Tip */}
|
| 232 |
+
{result.tip && (
|
| 233 |
+
<div className="ll-tip">
|
| 234 |
+
💡 {result.tip}
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
|
| 238 |
+
{/* Run */}
|
| 239 |
+
<div className="ll-panel__run">
|
| 240 |
+
<button
|
| 241 |
+
className="ll-btn ll-btn--run"
|
| 242 |
+
onClick={handleRun}
|
| 243 |
+
disabled={running}
|
| 244 |
+
>
|
| 245 |
+
{running ? 'Running…' : `Run on ${selectedResult?.modelName} →`}
|
| 246 |
+
</button>
|
| 247 |
+
</div>
|
| 248 |
+
</>
|
| 249 |
+
)}
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
|
| 253 |
+
<style>{`
|
| 254 |
+
.ll-root {
|
| 255 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 256 |
+
font-size: 12px;
|
| 257 |
+
color: #e8edf5;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* Toggle */
|
| 261 |
+
.ll-toggle {
|
| 262 |
+
display: flex;
|
| 263 |
+
align-items: center;
|
| 264 |
+
gap: 6px;
|
| 265 |
+
cursor: pointer;
|
| 266 |
+
user-select: none;
|
| 267 |
+
color: #8899bb;
|
| 268 |
+
font-size: 11px;
|
| 269 |
+
font-weight: 600;
|
| 270 |
+
letter-spacing: 0.3px;
|
| 271 |
+
}
|
| 272 |
+
.ll-toggle input[type=checkbox] {
|
| 273 |
+
accent-color: #8b5cf6;
|
| 274 |
+
width: 13px;
|
| 275 |
+
height: 13px;
|
| 276 |
+
}
|
| 277 |
+
.ll-toggle:hover { color: #a78bfa; }
|
| 278 |
+
|
| 279 |
+
/* Panel */
|
| 280 |
+
.ll-panel {
|
| 281 |
+
margin-top: 8px;
|
| 282 |
+
background: #111827;
|
| 283 |
+
border: 1px solid #1e2d4a;
|
| 284 |
+
border-radius: 8px;
|
| 285 |
+
overflow: hidden;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.ll-panel__header {
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: center;
|
| 291 |
+
justify-content: space-between;
|
| 292 |
+
padding: 8px 12px;
|
| 293 |
+
background: #1a2235;
|
| 294 |
+
border-bottom: 1px solid #1e2d4a;
|
| 295 |
+
}
|
| 296 |
+
.ll-panel__title {
|
| 297 |
+
font-size: 10px;
|
| 298 |
+
font-weight: 800;
|
| 299 |
+
letter-spacing: 1px;
|
| 300 |
+
color: #8899bb;
|
| 301 |
+
}
|
| 302 |
+
.ll-panel__task-badge {
|
| 303 |
+
font-size: 10px;
|
| 304 |
+
font-weight: 600;
|
| 305 |
+
padding: 2px 7px;
|
| 306 |
+
border-radius: 4px;
|
| 307 |
+
border: 1px solid;
|
| 308 |
+
color: #e8edf5;
|
| 309 |
+
display: flex;
|
| 310 |
+
align-items: center;
|
| 311 |
+
gap: 4px;
|
| 312 |
+
}
|
| 313 |
+
.ll-panel__uncertainty { opacity: 0.6; }
|
| 314 |
+
|
| 315 |
+
.ll-panel__empty {
|
| 316 |
+
padding: 20px;
|
| 317 |
+
text-align: center;
|
| 318 |
+
color: #4a6a9a;
|
| 319 |
+
font-size: 11px;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* Selected summary */
|
| 323 |
+
.ll-panel__selected {
|
| 324 |
+
padding: 10px 12px;
|
| 325 |
+
background: #0a0e1a;
|
| 326 |
+
border-bottom: 1px solid #1e2d4a;
|
| 327 |
+
}
|
| 328 |
+
.ll-panel__sel-model { font-size: 14px; font-weight: 700; color: #a78bfa; }
|
| 329 |
+
.ll-panel__sel-cost { font-size: 16px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: #10b981; }
|
| 330 |
+
.ll-panel__sel-tokens { font-size: 9px; color: #4a6a9a; font-weight: 400; }
|
| 331 |
+
|
| 332 |
+
/* Section label */
|
| 333 |
+
.ll-panel__section-label {
|
| 334 |
+
padding: 7px 12px 4px;
|
| 335 |
+
font-size: 10px;
|
| 336 |
+
font-weight: 700;
|
| 337 |
+
letter-spacing: 0.5px;
|
| 338 |
+
color: #4a6a9a;
|
| 339 |
+
text-transform: uppercase;
|
| 340 |
+
}
|
| 341 |
+
.ll-panel__count { font-weight: 400; text-transform: none; letter-spacing: 0; }
|
| 342 |
+
|
| 343 |
+
/* Cards */
|
| 344 |
+
.ll-cards { padding: 0 8px 4px; }
|
| 345 |
+
|
| 346 |
+
.ll-card {
|
| 347 |
+
padding: 7px 8px;
|
| 348 |
+
border-radius: 6px;
|
| 349 |
+
margin-bottom: 3px;
|
| 350 |
+
cursor: pointer;
|
| 351 |
+
border: 1px solid transparent;
|
| 352 |
+
transition: background 0.12s, border-color 0.12s;
|
| 353 |
+
background: #1a2235;
|
| 354 |
+
}
|
| 355 |
+
.ll-card:hover { background: #1f2d45; }
|
| 356 |
+
.ll-card--selected {
|
| 357 |
+
border-color: #8b5cf6;
|
| 358 |
+
background: rgba(139,92,246,0.08);
|
| 359 |
+
}
|
| 360 |
+
.ll-card--recommended { border-color: rgba(16,185,129,0.3); }
|
| 361 |
+
|
| 362 |
+
.ll-card__header {
|
| 363 |
+
display: flex;
|
| 364 |
+
align-items: center;
|
| 365 |
+
gap: 6px;
|
| 366 |
+
}
|
| 367 |
+
.ll-card__radio {
|
| 368 |
+
width: 12px;
|
| 369 |
+
height: 12px;
|
| 370 |
+
flex-shrink: 0;
|
| 371 |
+
display: flex;
|
| 372 |
+
align-items: center;
|
| 373 |
+
justify-content: center;
|
| 374 |
+
}
|
| 375 |
+
.ll-card__radio-dot {
|
| 376 |
+
width: 10px;
|
| 377 |
+
height: 10px;
|
| 378 |
+
border-radius: 50%;
|
| 379 |
+
background: #8b5cf6;
|
| 380 |
+
box-shadow: 0 0 4px rgba(139,92,246,0.6);
|
| 381 |
+
}
|
| 382 |
+
.ll-card__radio-empty {
|
| 383 |
+
width: 10px;
|
| 384 |
+
height: 10px;
|
| 385 |
+
border-radius: 50%;
|
| 386 |
+
border: 1.5px solid #4a6a9a;
|
| 387 |
+
}
|
| 388 |
+
.ll-card__name {
|
| 389 |
+
font-size: 12px;
|
| 390 |
+
font-weight: 600;
|
| 391 |
+
color: #e8edf5;
|
| 392 |
+
flex: 1;
|
| 393 |
+
min-width: 0;
|
| 394 |
+
overflow: hidden;
|
| 395 |
+
text-overflow: ellipsis;
|
| 396 |
+
white-space: nowrap;
|
| 397 |
+
}
|
| 398 |
+
.ll-card--selected .ll-card__name { color: #a78bfa; }
|
| 399 |
+
.ll-card__tier {
|
| 400 |
+
font-size: 9px;
|
| 401 |
+
color: #4a6a9a;
|
| 402 |
+
background: rgba(255,255,255,0.04);
|
| 403 |
+
padding: 1px 5px;
|
| 404 |
+
border-radius: 3px;
|
| 405 |
+
flex-shrink: 0;
|
| 406 |
+
}
|
| 407 |
+
.ll-card__cost {
|
| 408 |
+
font-size: 11px;
|
| 409 |
+
font-family: 'JetBrains Mono', monospace;
|
| 410 |
+
color: #8899bb;
|
| 411 |
+
flex-shrink: 0;
|
| 412 |
+
}
|
| 413 |
+
.ll-card__bar-wrap {
|
| 414 |
+
width: 50px;
|
| 415 |
+
height: 4px;
|
| 416 |
+
background: rgba(255,255,255,0.06);
|
| 417 |
+
border-radius: 99px;
|
| 418 |
+
overflow: hidden;
|
| 419 |
+
flex-shrink: 0;
|
| 420 |
+
}
|
| 421 |
+
.ll-card__bar {
|
| 422 |
+
height: 100%;
|
| 423 |
+
background: #4a6a9a;
|
| 424 |
+
border-radius: 99px;
|
| 425 |
+
transition: width 0.3s;
|
| 426 |
+
}
|
| 427 |
+
.ll-card--selected .ll-card__bar { background: #8b5cf6; }
|
| 428 |
+
.ll-card--recommended .ll-card__bar { background: #10b981; }
|
| 429 |
+
|
| 430 |
+
.ll-card__insight {
|
| 431 |
+
font-size: 10px;
|
| 432 |
+
color: #6b7280;
|
| 433 |
+
margin: 3px 0 3px 18px;
|
| 434 |
+
line-height: 1.4;
|
| 435 |
+
}
|
| 436 |
+
.ll-card__badges {
|
| 437 |
+
display: flex;
|
| 438 |
+
gap: 4px;
|
| 439 |
+
margin-left: 18px;
|
| 440 |
+
margin-top: 2px;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.ll-badge {
|
| 444 |
+
font-size: 9px;
|
| 445 |
+
font-weight: 700;
|
| 446 |
+
padding: 1px 5px;
|
| 447 |
+
border-radius: 3px;
|
| 448 |
+
}
|
| 449 |
+
.ll-badge--selected { background: rgba(139,92,246,0.15); color: #a78bfa; }
|
| 450 |
+
.ll-badge--recommended { background: rgba(16,185,129,0.12); color: #10b981; }
|
| 451 |
+
.ll-badge--overkill { background: rgba(245,158,11,0.12); color: #f59e0b; }
|
| 452 |
+
|
| 453 |
+
/* Tip */
|
| 454 |
+
.ll-tip {
|
| 455 |
+
margin: 6px 12px;
|
| 456 |
+
padding: 6px 10px;
|
| 457 |
+
background: rgba(16,185,129,0.07);
|
| 458 |
+
border: 1px solid rgba(16,185,129,0.15);
|
| 459 |
+
border-radius: 5px;
|
| 460 |
+
font-size: 10px;
|
| 461 |
+
color: #10b981;
|
| 462 |
+
line-height: 1.5;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/* Run button */
|
| 466 |
+
.ll-panel__run {
|
| 467 |
+
padding: 8px 12px 10px;
|
| 468 |
+
}
|
| 469 |
+
.ll-btn--run {
|
| 470 |
+
width: 100%;
|
| 471 |
+
background: #8b5cf6;
|
| 472 |
+
color: white;
|
| 473 |
+
border: none;
|
| 474 |
+
border-radius: 6px;
|
| 475 |
+
padding: 9px 16px;
|
| 476 |
+
font-size: 12px;
|
| 477 |
+
font-weight: 700;
|
| 478 |
+
cursor: pointer;
|
| 479 |
+
transition: background 0.15s, opacity 0.15s;
|
| 480 |
+
font-family: 'Inter', sans-serif;
|
| 481 |
+
}
|
| 482 |
+
.ll-btn--run:hover { background: #7c3aed; }
|
| 483 |
+
.ll-btn--run:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 484 |
+
`}</style>
|
| 485 |
+
</div>
|
| 486 |
+
);
|
| 487 |
+
};
|
| 488 |
+
|
| 489 |
+
export default LoomLensPanel;
|
loomlens-sidebar.html
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>LoomLens Live ✦</title>
|
| 6 |
+
<style>
|
| 7 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 8 |
+
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #0a0e1a;
|
| 11 |
+
--surface: #111827;
|
| 12 |
+
--surface2: #1a2235;
|
| 13 |
+
--border: #1e2d4a;
|
| 14 |
+
--accent: #8b5cf6;
|
| 15 |
+
--accent-bright: #a78bfa;
|
| 16 |
+
--green: #10b981;
|
| 17 |
+
--amber: #f59e0b;
|
| 18 |
+
--red: #ef4444;
|
| 19 |
+
--text: #e8edf5;
|
| 20 |
+
--text-dim: #8899bb;
|
| 21 |
+
--text-muted: #4a6a9a;
|
| 22 |
+
--radius: 8px;
|
| 23 |
+
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 24 |
+
--mono: 'JetBrains Mono', 'Fira Code', monospace;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: var(--font);
|
| 29 |
+
background: transparent;
|
| 30 |
+
color: var(--text);
|
| 31 |
+
font-size: 12px;
|
| 32 |
+
line-height: 1.4;
|
| 33 |
+
width: 320px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* ── Root Panel ── */
|
| 37 |
+
.ll-panel {
|
| 38 |
+
background: var(--surface);
|
| 39 |
+
border: 1px solid var(--border);
|
| 40 |
+
border-radius: var(--radius);
|
| 41 |
+
overflow: hidden;
|
| 42 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 43 |
+
display: flex;
|
| 44 |
+
flex-direction: column;
|
| 45 |
+
max-height: 600px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* ── Header ── */
|
| 49 |
+
.ll-header {
|
| 50 |
+
display: flex;
|
| 51 |
+
align-items: center;
|
| 52 |
+
justify-content: space-between;
|
| 53 |
+
padding: 10px 12px;
|
| 54 |
+
background: var(--surface2);
|
| 55 |
+
border-bottom: 1px solid var(--border);
|
| 56 |
+
cursor: move;
|
| 57 |
+
user-select: none;
|
| 58 |
+
flex-shrink: 0;
|
| 59 |
+
}
|
| 60 |
+
.ll-header-left { display: flex; align-items: center; gap: 8px; }
|
| 61 |
+
.ll-dot {
|
| 62 |
+
width: 8px; height: 8px;
|
| 63 |
+
border-radius: 50%;
|
| 64 |
+
background: var(--green);
|
| 65 |
+
box-shadow: 0 0 8px var(--green);
|
| 66 |
+
animation: pulse 2s infinite;
|
| 67 |
+
flex-shrink: 0;
|
| 68 |
+
}
|
| 69 |
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
| 70 |
+
.ll-title { font-size: 13px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; }
|
| 71 |
+
.ll-badge {
|
| 72 |
+
font-size: 9px; font-weight: 800;
|
| 73 |
+
padding: 2px 7px; border-radius: 4px;
|
| 74 |
+
letter-spacing: 0.5px; flex-shrink: 0;
|
| 75 |
+
}
|
| 76 |
+
.ll-badge.free { background: rgba(16,185,129,0.15); color: var(--green); border: 1px solid rgba(16,185,129,0.25); }
|
| 77 |
+
.ll-badge.used { background: rgba(245,158,11,0.15); color: var(--amber); border: 1px solid rgba(245,158,11,0.25); }
|
| 78 |
+
.ll-badge.locked { background: rgba(255,255,255,0.06); color: var(--text-muted); border: 1px solid var(--border); }
|
| 79 |
+
.ll-badge.live { background: rgba(139,92,246,0.15); color: var(--accent-bright); border: 1px solid rgba(139,92,246,0.25); }
|
| 80 |
+
.ll-hdr-btns { display: flex; gap: 3px; }
|
| 81 |
+
.ll-hdr-btn {
|
| 82 |
+
background: transparent; border: none;
|
| 83 |
+
color: var(--text-muted); cursor: pointer;
|
| 84 |
+
font-size: 14px; padding: 2px 6px; border-radius: 3px;
|
| 85 |
+
line-height: 1; transition: all 0.15s;
|
| 86 |
+
}
|
| 87 |
+
.ll-hdr-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
| 88 |
+
|
| 89 |
+
/* ── Prompt Input ── */
|
| 90 |
+
.ll-input-area {
|
| 91 |
+
padding: 10px 12px;
|
| 92 |
+
background: var(--bg);
|
| 93 |
+
border-bottom: 1px solid var(--border);
|
| 94 |
+
flex-shrink: 0;
|
| 95 |
+
}
|
| 96 |
+
.ll-prompt-wrap {
|
| 97 |
+
position: relative;
|
| 98 |
+
border: 1.5px dashed var(--border);
|
| 99 |
+
border-radius: var(--radius);
|
| 100 |
+
transition: border-color 0.2s;
|
| 101 |
+
min-height: 72px;
|
| 102 |
+
}
|
| 103 |
+
.ll-prompt-wrap.drag-over { border-color: var(--accent); background: rgba(139,92,246,0.05); }
|
| 104 |
+
.ll-prompt-wrap.focused { border-color: var(--accent); border-style: solid; }
|
| 105 |
+
.ll-prompt-ta {
|
| 106 |
+
width: 100%; min-height: 70px;
|
| 107 |
+
background: transparent; border: none; outline: none;
|
| 108 |
+
color: var(--text); font-family: var(--font);
|
| 109 |
+
font-size: 11px; line-height: 1.5;
|
| 110 |
+
padding: 8px 10px; resize: vertical;
|
| 111 |
+
placeholder-color: var(--text-muted);
|
| 112 |
+
}
|
| 113 |
+
.ll-prompt-ta::placeholder { color: var(--text-muted); }
|
| 114 |
+
.ll-drop-hint {
|
| 115 |
+
position: absolute; top: 50%; left: 50%;
|
| 116 |
+
transform: translate(-50%, -50%);
|
| 117 |
+
text-align: center; pointer-events: none;
|
| 118 |
+
color: var(--text-muted); font-size: 11px;
|
| 119 |
+
line-height: 1.5;
|
| 120 |
+
}
|
| 121 |
+
.ll-drop-hint.hidden { display: none; }
|
| 122 |
+
|
| 123 |
+
/* ── Filters ── */
|
| 124 |
+
.ll-filters {
|
| 125 |
+
display: flex; gap: 6px; align-items: center;
|
| 126 |
+
padding: 7px 12px;
|
| 127 |
+
border-bottom: 1px solid var(--border);
|
| 128 |
+
background: var(--surface2);
|
| 129 |
+
flex-shrink: 0;
|
| 130 |
+
}
|
| 131 |
+
.ll-filter-label { font-size: 10px; color: var(--text-muted); font-weight: 600; letter-spacing: 0.5px; }
|
| 132 |
+
.ll-select {
|
| 133 |
+
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
| 134 |
+
border-radius: 5px; color: var(--text); font-family: var(--font);
|
| 135 |
+
font-size: 10px; padding: 4px 6px; cursor: pointer; outline: none;
|
| 136 |
+
}
|
| 137 |
+
.ll-select:focus { border-color: var(--accent); }
|
| 138 |
+
|
| 139 |
+
/* ── Results ── */
|
| 140 |
+
.ll-body {
|
| 141 |
+
overflow-y: auto;
|
| 142 |
+
flex: 1;
|
| 143 |
+
min-height: 0;
|
| 144 |
+
}
|
| 145 |
+
.ll-body::-webkit-scrollbar { width: 3px; }
|
| 146 |
+
.ll-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
|
| 147 |
+
|
| 148 |
+
.ll-cluster { margin-bottom: 4px; }
|
| 149 |
+
|
| 150 |
+
.ll-cluster-header {
|
| 151 |
+
padding: 5px 12px 3px;
|
| 152 |
+
font-size: 9px; font-weight: 800;
|
| 153 |
+
letter-spacing: 1px; color: var(--text-muted);
|
| 154 |
+
text-transform: uppercase;
|
| 155 |
+
background: var(--bg);
|
| 156 |
+
border-top: 1px solid var(--border);
|
| 157 |
+
border-bottom: 1px solid var(--border);
|
| 158 |
+
position: sticky; top: 0;
|
| 159 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 160 |
+
}
|
| 161 |
+
.ll-cluster-count { font-weight: 400; letter-spacing: 0; text-transform: none; }
|
| 162 |
+
|
| 163 |
+
.ll-card {
|
| 164 |
+
padding: 7px 12px;
|
| 165 |
+
cursor: pointer;
|
| 166 |
+
border-bottom: 1px solid rgba(30,45,74,0.5);
|
| 167 |
+
transition: background 0.1s;
|
| 168 |
+
display: flex; flex-direction: column; gap: 3px;
|
| 169 |
+
}
|
| 170 |
+
.ll-card:last-child { border-bottom: none; }
|
| 171 |
+
.ll-card:hover { background: rgba(255,255,255,0.02); }
|
| 172 |
+
.ll-card.selected { background: rgba(139,92,246,0.08); border-left: 2px solid var(--accent); padding-left: 10px; }
|
| 173 |
+
.ll-card.recommended { border-left: 2px solid var(--green); padding-left: 10px; }
|
| 174 |
+
|
| 175 |
+
.ll-card-top {
|
| 176 |
+
display: flex; align-items: center; gap: 6px;
|
| 177 |
+
}
|
| 178 |
+
.ll-radio {
|
| 179 |
+
width: 12px; height: 12px;
|
| 180 |
+
border-radius: 50%; flex-shrink: 0;
|
| 181 |
+
display: flex; align-items: center; justify-content: center;
|
| 182 |
+
}
|
| 183 |
+
.ll-card.selected .ll-radio {
|
| 184 |
+
background: var(--accent);
|
| 185 |
+
box-shadow: 0 0 6px rgba(139,92,246,0.6);
|
| 186 |
+
}
|
| 187 |
+
.ll-card:not(.selected) .ll-radio {
|
| 188 |
+
border: 1.5px solid var(--text-muted);
|
| 189 |
+
}
|
| 190 |
+
.ll-card-name {
|
| 191 |
+
font-size: 12px; font-weight: 600;
|
| 192 |
+
color: var(--text-dim); flex: 1; min-width: 0;
|
| 193 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 194 |
+
}
|
| 195 |
+
.ll-card.selected .ll-card-name { color: var(--accent-bright); }
|
| 196 |
+
.ll-card-cost {
|
| 197 |
+
font-size: 11px; font-family: var(--mono);
|
| 198 |
+
color: var(--text-dim); flex-shrink: 0;
|
| 199 |
+
}
|
| 200 |
+
.ll-card-speed {
|
| 201 |
+
font-size: 10px; color: var(--green); flex-shrink: 0;
|
| 202 |
+
display: flex; align-items: center; gap: 2px;
|
| 203 |
+
}
|
| 204 |
+
.ll-card-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
| 205 |
+
|
| 206 |
+
.ll-card-bar-row { display: flex; align-items: center; gap: 6px; padding-left: 18px; }
|
| 207 |
+
.ll-card-bar-wrap {
|
| 208 |
+
flex: 1; height: 3px; background: rgba(255,255,255,0.06);
|
| 209 |
+
border-radius: 99px; overflow: hidden;
|
| 210 |
+
}
|
| 211 |
+
.ll-card-bar { height: 100%; background: var(--text-muted); border-radius: 99px; transition: width 0.3s; }
|
| 212 |
+
.ll-card.selected .ll-card-bar { background: var(--accent); }
|
| 213 |
+
.ll-card.recommended .ll-card-bar { background: var(--green); }
|
| 214 |
+
|
| 215 |
+
.ll-card-insight {
|
| 216 |
+
font-size: 10px; color: var(--text-muted);
|
| 217 |
+
padding-left: 18px; line-height: 1.4;
|
| 218 |
+
}
|
| 219 |
+
.ll-card-badges { display: flex; gap: 4px; padding-left: 18px; flex-wrap: wrap; }
|
| 220 |
+
.ll-card-badge {
|
| 221 |
+
font-size: 9px; font-weight: 700; padding: 1px 5px;
|
| 222 |
+
border-radius: 3px;
|
| 223 |
+
}
|
| 224 |
+
.ll-card-badge.rec { background: rgba(16,185,129,0.15); color: var(--green); }
|
| 225 |
+
.ll-card-badge.over { background: rgba(245,158,11,0.12); color: var(--amber); }
|
| 226 |
+
.ll-card-badge.sel { background: rgba(139,92,246,0.15); color: var(--accent-bright); }
|
| 227 |
+
|
| 228 |
+
/* ── Tip ── */
|
| 229 |
+
.ll-tip {
|
| 230 |
+
margin: 6px 12px;
|
| 231 |
+
padding: 7px 10px;
|
| 232 |
+
background: rgba(16,185,129,0.07);
|
| 233 |
+
border: 1px solid rgba(16,185,129,0.15);
|
| 234 |
+
border-radius: 5px;
|
| 235 |
+
font-size: 10px; color: var(--green);
|
| 236 |
+
line-height: 1.5;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/* ── Footer ── */
|
| 240 |
+
.ll-footer {
|
| 241 |
+
padding: 10px 12px;
|
| 242 |
+
border-top: 1px solid var(--border);
|
| 243 |
+
background: var(--surface2);
|
| 244 |
+
display: flex; flex-direction: column; gap: 7px;
|
| 245 |
+
flex-shrink: 0;
|
| 246 |
+
}
|
| 247 |
+
.ll-quota-row {
|
| 248 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 249 |
+
font-size: 10px;
|
| 250 |
+
}
|
| 251 |
+
.ll-quota-label { color: var(--text-muted); }
|
| 252 |
+
.ll-quota-val { font-family: var(--mono); color: var(--text-dim); }
|
| 253 |
+
.ll-quota-val.warn { color: var(--amber); }
|
| 254 |
+
.ll-quota-val.ok { color: var(--green); }
|
| 255 |
+
|
| 256 |
+
.ll-key-row { display: flex; gap: 5px; align-items: stretch; }
|
| 257 |
+
.ll-key-input {
|
| 258 |
+
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
| 259 |
+
border-radius: 5px; padding: 6px 8px;
|
| 260 |
+
color: var(--text); font-family: var(--mono); font-size: 10px;
|
| 261 |
+
outline: none;
|
| 262 |
+
}
|
| 263 |
+
.ll-key-input:focus { border-color: var(--accent); }
|
| 264 |
+
.ll-key-input::placeholder { color: var(--text-muted); font-family: var(--font); }
|
| 265 |
+
.ll-btn {
|
| 266 |
+
font-family: var(--font); font-size: 10px; font-weight: 700;
|
| 267 |
+
padding: 6px 10px; border-radius: 5px; border: none;
|
| 268 |
+
cursor: pointer; transition: opacity 0.15s; white-space: nowrap;
|
| 269 |
+
}
|
| 270 |
+
.ll-btn:hover { opacity: 0.85; }
|
| 271 |
+
.ll-btn.primary { background: var(--accent); color: white; }
|
| 272 |
+
.ll-btn.ghost {
|
| 273 |
+
background: rgba(255,255,255,0.06); color: var(--text-dim);
|
| 274 |
+
border: 1px solid var(--border);
|
| 275 |
+
}
|
| 276 |
+
.ll-btn.danger { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.2); }
|
| 277 |
+
|
| 278 |
+
.ll-run-btn {
|
| 279 |
+
width: 100%;
|
| 280 |
+
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
| 281 |
+
color: white; border: none;
|
| 282 |
+
border-radius: 6px; padding: 10px;
|
| 283 |
+
font-family: var(--font); font-size: 12px; font-weight: 800;
|
| 284 |
+
cursor: pointer; transition: all 0.15s;
|
| 285 |
+
display: flex; align-items: center; justify-content: center; gap: 6px;
|
| 286 |
+
letter-spacing: 0.3px;
|
| 287 |
+
}
|
| 288 |
+
.ll-run-btn:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(139,92,246,0.4); }
|
| 289 |
+
.ll-run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
| 290 |
+
|
| 291 |
+
.ll-attribution {
|
| 292 |
+
font-size: 9px; color: var(--text-muted); text-align: center;
|
| 293 |
+
display: flex; align-items: center; justify-content: center; gap: 4px;
|
| 294 |
+
}
|
| 295 |
+
.ll-attribution a { color: var(--text-muted); text-decoration: none; }
|
| 296 |
+
.ll-attribution a:hover { color: var(--accent-bright); }
|
| 297 |
+
|
| 298 |
+
/* ── Empty / Locked State ── */
|
| 299 |
+
.ll-state {
|
| 300 |
+
padding: 24px 20px;
|
| 301 |
+
text-align: center;
|
| 302 |
+
display: flex; flex-direction: column; gap: 12px; align-items: center;
|
| 303 |
+
}
|
| 304 |
+
.ll-state-icon { font-size: 28px; opacity: 0.6; }
|
| 305 |
+
.ll-state-title { font-size: 13px; font-weight: 700; color: var(--text-dim); }
|
| 306 |
+
.ll-state-sub { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
|
| 307 |
+
.ll-state-sub a { color: var(--accent-bright); text-decoration: none; }
|
| 308 |
+
.ll-state-sub a:hover { text-decoration: underline; }
|
| 309 |
+
|
| 310 |
+
/* ── Minimized Dock ── */
|
| 311 |
+
.ll-dock {
|
| 312 |
+
width: 44px; height: 44px;
|
| 313 |
+
background: var(--surface);
|
| 314 |
+
border: 1px solid var(--border);
|
| 315 |
+
border-radius: 12px;
|
| 316 |
+
display: flex; align-items: center; justify-content: center;
|
| 317 |
+
cursor: pointer;
|
| 318 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
| 319 |
+
transition: all 0.2s;
|
| 320 |
+
position: relative;
|
| 321 |
+
}
|
| 322 |
+
.ll-dock:hover { border-color: var(--accent); transform: scale(1.05); }
|
| 323 |
+
.ll-dock-inner {
|
| 324 |
+
font-size: 16px; font-weight: 900;
|
| 325 |
+
color: var(--accent-bright);
|
| 326 |
+
letter-spacing: -1px;
|
| 327 |
+
}
|
| 328 |
+
.ll-dock-pulse {
|
| 329 |
+
position: absolute; top: -3px; right: -3px;
|
| 330 |
+
width: 10px; height: 10px;
|
| 331 |
+
border-radius: 50%;
|
| 332 |
+
background: var(--green);
|
| 333 |
+
box-shadow: 0 0 6px var(--green);
|
| 334 |
+
animation: pulse 2s infinite;
|
| 335 |
+
}
|
| 336 |
+
.ll-dock-pulse.used { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
| 337 |
+
.ll-dock-pulse.gone { display: none; }
|
| 338 |
+
</style>
|
| 339 |
+
</head>
|
| 340 |
+
<body>
|
| 341 |
+
|
| 342 |
+
<!-- ── DOCK (minimized) ─────────────────────────────── -->
|
| 343 |
+
<div class="ll-dock" id="ll_dock" onclick="LL.open()" title="LoomLens Live ✦">
|
| 344 |
+
<div class="ll-dock-inner">✦</div>
|
| 345 |
+
<div class="ll-dock-pulse" id="ll_dock_pulse"></div>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
<!-- ── PANEL ─────────────────────────────────────── -->
|
| 349 |
+
<div class="ll-panel" id="ll_panel" style="display:none;">
|
| 350 |
+
|
| 351 |
+
<!-- Header -->
|
| 352 |
+
<div class="ll-header">
|
| 353 |
+
<div class="ll-header-left">
|
| 354 |
+
<div class="ll-dot"></div>
|
| 355 |
+
<span class="ll-title">LoomLens Live</span>
|
| 356 |
+
<span class="ll-badge free" id="ll_status_badge">3 FREE</span>
|
| 357 |
+
</div>
|
| 358 |
+
<div class="ll-hdr-btns">
|
| 359 |
+
<button class="ll-hdr-btn" id="ll_btn_min" title="Minimize">−</button>
|
| 360 |
+
<button class="ll-hdr-btn" id="ll_btn_close" title="Close">×</button>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
<!-- Prompt Input -->
|
| 365 |
+
<div class="ll-input-area">
|
| 366 |
+
<div class="ll-prompt-wrap" id="ll_prompt_wrap">
|
| 367 |
+
<textarea
|
| 368 |
+
class="ll-prompt-ta"
|
| 369 |
+
id="ll_prompt_ta"
|
| 370 |
+
placeholder="Type or drop prompt here to estimate…"
|
| 371 |
+
rows="3"
|
| 372 |
+
></textarea>
|
| 373 |
+
<div class="ll-drop-hint" id="ll_drop_hint">
|
| 374 |
+
✦ Drop prompt here to estimate cost<br>
|
| 375 |
+
<span style="font-size:10px;opacity:0.6">or paste/type in the box above</span>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<!-- Filters -->
|
| 381 |
+
<div class="ll-filters">
|
| 382 |
+
<span class="ll-filter-label">Cluster:</span>
|
| 383 |
+
<select class="ll-select" id="ll_cluster_sel">
|
| 384 |
+
<option value="all">All Clusters</option>
|
| 385 |
+
<option value="flash">Flash Reasoning</option>
|
| 386 |
+
<option value="fastgen">Fast General</option>
|
| 387 |
+
<option value="code">Balanced Coding</option>
|
| 388 |
+
<option value="power">Power / Architecture</option>
|
| 389 |
+
<option value="vision">Vision / Multimodal</option>
|
| 390 |
+
</select>
|
| 391 |
+
<span class="ll-filter-label">Sort:</span>
|
| 392 |
+
<select class="ll-select" id="ll_sort_sel">
|
| 393 |
+
<option value="score">Best Fit</option>
|
| 394 |
+
<option value="cost">Lowest Cost</option>
|
| 395 |
+
<option value="speed">Fastest</option>
|
| 396 |
+
</select>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
<!-- Results Body -->
|
| 400 |
+
<div class="ll-body" id="ll_body">
|
| 401 |
+
<div class="ll-state" id="ll_empty_state">
|
| 402 |
+
<div class="ll-state-icon">✦</div>
|
| 403 |
+
<div class="ll-state-title">LoomLens Live</div>
|
| 404 |
+
<div class="ll-state-sub">
|
| 405 |
+
Enter a prompt above to see cost estimates<br>across all major models
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<!-- Tip -->
|
| 411 |
+
<div class="ll-tip" id="ll_tip" style="display:none;"></div>
|
| 412 |
+
|
| 413 |
+
<!-- Footer -->
|
| 414 |
+
<div class="ll-footer">
|
| 415 |
+
<div class="ll-quota-row">
|
| 416 |
+
<span class="ll-quota-label" id="ll_quota_label">Free estimates:</span>
|
| 417 |
+
<span class="ll-quota-val ok" id="ll_quota_val">3 left</span>
|
| 418 |
+
</div>
|
| 419 |
+
<div class="ll-key-row" id="ll_key_row" style="display:none;">
|
| 420 |
+
<input type="password" class="ll-key-input" id="ll_key_input" placeholder="sk_... Signal Loom API key" />
|
| 421 |
+
<button class="ll-btn primary" onclick="LL.connectKey()">Connect</button>
|
| 422 |
+
</div>
|
| 423 |
+
<button class="ll-btn ghost" id="ll_show_key" onclick="LL.toggleKeyRow()" style="font-size:9px;">
|
| 424 |
+
+ Connect API key for unlimited
|
| 425 |
+
</button>
|
| 426 |
+
<button class="ll-run-btn" id="ll_run_btn" onclick="LL.runEstimate()" disabled>
|
| 427 |
+
Select a model to run →
|
| 428 |
+
</button>
|
| 429 |
+
<div class="ll-attribution">
|
| 430 |
+
Powered by <a href="https://signalloomai.com" target="_blank">Signal Loom AI</a> ✦
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
|
| 434 |
+
</div>
|
| 435 |
+
|
| 436 |
+
<script>
|
| 437 |
+
(function() {
|
| 438 |
+
'use strict';
|
| 439 |
+
|
| 440 |
+
// ─── State ────────────────────────────────────────────────────────────────
|
| 441 |
+
const STORAGE_KEYS = {
|
| 442 |
+
apiKey: 'llive_api_key',
|
| 443 |
+
freeUsed: 'llive_free_used',
|
| 444 |
+
freeReset:'llive_free_reset',
|
| 445 |
+
selected: 'llive_selected_model',
|
| 446 |
+
};
|
| 447 |
+
const FREE_LIMIT = 3;
|
| 448 |
+
const CPT = 3.8; // chars per token (English average)
|
| 449 |
+
|
| 450 |
+
let state = {
|
| 451 |
+
prompt: '',
|
| 452 |
+
apiKey: localStorage.getItem(STORAGE_KEYS.apiKey) || '',
|
| 453 |
+
freeUsed: parseInt(localStorage.getItem(STORAGE_KEYS.freeUsed) || '0'),
|
| 454 |
+
freeReset: parseInt(localStorage.getItem(STORAGE_KEYS.freeReset) || '0'),
|
| 455 |
+
selectedModel: localStorage.getItem(STORAGE_KEYS.selected) || '',
|
| 456 |
+
result: null,
|
| 457 |
+
minimized: false,
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
// ─── Model Clusters ────────────────────────────────────────────────────────
|
| 461 |
+
const CLUSTERS = [
|
| 462 |
+
{
|
| 463 |
+
id: 'flash',
|
| 464 |
+
name: 'Flash Reasoning',
|
| 465 |
+
desc: 'Fast + cheap. Best for Q&A, summaries, simple tasks.',
|
| 466 |
+
models: [
|
| 467 |
+
{ id: 'minimax/MiniMax-M2', name: 'MiniMax M2', provider: 'MiniMax', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['reasoning','fast','cheap'] },
|
| 468 |
+
{ id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['reasoning','fast'] },
|
| 469 |
+
{ id: 'anthropic/claude-haiku', name: 'Claude Haiku', provider: 'Anthropic',tier: 3, inPrice: 0.80, outPrice: 4.00, latency: 'fast', caps: ['fast','light-reasoning'] },
|
| 470 |
+
{ id: 'deepseek/deepseek-chat-v3',name:'DeepSeek Chat v3', provider: 'DeepSeek',tier: 3, inPrice: 0.27, outPrice: 1.10, latency: 'fast', caps: ['reasoning','fast'] },
|
| 471 |
+
],
|
| 472 |
+
},
|
| 473 |
+
{
|
| 474 |
+
id: 'fastgen',
|
| 475 |
+
name: 'Fast General',
|
| 476 |
+
desc: 'Fast general purpose. Best latency for typical prompts.',
|
| 477 |
+
models: [
|
| 478 |
+
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI', tier: 3, inPrice: 0.15, outPrice: 0.60, latency: 'fast', caps: ['fast','cheap'] },
|
| 479 |
+
{ id: 'google/gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'Google', tier: 3, inPrice: 0.10, outPrice: 0.40, latency: 'fast', caps: ['fast','cheap'] },
|
| 480 |
+
{ id: 'xai/grok-3', name: 'xAI Grok-3', provider: 'xAI', tier: 3, inPrice: 2.00, outPrice: 10.0, latency: 'fast', caps: ['fast','reasoning'] },
|
| 481 |
+
],
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
id: 'code',
|
| 485 |
+
name: 'Balanced Coding',
|
| 486 |
+
desc: 'Best code capability per dollar. Medium tasks.',
|
| 487 |
+
models: [
|
| 488 |
+
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'Anthropic', tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['reasoning','code','analysis'] },
|
| 489 |
+
{ id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 2, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['reasoning','vision','code'] },
|
| 490 |
+
{ id: 'anthropic/claude-3.5-sonnet',name: 'Claude 3.5 Sonnet', provider: 'Anthropic',tier: 2, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['code','reasoning'] },
|
| 491 |
+
],
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
id: 'power',
|
| 495 |
+
name: 'Power / Architecture',
|
| 496 |
+
desc: 'Maximum reasoning. Pay for it only when needed.',
|
| 497 |
+
models: [
|
| 498 |
+
{ id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['reasoning','code','analysis','long-context'] },
|
| 499 |
+
{ id: 'anthropic/claude-code', name: 'Claude Code', provider: 'Anthropic', tier: 1, inPrice: 15.0, outPrice: 75.0, latency: 'slow', caps: ['agentic','code','architecture'] },
|
| 500 |
+
],
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
id: 'vision',
|
| 504 |
+
name: 'Vision / Multimodal',
|
| 505 |
+
desc: 'Image + document understanding.',
|
| 506 |
+
models: [
|
| 507 |
+
{ id: 'openai/gpt-4o', name: 'GPT-4o', provider: 'OpenAI', tier: 4, inPrice: 2.50, outPrice: 10.0, latency: 'medium', caps: ['vision','reasoning','multimodal'] },
|
| 508 |
+
{ id: 'anthropic/claude-3.5', name: 'Claude 3.5', provider: 'Anthropic',tier: 4, inPrice: 3.00, outPrice: 15.0, latency: 'medium', caps: ['vision','code','analysis'] },
|
| 509 |
+
],
|
| 510 |
+
},
|
| 511 |
+
];
|
| 512 |
+
|
| 513 |
+
// ─── Task Detection ��───────────────────────────────────────────────────────
|
| 514 |
+
const TASK_KEYWORDS = {
|
| 515 |
+
simple_qa: ['what is','who is','define','explain','how do i','how does','what are','why does','when did','where is','tell me'],
|
| 516 |
+
summary: ['summarize','tl;dr','sum up','brief','recap','key points','highlights','overview','condense'],
|
| 517 |
+
code_edit: ['fix','bug','debug','hotfix','patch','tweak','adjust','correct','error','issue'],
|
| 518 |
+
code_write: ['write','build','create','implement','add feature','new function','generate',' scaffold','code for'],
|
| 519 |
+
complex_analysis: ['analyze','architecture','review','refactor','design','strategy','compare','evaluate','assess','plan'],
|
| 520 |
+
vision: ['image','photo','picture','screenshot','diagram','chart','visual','frame','pdf','document'],
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
const OUTPUT_TOKENS = { simple_qa:150, summary:200, code_edit:400, code_write:800, complex_analysis:600, vision:300, unknown:400 };
|
| 524 |
+
const LATENCY_SCORE = { fast: 100, medium: 62, slow: 22 };
|
| 525 |
+
|
| 526 |
+
function detectTask(prompt) {
|
| 527 |
+
const lower = prompt.toLowerCase();
|
| 528 |
+
const scores = {};
|
| 529 |
+
for (const [type, kws] of Object.entries(TASK_KEYWORDS)) {
|
| 530 |
+
scores[type] = kws.reduce((acc, kw) => acc + (lower.includes(kw) ? 1 : 0), 0);
|
| 531 |
+
}
|
| 532 |
+
// Boost code signals
|
| 533 |
+
if (/```[\s\S]+```/.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
|
| 534 |
+
if (/^\s*(import|from|const|let|function|class|def |fn |pub fn)/m.test(prompt)) scores.code_write = (scores.code_write||0) + 2;
|
| 535 |
+
const best = Object.entries(scores).sort((a,b) => b[1]-a[1])[0];
|
| 536 |
+
return best && best[1] > 0 ? best[0] : 'unknown';
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// ─── Capability Fit per Cluster ──────────────────────────────────────────
|
| 540 |
+
const CAP_FIT = {
|
| 541 |
+
// task → cluster → fit 0-100
|
| 542 |
+
simple_qa: { flash: 95, fastgen: 80, code: 30, power: 20, vision: 20 },
|
| 543 |
+
summary: { flash: 90, fastgen: 75, code: 40, power: 30, vision: 20 },
|
| 544 |
+
code_edit: { flash: 60, fastgen: 40, code: 90, power: 40, vision: 20 },
|
| 545 |
+
code_write: { flash: 30, fastgen: 20, code: 95, power: 95, vision: 20 },
|
| 546 |
+
complex_analysis: { flash: 40, fastgen: 30, code: 85, power: 98, vision: 20 },
|
| 547 |
+
vision: { flash: 10, fastgen: 10, code: 30, power: 30, vision: 98 },
|
| 548 |
+
unknown: { flash: 70, fastgen: 60, code: 60, power: 50, vision: 40 },
|
| 549 |
+
};
|
| 550 |
+
|
| 551 |
+
// ─── Cost Calculation ─────────────────────────────────────────────────────
|
| 552 |
+
function estimateTokens(text) {
|
| 553 |
+
if (!text || !text.trim()) return 0;
|
| 554 |
+
const lang = /[\u3040-\u309F\u30A0-\u30FF]/.test(text) ? 'ja' : /[\u4E00-\u9FFF]/.test(text) ? 'zh' : /[áéíóúñ¿¡ü]/i.test(text) ? 'es' : 'en';
|
| 555 |
+
const cpt = lang === 'ja' ? 1.2 : lang === 'zh' ? 1.0 : lang === 'es' ? 3.5 : CPT;
|
| 556 |
+
const multiline = (text.match(/\n/g)||[]).length;
|
| 557 |
+
const codeFences = (text.match(/```/g)||[]).length;
|
| 558 |
+
const mult = (codeFences >= 2 || multiline > 3) ? 1.2 : 1.0;
|
| 559 |
+
return Math.ceil((text.length / cpt) * mult);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
function calcCost(model, inTokens, outTokens) {
|
| 563 |
+
return ((inTokens/1e6)*model.inPrice) + ((outTokens/1e6)*model.outPrice);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
// ─── Scoring ─────────────────────────────────────────────────────────────
|
| 567 |
+
function scoreModel(model, task, clusterModels) {
|
| 568 |
+
const capFit = CAP_FIT[task]?.[model.clusterId] ?? 50;
|
| 569 |
+
const costs = clusterModels.map(m => calcCost(m,1,1)); // relative
|
| 570 |
+
const minC = Math.min(...costs);
|
| 571 |
+
const maxC = Math.max(...costs);
|
| 572 |
+
const range = maxC - minC || 1;
|
| 573 |
+
const costEff = Math.round(100 - ((calcCost(model,1,1)-minC)/range*100));
|
| 574 |
+
const speed = LATENCY_SCORE[model.latency] ?? 50;
|
| 575 |
+
return Math.round(capFit * 0.35 + costEff * 0.35 + speed * 0.30);
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
// ─── Main Estimation ─────────────────────────────────────────────────────
|
| 579 |
+
function estimate(prompt, selectedId) {
|
| 580 |
+
if (!prompt || prompt.trim().length < 10) return null;
|
| 581 |
+
const task = detectTask(prompt);
|
| 582 |
+
const inTokens = estimateTokens(prompt);
|
| 583 |
+
const outTokens = OUTPUT_TOKENS[task] ?? 400;
|
| 584 |
+
const flat = [];
|
| 585 |
+
|
| 586 |
+
for (const cluster of CLUSTERS) {
|
| 587 |
+
for (const model of cluster.models) {
|
| 588 |
+
const m = { ...model, clusterId: cluster.id, clusterName: cluster.name };
|
| 589 |
+
m.cost = calcCost(m, inTokens, outTokens);
|
| 590 |
+
m.inTokens = inTokens;
|
| 591 |
+
m.outTokens= outTokens;
|
| 592 |
+
flat.push(m);
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
// Per-cluster scoring
|
| 597 |
+
const clusterResults = {};
|
| 598 |
+
for (const cluster of CLUSTERS) {
|
| 599 |
+
const cModels = flat.filter(m => m.clusterId === cluster.id);
|
| 600 |
+
cModels.forEach(m => { m.score = scoreModel(m, task, cModels); });
|
| 601 |
+
const sorted = [...cModels].sort((a,b) => b.score - a.score);
|
| 602 |
+
const best = sorted.find(m => m.score >= 65) ?? sorted[0];
|
| 603 |
+
clusterResults[cluster.id] = { sorted, best, cluster };
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// Global
|
| 607 |
+
const allScored = flat.map(m => {
|
| 608 |
+
const cr = clusterResults[m.clusterId];
|
| 609 |
+
m.score = cr.best === m ? m.score : Math.round(m.score * 0.7); // penalize non-cluster-best
|
| 610 |
+
return m;
|
| 611 |
+
});
|
| 612 |
+
const globalSorted = [...allScored].sort((a,b) => b.score - a.score);
|
| 613 |
+
const recommended = globalSorted.find(m => m.score >= 80);
|
| 614 |
+
const selected = flat.find(m => m.id === selectedId) ?? globalSorted[0];
|
| 615 |
+
|
| 616 |
+
// Tip
|
| 617 |
+
let tip = '';
|
| 618 |
+
if (recommended && recommended.id !== selectedId) {
|
| 619 |
+
const saving = (flat.find(m=>m.id===selectedId)?.cost ?? 0) - recommended.cost;
|
| 620 |
+
if (saving > 0.0001) {
|
| 621 |
+
tip = `${recommended.name} is better for this task. Switching saves ${fmtCost(saving)}.`;
|
| 622 |
+
}
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
return {
|
| 626 |
+
task, inTokens, outTokens,
|
| 627 |
+
clusters: clusterResults,
|
| 628 |
+
all: globalSorted,
|
| 629 |
+
recommended,
|
| 630 |
+
selected,
|
| 631 |
+
tip,
|
| 632 |
+
};
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
// ─── Formatting ──────────────────────────────────────────────────────────
|
| 636 |
+
function fmtCost(n) {
|
| 637 |
+
if (n >= 1) return '$' + n.toFixed(2);
|
| 638 |
+
if (n >= 0.01) return '$' + n.toFixed(3);
|
| 639 |
+
return '$' + n.toFixed(4);
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
// ─── API Response Mapper ─────────────────────────────────────────────────
|
| 643 |
+
// Maps Signal Loom /v1/estimate response → local LoomLensResult format.
|
| 644 |
+
// We pass all cluster models and inject the API cost into the right one.
|
| 645 |
+
function _mapAPIResponse(data, selectedId, prompt) {
|
| 646 |
+
// Find the cheapest from the API response
|
| 647 |
+
const apiModels = (data.models || []).sort((a,b) => a.estimated_cost_mid - b.estimated_cost_mid);
|
| 648 |
+
const cheapestAPI = apiModels[0];
|
| 649 |
+
const expensiveAPI = apiModels[apiModels.length - 1];
|
| 650 |
+
|
| 651 |
+
// Run local engine to get clusters, then inject API costs
|
| 652 |
+
const local = estimate(prompt, selectedId);
|
| 653 |
+
|
| 654 |
+
if (!cheapestAPI) return local;
|
| 655 |
+
|
| 656 |
+
// Inject API cost into the selected model
|
| 657 |
+
const selectedModel = local.allModels.find(m => m.modelId === selectedId) || local.allModels[0];
|
| 658 |
+
if (selectedModel) {
|
| 659 |
+
selectedModel.inputTokens = apiModels.find(m => m.model === selectedId)?.input_tokens_estimate || selectedModel.inputTokens;
|
| 660 |
+
selectedModel.outputTokens = apiModels.find(m => m.model === selectedId)?.output_tokens_estimate || selectedModel.outputTokens;
|
| 661 |
+
selectedModel.totalCost = apiModels.find(m => m.model === selectedId)?.estimated_cost_mid || selectedModel.totalCost;
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
// Update cheapest/expensive based on API data
|
| 665 |
+
const cheapestLocal = local.allModels.find(m => m.modelId === cheapestAPI.model);
|
| 666 |
+
if (cheapestLocal) cheapestLocal.totalCost = cheapestAPI.estimated_cost_mid;
|
| 667 |
+
|
| 668 |
+
// Re-sort clusters with injected costs
|
| 669 |
+
for (const [cid, cr] of Object.entries(local.clusters)) {
|
| 670 |
+
cr.models.sort((a,b) => a.totalCost - b.totalCost);
|
| 671 |
+
cr.best = cr.models.find(m => m.compositeScore >= 65) ?? cr.models[0];
|
| 672 |
+
}
|
| 673 |
+
local.allModels.sort((a,b) => b.compositeScore - a.compositeScore);
|
| 674 |
+
local.recommended = local.allModels.find(m => m.compositeScore >= 80) ?? cheapestLocal;
|
| 675 |
+
|
| 676 |
+
// Tip using API costs
|
| 677 |
+
const rec = local.recommended;
|
| 678 |
+
const sel = selectedModel;
|
| 679 |
+
if (rec && sel && rec.modelId !== sel.modelId) {
|
| 680 |
+
const saving = sel.totalCost - rec.totalCost;
|
| 681 |
+
if (saving > 0.0001) local.tip = `Switch to ${rec.modelName} and save ${fmtCost(saving)}.`;
|
| 682 |
+
else if (saving < -0.0001) local.tip = `${sel.modelName} costs ${fmtCost(Math.abs(saving))} more.`;
|
| 683 |
+
} else if (sel) {
|
| 684 |
+
local.tip = `${sel.modelName} is a good fit for this task.`;
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
// Attach billing metadata from API response
|
| 688 |
+
local._billing = data.billing || null;
|
| 689 |
+
local._apiResponse = data;
|
| 690 |
+
return local;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
// ─── Loading State ─────────────────────────────────────────────────────────
|
| 694 |
+
let loading = false;
|
| 695 |
+
function setLoading(v) { loading = v; }
|
| 696 |
+
|
| 697 |
+
// ─── Free Usage ──────────────────────────────────────────────────────────
|
| 698 |
+
// Reset free counter at midnight UTC.
|
| 699 |
+
function checkFreeReset() {
|
| 700 |
+
const now = Math.floor(Date.now() / 1000);
|
| 701 |
+
if (now >= state.freeReset) {
|
| 702 |
+
state.freeUsed = 0;
|
| 703 |
+
state.freeReset = now + 86400; // reset at next midnight UTC
|
| 704 |
+
localStorage.setItem(STORAGE_KEYS.freeUsed, '0');
|
| 705 |
+
localStorage.setItem(STORAGE_KEYS.freeReset, String(state.freeReset));
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// Decrement free run counter. Returns false when all 3 daily free runs are exhausted.
|
| 710 |
+
function useFree() {
|
| 711 |
+
if (state.apiKey) return true; // API key = paid, always allowed
|
| 712 |
+
checkFreeReset();
|
| 713 |
+
if (state.freeUsed >= FREE_LIMIT) return false; // no free runs left
|
| 714 |
+
state.freeUsed++;
|
| 715 |
+
localStorage.setItem(STORAGE_KEYS.freeUsed, String(state.freeUsed));
|
| 716 |
+
return true;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
// Returns remaining free runs today (0 when exhausted).
|
| 720 |
+
function freeLeft() {
|
| 721 |
+
if (state.apiKey) return Infinity;
|
| 722 |
+
checkFreeReset();
|
| 723 |
+
return Math.max(0, FREE_LIMIT - state.freeUsed);
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
// ─── Render ──────────────────────────────────────────────────────────────
|
| 727 |
+
const $ = id => document.getElementById(id);
|
| 728 |
+
|
| 729 |
+
function render() {
|
| 730 |
+
const left = freeLeft();
|
| 731 |
+
const hasApi = !!state.apiKey;
|
| 732 |
+
const panel = $('ll_panel');
|
| 733 |
+
const dock = $('ll_dock');
|
| 734 |
+
|
| 735 |
+
// Badge — show -3, -2, -1 counter for free tier
|
| 736 |
+
const badge = $('ll_status_badge');
|
| 737 |
+
if (hasApi) {
|
| 738 |
+
badge.textContent = 'LIVE'; badge.className = 'll-badge live';
|
| 739 |
+
} else if (left > 0) {
|
| 740 |
+
const displayCount = -(FREE_LIMIT - left + 1); // -3, -2, -1
|
| 741 |
+
badge.textContent = displayCount; badge.className = 'll-badge free';
|
| 742 |
+
} else {
|
| 743 |
+
badge.textContent = '0'; badge.className = 'll-badge locked';
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// Dock pulse
|
| 747 |
+
const pulse = $('ll_dock_pulse');
|
| 748 |
+
if (hasApi || left > 0) pulse.className = 'll-dock-pulse';
|
| 749 |
+
else if (left === 0) pulse.className = 'll-dock-pulse used';
|
| 750 |
+
else pulse.className = 'll-dock-pulse gone';
|
| 751 |
+
|
| 752 |
+
// Quota
|
| 753 |
+
const qLabel = $('ll_quota_label');
|
| 754 |
+
const qVal = $('ll_quota_val');
|
| 755 |
+
if (hasApi) {
|
| 756 |
+
// Show quota from last API response headers
|
| 757 |
+
const quota = state.result?._quota;
|
| 758 |
+
if (quota && quota.monthly_limit > 0) {
|
| 759 |
+
const rem = quota.remaining_this_month;
|
| 760 |
+
qLabel.textContent = 'API quota:';
|
| 761 |
+
qVal.textContent = rem === 0 ? 'Exhausted ⚠' : `${rem} remaining`;
|
| 762 |
+
qVal.className = rem === 0 ? 'll-quota-val warn' : 'll-quota-val ok';
|
| 763 |
+
} else {
|
| 764 |
+
qLabel.textContent = 'API key:'; qVal.textContent = 'Connected ✓'; qVal.className = 'll-quota-val ok';
|
| 765 |
+
}
|
| 766 |
+
} else {
|
| 767 |
+
const displayCount = left > 0 ? -(FREE_LIMIT - left + 1) : 0; // -3, -2, -1, then 0
|
| 768 |
+
qLabel.textContent = 'Free runs:';
|
| 769 |
+
qVal.textContent = displayCount < 0 ? displayCount : '0 (paid)';
|
| 770 |
+
qVal.className = displayCount < 0 ? 'll-quota-val ok' : 'll-quota-val warn';
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
// Key row
|
| 774 |
+
if (hasApi) {
|
| 775 |
+
$('ll_key_row').style.display = 'none';
|
| 776 |
+
$('ll_show_key').style.display = 'flex';
|
| 777 |
+
$('ll_show_key').className = 'll-btn ghost';
|
| 778 |
+
$('ll_show_key').textContent = 'Disconnect key';
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
renderBody();
|
| 782 |
+
renderTip();
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function renderBody() {
|
| 786 |
+
const result = state.result;
|
| 787 |
+
const body = $('ll_body');
|
| 788 |
+
const empty = $('ll_empty_state');
|
| 789 |
+
const clusterSel = $('ll_cluster_sel').value;
|
| 790 |
+
const sortSel = $('ll_sort_sel').value;
|
| 791 |
+
|
| 792 |
+
// Loading state
|
| 793 |
+
if (loading) {
|
| 794 |
+
body.innerHTML = `<div class="ll-state">
|
| 795 |
+
<div class="ll-state-icon">⚡</div>
|
| 796 |
+
<div class="ll-state-title">Estimating…</div>
|
| 797 |
+
<div class="ll-state-sub">Calling Signal Loom API</div>
|
| 798 |
+
</div>`;
|
| 799 |
+
return;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
// Error state
|
| 803 |
+
if (result?.error) {
|
| 804 |
+
const isQuota = result.error === 'quota_exhausted';
|
| 805 |
+
body.innerHTML = `<div class="ll-state">
|
| 806 |
+
<div class="ll-state-icon">${isQuota ? '📊' : '🔑'}</div>
|
| 807 |
+
<div class="ll-state-title">${isQuota ? 'Quota Exhausted' : 'API Key Issue'}</div>
|
| 808 |
+
<div class="ll-state-sub">${result.message}<br><br>
|
| 809 |
+
<a href="https://signallloomai.com/signup.html" target="_blank" style="color:#a78bfa">Get API key →</a>
|
| 810 |
+
·
|
| 811 |
+
<a href="https://signallloomai.com/pricing" target="_blank" style="color:#a78bfa">View plans →</a>
|
| 812 |
+
</div>
|
| 813 |
+
</div>`;
|
| 814 |
+
return;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
if (!result) {
|
| 818 |
+
body.innerHTML = '';
|
| 819 |
+
body.appendChild(empty);
|
| 820 |
+
empty.style.display = '';
|
| 821 |
+
return;
|
| 822 |
+
}
|
| 823 |
+
empty.style.display = 'none';
|
| 824 |
+
|
| 825 |
+
// Collect models to show
|
| 826 |
+
let models = [];
|
| 827 |
+
if (clusterSel === 'all') {
|
| 828 |
+
models = result.all;
|
| 829 |
+
} else {
|
| 830 |
+
models = result.clusters[clusterSel]?.sorted ?? [];
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
// Sort
|
| 834 |
+
if (sortSel === 'cost') models = [...models].sort((a,b) => a.cost - b.cost);
|
| 835 |
+
else if (sortSel === 'speed') models = [...models].sort((a,b) => LATENCY_SCORE[b.latency] - LATENCY_SCORE[a.latency]);
|
| 836 |
+
// else: keep score order
|
| 837 |
+
|
| 838 |
+
const maxCost = Math.max(...result.all.map(m=>m.cost));
|
| 839 |
+
|
| 840 |
+
// Group by cluster
|
| 841 |
+
const groups = {};
|
| 842 |
+
for (const m of models) {
|
| 843 |
+
if (!groups[m.clusterId]) groups[m.clusterId] = [];
|
| 844 |
+
groups[m.clusterId].push(m);
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
let html = '';
|
| 848 |
+
for (const [cid, cmodels] of Object.entries(groups)) {
|
| 849 |
+
const cluster = CLUSTERS.find(c=>c.id===cid);
|
| 850 |
+
const best = result.clusters[cid]?.best;
|
| 851 |
+
html += `<div class="ll-cluster">
|
| 852 |
+
<div class="ll-cluster-header">
|
| 853 |
+
${cluster?.name ?? cid}
|
| 854 |
+
<span class="ll-cluster-count">${cmodels.length} models</span>
|
| 855 |
+
</div>`;
|
| 856 |
+
|
| 857 |
+
for (const m of cmodels) {
|
| 858 |
+
const isSel = m.id === state.selectedModel;
|
| 859 |
+
const isBest = best?.id === m.id;
|
| 860 |
+
const barW = Math.max(4, Math.round((m.cost / maxCost) * 100));
|
| 861 |
+
const speedGlyph = m.latency === 'fast' ? '⚡' : m.latency === 'medium' ? '◆' : '◇';
|
| 862 |
+
const badges = [];
|
| 863 |
+
if (isSel) badges.push('<span class="ll-card-badge sel">✓ Selected</span>');
|
| 864 |
+
if (isBest && !isSel) badges.push('<span class="ll-card-badge rec">💡 Best in group</span>');
|
| 865 |
+
if (m.score < 50) badges.push('<span class="ll-card-badge over">⚠️ Review</span>');
|
| 866 |
+
html += `<div class="ll-card${isSel?' selected':''}${isBest&&!isSel?' recommended':''}" onclick="LL.select('${m.id}')">
|
| 867 |
+
<div class="ll-card-top">
|
| 868 |
+
<div class="ll-radio"></div>
|
| 869 |
+
<div class="ll-card-name">${m.name}</div>
|
| 870 |
+
<div class="ll-card-right">
|
| 871 |
+
<div class="ll-card-speed">${speedGlyph} ${LATENCY_SCORE[m.latency]}</div>
|
| 872 |
+
<div class="ll-card-cost">${fmtCost(m.cost)}</div>
|
| 873 |
+
</div>
|
| 874 |
+
</div>
|
| 875 |
+
<div class="ll-card-bar-row">
|
| 876 |
+
<div class="ll-card-bar-wrap"><div class="ll-card-bar" style="width:${barW}%"></div></div>
|
| 877 |
+
</div>
|
| 878 |
+
<div class="ll-card-insight">${m.clusterName} · ${m.provider} · ~${m.inTokens.toLocaleString()} in / ~${m.outTokens} out</div>
|
| 879 |
+
${badges.length ? `<div class="ll-card-badges">${badges.join('')}</div>` : ''}
|
| 880 |
+
</div>`;
|
| 881 |
+
}
|
| 882 |
+
html += '</div>';
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
body.innerHTML = html;
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
function renderTip() {
|
| 889 |
+
const tip = state.result?.tip;
|
| 890 |
+
const el = $('ll_tip');
|
| 891 |
+
if (!tip) { el.style.display = 'none'; return; }
|
| 892 |
+
el.textContent = '💡 ' + tip;
|
| 893 |
+
el.style.display = '';
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
// ─── Public API ───────────────────────────────────────────────────────────
|
| 897 |
+
window.LL = {
|
| 898 |
+
open() {
|
| 899 |
+
$('ll_panel').style.display = 'flex';
|
| 900 |
+
$('ll_dock').style.display = 'none';
|
| 901 |
+
$('ll_prompt_ta').focus();
|
| 902 |
+
},
|
| 903 |
+
close() {
|
| 904 |
+
$('ll_panel').style.display = 'none';
|
| 905 |
+
$('ll_dock').style.display = 'flex';
|
| 906 |
+
},
|
| 907 |
+
minimize() {
|
| 908 |
+
$('ll_panel').style.display = 'none';
|
| 909 |
+
$('ll_dock').style.display = 'flex';
|
| 910 |
+
},
|
| 911 |
+
select(modelId) {
|
| 912 |
+
state.selectedModel = modelId;
|
| 913 |
+
localStorage.setItem(STORAGE_KEYS.selected, modelId);
|
| 914 |
+
const runBtn = $('ll_run_btn');
|
| 915 |
+
const sel = state.result?.all.find(m=>m.id===modelId);
|
| 916 |
+
if (sel) {
|
| 917 |
+
runBtn.textContent = `✦ Run on ${sel.name} →`;
|
| 918 |
+
runBtn.disabled = false;
|
| 919 |
+
}
|
| 920 |
+
renderBody();
|
| 921 |
+
},
|
| 922 |
+
run() {
|
| 923 |
+
if (!state.selectedModel) return;
|
| 924 |
+
if (!state.apiKey && !useFree()) {
|
| 925 |
+
alert('Daily free estimates exhausted. Connect your Signal Loom API key for unlimited use.');
|
| 926 |
+
return;
|
| 927 |
+
}
|
| 928 |
+
// Dispatch — post to parent or emit event
|
| 929 |
+
const event = new CustomEvent('loomlens:run', {
|
| 930 |
+
detail: { modelId: state.selectedModel, prompt: state.prompt }
|
| 931 |
+
});
|
| 932 |
+
window.dispatchEvent(event);
|
| 933 |
+
// Also try postMessage for cross-origin
|
| 934 |
+
if (window.parent !== window) {
|
| 935 |
+
window.parent.postMessage({ type: 'loomlens_run', modelId: state.selectedModel, prompt: state.prompt }, '*');
|
| 936 |
+
}
|
| 937 |
+
this.close();
|
| 938 |
+
},
|
| 939 |
+
connectKey() {
|
| 940 |
+
const key = $('ll_key_input').value.trim();
|
| 941 |
+
if (!key) return;
|
| 942 |
+
state.apiKey = key;
|
| 943 |
+
localStorage.setItem(STORAGE_KEYS.apiKey, key);
|
| 944 |
+
$('ll_key_input').value = key;
|
| 945 |
+
render();
|
| 946 |
+
},
|
| 947 |
+
disconnectKey() {
|
| 948 |
+
state.apiKey = '';
|
| 949 |
+
localStorage.removeItem(STORAGE_KEYS.apiKey);
|
| 950 |
+
$('ll_key_input').value = '';
|
| 951 |
+
render();
|
| 952 |
+
},
|
| 953 |
+
toggleKeyRow() {
|
| 954 |
+
if (state.apiKey) {
|
| 955 |
+
this.disconnectKey();
|
| 956 |
+
return;
|
| 957 |
+
}
|
| 958 |
+
const row = $('ll_key_row');
|
| 959 |
+
const btn = $('ll_show_key');
|
| 960 |
+
if (row.style.display === 'none') {
|
| 961 |
+
row.style.display = 'flex';
|
| 962 |
+
btn.textContent = 'Cancel';
|
| 963 |
+
$('ll_key_input').focus();
|
| 964 |
+
} else {
|
| 965 |
+
row.style.display = 'none';
|
| 966 |
+
btn.textContent = '+ Connect API key for unlimited';
|
| 967 |
+
}
|
| 968 |
+
},
|
| 969 |
+
estimate() {
|
| 970 |
+
const prompt = $('ll_prompt_ta').value;
|
| 971 |
+
if (!prompt || prompt.trim().length < 10) {
|
| 972 |
+
state.result = null;
|
| 973 |
+
render();
|
| 974 |
+
return;
|
| 975 |
+
}
|
| 976 |
+
state.prompt = prompt;
|
| 977 |
+
// ── Local engine preview (always free) ────────────────��─────────
|
| 978 |
+
// API billing happens only on "Run Estimate" click.
|
| 979 |
+
state.result = estimate(prompt, state.selectedModel);
|
| 980 |
+
if (!state.selectedModel && state.result?.recommended) {
|
| 981 |
+
state.selectedModel = state.result.recommended.id;
|
| 982 |
+
}
|
| 983 |
+
render();
|
| 984 |
+
const btn = $('ll_run_btn');
|
| 985 |
+
const sel = state.result?.allModels?.find(m=>m.id===state.selectedModel);
|
| 986 |
+
btn.textContent = sel ? `✦ Run on ${sel.name} →` : 'Select a model above →';
|
| 987 |
+
btn.disabled = !sel;
|
| 988 |
+
},
|
| 989 |
+
|
| 990 |
+
/**
|
| 991 |
+
* _estimateAPI — call POST /v1/estimate (billed per-call).
|
| 992 |
+
* Sets billing: true, decrements quota server-side.
|
| 993 |
+
* Falls back to local engine on network error.
|
| 994 |
+
*/
|
| 995 |
+
async runEstimate() {
|
| 996 |
+
if (!state.apiKey) {
|
| 997 |
+
const left = freeLeft();
|
| 998 |
+
if (left === 0) {
|
| 999 |
+
alert('Your 3 free runs are used for today.\n\nConnect your Signal Loom API key to continue — signallloomai.com →');
|
| 1000 |
+
} else {
|
| 1001 |
+
alert('Connect your Signal Loom API key at signallloomai.com to run estimates.');
|
| 1002 |
+
}
|
| 1003 |
+
return;
|
| 1004 |
+
}
|
| 1005 |
+
// Count this as a free use (we'll call /estimate/free on their behalf via their key)
|
| 1006 |
+
useFree(); // record usage against their account
|
| 1007 |
+
const prompt = state.prompt || $('ll_prompt_ta').value;
|
| 1008 |
+
if (!prompt) return;
|
| 1009 |
+
const modelId = state.selectedModel || 'minimax/MiniMax-M2';
|
| 1010 |
+
const btn = $('ll_run_btn');
|
| 1011 |
+
btn.disabled = true;
|
| 1012 |
+
btn.textContent = '⚡ Running…';
|
| 1013 |
+
try {
|
| 1014 |
+
const body = {
|
| 1015 |
+
job_description: prompt,
|
| 1016 |
+
num_agents: 1,
|
| 1017 |
+
estimated_runtime_minutes: 1,
|
| 1018 |
+
complexity: 'standard',
|
| 1019 |
+
target_models: [modelId],
|
| 1020 |
+
include_postmortem: false,
|
| 1021 |
+
};
|
| 1022 |
+
const res = await fetch('https://api.signallloomai.com/v1/estimate', {
|
| 1023 |
+
method: 'POST',
|
| 1024 |
+
headers: {
|
| 1025 |
+
'Authorization': 'Bearer ' + state.apiKey,
|
| 1026 |
+
'Content-Type': 'application/json',
|
| 1027 |
+
},
|
| 1028 |
+
body: JSON.stringify(body),
|
| 1029 |
+
});
|
| 1030 |
+
|
| 1031 |
+
if (res.status === 429) {
|
| 1032 |
+
// Quota exhausted
|
| 1033 |
+
const err = await res.json().catch(()=>({}));
|
| 1034 |
+
state.result = {
|
| 1035 |
+
error: 'quota_exhausted',
|
| 1036 |
+
message: err.detail || 'Monthly estimate quota exhausted. Upgrade at signallloomai.com →',
|
| 1037 |
+
};
|
| 1038 |
+
render();
|
| 1039 |
+
return;
|
| 1040 |
+
}
|
| 1041 |
+
if (res.status === 401) {
|
| 1042 |
+
state.result = {
|
| 1043 |
+
error: 'invalid_key',
|
| 1044 |
+
message: 'Invalid API key. Check your key at signallloomai.com →',
|
| 1045 |
+
};
|
| 1046 |
+
render();
|
| 1047 |
+
return;
|
| 1048 |
+
}
|
| 1049 |
+
if (!res.ok) {
|
| 1050 |
+
throw new Error('API error: ' + res.status);
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
// Extract quota from response headers
|
| 1054 |
+
const quota = {
|
| 1055 |
+
monthly_limit: parseInt(res.headers.get('X-Estimate-Limit') || '0'),
|
| 1056 |
+
used_this_month: parseInt(res.headers.get('X-Estimate-Used') || '0'),
|
| 1057 |
+
remaining_this_month: parseInt(res.headers.get('X-Estimate-Remaining') || '0'),
|
| 1058 |
+
resets_at: res.headers.get('X-Estimate-Resets') || '',
|
| 1059 |
+
};
|
| 1060 |
+
|
| 1061 |
+
const data = await res.json();
|
| 1062 |
+
// Attach billing + quota to result for display
|
| 1063 |
+
data._billing = data.billing || quota;
|
| 1064 |
+
data._quota = quota;
|
| 1065 |
+
state.result = _mapAPIResponse(data, modelId, prompt);
|
| 1066 |
+
state.result._quota = quota;
|
| 1067 |
+
state.result._billing = data.billing || null;
|
| 1068 |
+
if (!state.selectedModel && state.result?.recommended) {
|
| 1069 |
+
state.selectedModel = state.result.recommended.id;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
// ── Dispatch to OpenClaw ─────────────────────────────────────
|
| 1073 |
+
const event = new CustomEvent('loomlens:run', {
|
| 1074 |
+
detail: { modelId, prompt, cost: data.models?.[0]?.estimated_cost_mid }
|
| 1075 |
+
});
|
| 1076 |
+
window.dispatchEvent(event);
|
| 1077 |
+
if (window.parent !== window) {
|
| 1078 |
+
window.parent.postMessage({ type: 'loomlens_run', modelId, prompt }, '*');
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
btn.textContent = '✓ Dispatched';
|
| 1082 |
+
setTimeout(() => { btn.disabled = false; btn.textContent = `✦ Run on ${state.selectedModel?.split('/').pop()} →`; }, 1200);
|
| 1083 |
+
setTimeout(() => this.close(), 1500);
|
| 1084 |
+
} catch (err) {
|
| 1085 |
+
console.warn('[LoomLens] runEstimate failed:', err.message);
|
| 1086 |
+
const btn = $('ll_run_btn');
|
| 1087 |
+
btn.textContent = '⚠ Error — retry';
|
| 1088 |
+
btn.disabled = false;
|
| 1089 |
+
}
|
| 1090 |
+
},
|
| 1091 |
+
getState() { return {...state}; },
|
| 1092 |
+
};
|
| 1093 |
+
|
| 1094 |
+
// ─── Event Wiring ─────────────────────────────────────────────────────────
|
| 1095 |
+
// Drag & drop
|
| 1096 |
+
const wrap = $('ll_prompt_wrap');
|
| 1097 |
+
wrap.addEventListener('dragover', e => { e.preventDefault(); wrap.classList.add('drag-over'); });
|
| 1098 |
+
wrap.addEventListener('dragleave', () => wrap.classList.remove('drag-over'));
|
| 1099 |
+
wrap.addEventListener('drop', e => {
|
| 1100 |
+
e.preventDefault();
|
| 1101 |
+
wrap.classList.remove('drag-over');
|
| 1102 |
+
const text = e.dataTransfer?.getData('text/plain') || '';
|
| 1103 |
+
if (text) { $('ll_prompt_ta').value = text; $('ll_prompt_ta').dispatchEvent(new Event('input')); }
|
| 1104 |
+
});
|
| 1105 |
+
|
| 1106 |
+
// Textarea input
|
| 1107 |
+
let debounce;
|
| 1108 |
+
$('ll_prompt_ta').addEventListener('input', () => {
|
| 1109 |
+
wrap.classList.toggle('focused', $('ll_prompt_ta').value.length > 0);
|
| 1110 |
+
$('ll_drop_hint').classList.toggle('hidden', $('ll_prompt_ta').value.length > 0);
|
| 1111 |
+
clearTimeout(debounce);
|
| 1112 |
+
debounce = setTimeout(() => window.LL.estimate(), 300);
|
| 1113 |
+
});
|
| 1114 |
+
$('ll_prompt_ta').addEventListener('focus', () => wrap.classList.add('focused'));
|
| 1115 |
+
$('ll_prompt_ta').addEventListener('blur', () => wrap.classList.remove('focused'));
|
| 1116 |
+
|
| 1117 |
+
// Filters
|
| 1118 |
+
$('ll_cluster_sel').addEventListener('change', renderBody);
|
| 1119 |
+
$('ll_sort_sel').addEventListener('change', renderBody);
|
| 1120 |
+
|
| 1121 |
+
// Header controls
|
| 1122 |
+
$('ll_btn_close').onclick = () => window.LL.close();
|
| 1123 |
+
$('ll_btn_min').onclick = () => window.LL.minimize();
|
| 1124 |
+
|
| 1125 |
+
// Keyboard shortcut
|
| 1126 |
+
document.addEventListener('keydown', e => {
|
| 1127 |
+
if ((e.metaKey || e.ctrlKey) && e.key === 'l') { e.preventDefault(); window.LL.open(); }
|
| 1128 |
+
if (e.key === 'Escape') window.LL.close();
|
| 1129 |
+
});
|
| 1130 |
+
|
| 1131 |
+
// postMessage: receive prompt from parent (OpenClaw webchat)
|
| 1132 |
+
window.addEventListener('message', e => {
|
| 1133 |
+
if (e.data?.type === 'openclaw_prompt' && e.data?.text) {
|
| 1134 |
+
$('ll_prompt_ta').value = e.data.text;
|
| 1135 |
+
$('ll_drop_hint').classList.add('hidden');
|
| 1136 |
+
window.LL.estimate();
|
| 1137 |
+
}
|
| 1138 |
+
});
|
| 1139 |
+
|
| 1140 |
+
// ─── Init ──────────────────────────────────────────────────────────────────
|
| 1141 |
+
checkFreeReset();
|
| 1142 |
+
if (state.apiKey) $('ll_key_input').value = state.apiKey;
|
| 1143 |
+
if (state.selectedModel) {
|
| 1144 |
+
const runBtn = $('ll_run_btn');
|
| 1145 |
+
runBtn.textContent = `✦ Run on ${state.selectedModel.split('/').pop()} →`;
|
| 1146 |
+
runBtn.disabled = false;
|
| 1147 |
+
}
|
| 1148 |
+
render();
|
| 1149 |
+
|
| 1150 |
+
// Open on load if coming back with a result
|
| 1151 |
+
if (state.result) window.LL.open();
|
| 1152 |
+
|
| 1153 |
+
})();
|
| 1154 |
+
</script>
|
| 1155 |
+
</body>
|
| 1156 |
+
</html>
|
loomlensai-pro.html
CHANGED
|
@@ -105,36 +105,26 @@
|
|
| 105 |
</div>
|
| 106 |
<div class="tier-desc">$99/mo value. Transcription platform + LoomLensAI Power Pro + All future tools. Monthly: locks at $59 for 6 mo, then $99/mo. Annual: $449/yr (locks 12 mo, then $699/yr.</div>
|
| 107 |
</div>
|
| 108 |
-
|
| 109 |
-
<!-- Scale -->
|
| 110 |
-
<div class="tier" id="tier-scale" onclick="selectTier('scale')">
|
| 111 |
-
<div class="tier-header">
|
| 112 |
-
<span class="tier-name">Scale — LoomLensAI Only</span>
|
| 113 |
-
<span class="tier-price">$20<span>/mo</span></span>
|
| 114 |
-
</div>
|
| 115 |
-
<div class="tier-desc">1,000 estimates/month · Overage at $0.005/job</div>
|
| 116 |
-
</div>
|
| 117 |
-
|
| 118 |
<!-- Pro -->
|
| 119 |
-
|
| 120 |
<div class="tier-header">
|
| 121 |
-
<span class="tier-name">Pro — LoomLensAI
|
| 122 |
-
<span class="tier-price">$
|
| 123 |
</div>
|
| 124 |
-
<div class="tier-desc">
|
| 125 |
</div>
|
| 126 |
|
| 127 |
<!-- Per-job -->
|
| 128 |
<div class="tier" id="tier-per_job" onclick="selectTier('per_job')">
|
| 129 |
<div class="tier-header">
|
| 130 |
<span class="tier-name">Per-Job — For Agents & Pipelines</span>
|
| 131 |
-
<span class="tier-price">$0.
|
| 132 |
</div>
|
| 133 |
<div class="tier-desc">No monthly commitment · Pay only for what you use · Ideal for automated pipelines</div>
|
| 134 |
</div>
|
| 135 |
|
| 136 |
<ul class="features">
|
| 137 |
-
|
| 138 |
<li>All major AI models included</li>
|
| 139 |
<li>API access for agents and pipelines</li>
|
| 140 |
<li>Cancel anytime</li>
|
|
@@ -146,7 +136,7 @@
|
|
| 146 |
</div>
|
| 147 |
|
| 148 |
<button class="btn-checkout" id="btn_checkout" onclick="startCheckout()">
|
| 149 |
-
<span id="btn_label">Get Pro — $
|
| 150 |
</button>
|
| 151 |
<div class="error" id="error_msg"></div>
|
| 152 |
|
|
@@ -154,6 +144,7 @@
|
|
| 154 |
<strong>Per-Job API:</strong> After checkout, we'll set up your API key and send credentials within 2 hours. Contact <a href="mailto:hello@signalloomai.com">hello@signalloomai.com</a> for immediate setup.
|
| 155 |
</div>
|
| 156 |
|
|
|
|
| 157 |
<p class="note">
|
| 158 |
Secured by Stripe · Cancel anytime · <a href="https://signalloomai.com" target="_blank">A SignalloomAI™ Product</a>
|
| 159 |
</p>
|
|
@@ -168,15 +159,15 @@
|
|
| 168 |
|
| 169 |
const tierConfig = {
|
| 170 |
signalloomai_early: { label: 'Claim Early Adopter Price — $59/mo', scale: 'monthly' },
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
per_job: { label: 'Start Per-Job', scale: 'per_job' },
|
| 174 |
};
|
| 175 |
|
| 176 |
function selectTier(tier) {
|
| 177 |
selectedTier = tier;
|
| 178 |
document.querySelectorAll('.tier').forEach(el => el.classList.remove('selected'));
|
| 179 |
-
document.getElementById('tier-' + tier)
|
|
|
|
| 180 |
document.getElementById('btn_label').textContent = tierConfig[tier].label;
|
| 181 |
document.getElementById('api_note').style.display = tier === 'per_job' ? 'block' : 'none';
|
| 182 |
}
|
|
@@ -190,11 +181,15 @@
|
|
| 190 |
const successUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=success';
|
| 191 |
const cancelUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=cancelled';
|
| 192 |
|
| 193 |
-
// Per-job
|
| 194 |
if (selectedTier === 'per_job') {
|
| 195 |
window.location.href = `mailto:hello@signalloomai.com?subject=Per-Job+API+Setup&body=Hi, I'd like to set up per-job API access.%0A%0AEmail: ${encodeURIComponent(email || 'your@email.com')}%0A%0AThanks!`;
|
| 196 |
return;
|
| 197 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
const formData = new FormData();
|
| 200 |
formData.append('tier', selectedTier);
|
|
@@ -206,7 +201,7 @@
|
|
| 206 |
btn.classList.add('loading');
|
| 207 |
|
| 208 |
try {
|
| 209 |
-
const res = await fetch('https://api.signalloomai.com/v1/
|
| 210 |
method: 'POST',
|
| 211 |
body: formData,
|
| 212 |
});
|
|
@@ -214,7 +209,7 @@
|
|
| 214 |
if (!res.ok || data.error) throw new Error(data.detail || data.error || 'Checkout unavailable');
|
| 215 |
window.location.href = data.checkout_url;
|
| 216 |
} catch (e) {
|
| 217 |
-
err.textContent = 'Checkout
|
| 218 |
err.style.display = 'block';
|
| 219 |
btn.disabled = false;
|
| 220 |
btn.classList.remove('loading');
|
|
|
|
| 105 |
</div>
|
| 106 |
<div class="tier-desc">$99/mo value. Transcription platform + LoomLensAI Power Pro + All future tools. Monthly: locks at $59 for 6 mo, then $99/mo. Annual: $449/yr (locks 12 mo, then $699/yr.</div>
|
| 107 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
<!-- Pro -->
|
| 109 |
+
<div class="tier selected" id="tier-pro" onclick="selectTier('pro')">
|
| 110 |
<div class="tier-header">
|
| 111 |
+
<span class="tier-name">Pro — LoomLensAI</span>
|
| 112 |
+
<span class="tier-price">$15<span>/mo</span></span>
|
| 113 |
</div>
|
| 114 |
+
<div class="tier-desc">1,000 estimates/month · Introductory price · Overage at $0.033/job</div>
|
| 115 |
</div>
|
| 116 |
|
| 117 |
<!-- Per-job -->
|
| 118 |
<div class="tier" id="tier-per_job" onclick="selectTier('per_job')">
|
| 119 |
<div class="tier-header">
|
| 120 |
<span class="tier-name">Per-Job — For Agents & Pipelines</span>
|
| 121 |
+
<span class="tier-price">$0.033<span>/job</span></span>
|
| 122 |
</div>
|
| 123 |
<div class="tier-desc">No monthly commitment · Pay only for what you use · Ideal for automated pipelines</div>
|
| 124 |
</div>
|
| 125 |
|
| 126 |
<ul class="features">
|
| 127 |
+
|
| 128 |
<li>All major AI models included</li>
|
| 129 |
<li>API access for agents and pipelines</li>
|
| 130 |
<li>Cancel anytime</li>
|
|
|
|
| 136 |
</div>
|
| 137 |
|
| 138 |
<button class="btn-checkout" id="btn_checkout" onclick="startCheckout()">
|
| 139 |
+
<span id="btn_label">Get Pro — $15/mo</span>
|
| 140 |
</button>
|
| 141 |
<div class="error" id="error_msg"></div>
|
| 142 |
|
|
|
|
| 144 |
<strong>Per-Job API:</strong> After checkout, we'll set up your API key and send credentials within 2 hours. Contact <a href="mailto:hello@signalloomai.com">hello@signalloomai.com</a> for immediate setup.
|
| 145 |
</div>
|
| 146 |
|
| 147 |
+
<p class="note" style="color:var(--yellow);margin-top:12px;">★ Basic and Pro subscriptions are not eligible for the Builder API Revenue Share Program.</p>
|
| 148 |
<p class="note">
|
| 149 |
Secured by Stripe · Cancel anytime · <a href="https://signalloomai.com" target="_blank">A SignalloomAI™ Product</a>
|
| 150 |
</p>
|
|
|
|
| 159 |
|
| 160 |
const tierConfig = {
|
| 161 |
signalloomai_early: { label: 'Claim Early Adopter Price — $59/mo', scale: 'monthly' },
|
| 162 |
+
pro: { label: 'Get Pro — $15/mo', scale: 'monthly' },
|
| 163 |
+
per_job: { label: 'Start Per-Job', scale: 'per_job' },
|
|
|
|
| 164 |
};
|
| 165 |
|
| 166 |
function selectTier(tier) {
|
| 167 |
selectedTier = tier;
|
| 168 |
document.querySelectorAll('.tier').forEach(el => el.classList.remove('selected'));
|
| 169 |
+
const el = document.getElementById('tier-' + tier);
|
| 170 |
+
if (el) el.classList.add('selected');
|
| 171 |
document.getElementById('btn_label').textContent = tierConfig[tier].label;
|
| 172 |
document.getElementById('api_note').style.display = tier === 'per_job' ? 'block' : 'none';
|
| 173 |
}
|
|
|
|
| 181 |
const successUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=success';
|
| 182 |
const cancelUrl = 'https://huggingface.co/spaces/Signalloom-ai/LoomLensAI?checkout=cancelled';
|
| 183 |
|
| 184 |
+
// Per-job and Early Adopter don't use Stripe checkout yet — contact us manually
|
| 185 |
if (selectedTier === 'per_job') {
|
| 186 |
window.location.href = `mailto:hello@signalloomai.com?subject=Per-Job+API+Setup&body=Hi, I'd like to set up per-job API access.%0A%0AEmail: ${encodeURIComponent(email || 'your@email.com')}%0A%0AThanks!`;
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
+
if (selectedTier === 'signalloomai_early') {
|
| 190 |
+
window.location.href = `mailto:hello@signalloomai.com?subject=SignalloomAI+Pro+Early+Adopter&body=Hi, I'd like to claim the $59/mo Early Adopter price for SignalloomAI Pro.%0A%0AEmail: ${encodeURIComponent(email || 'your@email.com')}%0A%0AThanks!`;
|
| 191 |
+
return;
|
| 192 |
+
}
|
| 193 |
|
| 194 |
const formData = new FormData();
|
| 195 |
formData.append('tier', selectedTier);
|
|
|
|
| 201 |
btn.classList.add('loading');
|
| 202 |
|
| 203 |
try {
|
| 204 |
+
const res = await fetch('https://api.signalloomai.com/v1/stripe/checkout', {
|
| 205 |
method: 'POST',
|
| 206 |
body: formData,
|
| 207 |
});
|
|
|
|
| 209 |
if (!res.ok || data.error) throw new Error(data.detail || data.error || 'Checkout unavailable');
|
| 210 |
window.location.href = data.checkout_url;
|
| 211 |
} catch (e) {
|
| 212 |
+
err.textContent = 'Checkout unavailable right now — email hello@signalloomai.com and we'll get you set up manually.';
|
| 213 |
err.style.display = 'block';
|
| 214 |
btn.disabled = false;
|
| 215 |
btn.classList.remove('loading');
|
package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "loomlens-live",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "LoomLens Live — Pre-flight cost intelligence for LLM prompts",
|
| 5 |
+
"author": "Aster Vale / AEHS",
|
| 6 |
+
"license": "UNLICENSED",
|
| 7 |
+
"private": true,
|
| 8 |
+
"type": "module",
|
| 9 |
+
"scripts": {
|
| 10 |
+
"build": "node build.mjs",
|
| 11 |
+
"build:sidebar": "node build.mjs sidebar",
|
| 12 |
+
"typecheck": "tsc --noEmit"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {},
|
| 15 |
+
"devDependencies": {},
|
| 16 |
+
"files": [
|
| 17 |
+
"loomlens-clusters.js",
|
| 18 |
+
"loomlens-engine.js",
|
| 19 |
+
"loomlens-sidebar.html"
|
| 20 |
+
]
|
| 21 |
+
}
|
x-thread-loomlens-live.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# X Thread — LoomLens Live for OpenClaw
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
**Tweet 1 (Hook):**
|
| 6 |
+
Most AI agents have no idea what they're costing you.
|
| 7 |
+
|
| 8 |
+
They pick a model. Any model. Usually the one you defaulted to.
|
| 9 |
+
|
| 10 |
+
What if your agent could see the cost of every option *before* it runs — and route accordingly?
|
| 11 |
+
|
| 12 |
+
Introducing **LoomLens Live** for OpenClaw. 🧵
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
**Tweet 2 (What it is):**
|
| 17 |
+
LoomLens Live is a real-time cost estimation panel that lives inside the OpenClaw agent sidebar.
|
| 18 |
+
|
| 19 |
+
Every time you run a prompt, you see:
|
| 20 |
+
→ Cost across 6 model clusters
|
| 21 |
+
→ The cheapest viable option in each cluster
|
| 22 |
+
→ One-click dispatch to any model you choose
|
| 23 |
+
|
| 24 |
+
No switching tabs. No external dashboards. It's just *there*.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
**Tweet 3 (How it works):**
|
| 29 |
+
LoomLens estimates cost before you run — using token counts and published model pricing.
|
| 30 |
+
|
| 31 |
+
No, it doesn't log your prompts.
|
| 32 |
+
No, your data doesn't go anywhere.
|
| 33 |
+
Yes, it's actually fast.
|
| 34 |
+
|
| 35 |
+
Free tier: 3 estimates/day, no account needed.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
**Tweet 4 (The clusters):**
|
| 40 |
+
6 model clusters, each optimized for a different workload:
|
| 41 |
+
|
| 42 |
+
⚡ Flash Reasoning — MiniMax, Gemini Flash, Haiku, DeepSeek
|
| 43 |
+
🚀 Fast General — GPT-4o Mini, Gemini 2.0 Flash, Grok-3
|
| 44 |
+
💻 Balanced Coding — Claude Sonnet 4, GPT-4o
|
| 45 |
+
🧠 Power — Claude Opus 4, Claude Code
|
| 46 |
+
👁 Vision — GPT-4o, Claude 3.5
|
| 47 |
+
🎯 Custom — define your own
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
**Tweet 5 (The economics):**
|
| 52 |
+
We ran a support pipeline that cost $4.70/job on GPT-4o.
|
| 53 |
+
|
| 54 |
+
LoomLens found the same output quality on a lighter model: $0.19/job.
|
| 55 |
+
|
| 56 |
+
Same input. Same output. 96% less.
|
| 57 |
+
|
| 58 |
+
Multiply that by your monthly API volume and the number stops being abstract.
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
**Tweet 6 (For builders):**
|
| 63 |
+
LoomLens Live installs as an OpenClaw skill:
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
openclaw skills install loomlens-live
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Works in every session. Persists across sessions.
|
| 70 |
+
|
| 71 |
+
Or use the API directly:
|
| 72 |
+
```
|
| 73 |
+
npm install signalloomai
|
| 74 |
+
pip install signalloom
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
**Tweet 7 (Rev-share):**
|
| 80 |
+
Running AI tooling for a team or platform?
|
| 81 |
+
|
| 82 |
+
Developers earn **23–28% revenue share** on billable estimate calls generated through their integrations.
|
| 83 |
+
|
| 84 |
+
10K mins/month → Loom Partner (23%)
|
| 85 |
+
100K mins/month → Loom Elite (28%)
|
| 86 |
+
|
| 87 |
+
Built something with the API? You're earning on it passively.
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
**Tweet 8 (CTA):**
|
| 92 |
+
OpenClaw users: install it today. It's already running in our own production workflow.
|
| 93 |
+
|
| 94 |
+
Everyone else: the API is live. The dashboard is live.
|
| 95 |
+
|
| 96 |
+
**https://huggingface.co/spaces/Signalloom-ai/LoomLensAI**
|
| 97 |
+
|
| 98 |
+
*LoomLensAI — Every prompt. Right model. Right price. Every time.*
|
| 99 |
+
|
| 100 |
+
#AI #OpenAI #Claude #LangChain #DevTools #API #AIOps
|