cycls 0.0.2.93__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ :root{--bg-primary: #ffffff;--bg-secondary: #f9fafb;--bg-tertiary: #f3f4f6;--bg-sidebar: rgba(255, 255, 255, .8);--bg-hover: rgba(0, 0, 0, .05);--bg-active: rgba(0, 0, 0, .07);--bg-overlay: rgba(0, 0, 0, .25);--text-primary: #0d0d0d;--text-secondary: #374151;--text-tertiary: #6b6b6b;--text-muted: #9ca3af;--border-primary: rgba(0, 0, 0, .1);--border-secondary: rgba(0, 0, 0, .06);--accent-primary: #10a37f;--accent-hover: #0d8a6c;--scrollbar-thumb: #d1d1d1;--scrollbar-thumb-hover: #b1b1b1;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -1px rgba(0, 0, 0, .06);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -2px rgba(0, 0, 0, .05);--code-bg: #f3f4f6;--code-text: #1f2937;--msg-user-bg: #f3f4f6;--msg-assistant-bg: transparent;--input-bg: #ffffff;--input-border: #e5e7eb;--input-focus-border: #10a37f;--btn-primary-bg: #0d0d0d;--btn-primary-text: #ffffff;--btn-secondary-bg: transparent;--btn-secondary-text: #0d0d0d}.dark,[data-theme=dark]{--bg-primary: #212121;--bg-secondary: #171717;--bg-tertiary: #2f2f2f;--bg-sidebar: rgba(23, 23, 23, .95);--bg-hover: rgba(255, 255, 255, .08);--bg-active: rgba(255, 255, 255, .12);--bg-overlay: rgba(0, 0, 0, .5);--text-primary: #ececec;--text-secondary: #c5c5c5;--text-tertiary: #8e8e8e;--text-muted: #6b6b6b;--border-primary: rgba(255, 255, 255, .1);--border-secondary: rgba(255, 255, 255, .06);--accent-primary: #10a37f;--accent-hover: #1abc94;--scrollbar-thumb: #4a4a4a;--scrollbar-thumb-hover: #5a5a5a;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .4), 0 2px 4px -1px rgba(0, 0, 0, .3);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .4), 0 4px 6px -2px rgba(0, 0, 0, .3);--code-bg: #2f2f2f;--code-text: #e5e7eb;--msg-user-bg: #2f2f2f;--msg-assistant-bg: transparent;--input-bg: #2f2f2f;--input-border: #424242;--input-focus-border: #10a37f;--btn-primary-bg: #ececec;--btn-primary-text: #0d0d0d;--btn-secondary-bg: transparent;--btn-secondary-text: #ececec}@media(prefers-color-scheme:dark){:root:not(.light):not([data-theme=light]){--bg-primary: #212121;--bg-secondary: #171717;--bg-tertiary: #2f2f2f;--bg-sidebar: rgba(23, 23, 23, .95);--bg-hover: rgba(255, 255, 255, .08);--bg-active: rgba(255, 255, 255, .12);--bg-overlay: rgba(0, 0, 0, .5);--text-primary: #ececec;--text-secondary: #c5c5c5;--text-tertiary: #8e8e8e;--text-muted: #6b6b6b;--border-primary: rgba(255, 255, 255, .1);--border-secondary: rgba(255, 255, 255, .06);--accent-primary: #10a37f;--accent-hover: #1abc94;--scrollbar-thumb: #4a4a4a;--scrollbar-thumb-hover: #5a5a5a;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .4), 0 2px 4px -1px rgba(0, 0, 0, .3);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .4), 0 4px 6px -2px rgba(0, 0, 0, .3);--code-bg: #2f2f2f;--code-text: #e5e7eb;--msg-user-bg: #2f2f2f;--msg-assistant-bg: transparent;--input-bg: #2f2f2f;--input-border: #424242;--input-focus-border: #10a37f;--btn-primary-bg: #ececec;--btn-primary-text: #0d0d0d;--btn-secondary-bg: transparent;--btn-secondary-text: #ececec}}body{background-color:var(--bg-primary);color:var(--text-primary);transition:background-color .2s ease,color .2s ease}.cl-internal-p8bmz4{box-shadow:none!important;border:1px solid var(--border-primary);box-shadow:var(--shadow-sm)}.cl-drawerRoot{z-index:99!important}.cl-pricingTableCardFooterButton{padding:10px}.cl-pricingTableCardFee{font-size:2rem}.cl-pricingTableCardTitleContainer{margin-bottom:10px}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) transparent}.scrollbar-thin::-webkit-scrollbar{width:6px}.scrollbar-thin::-webkit-scrollbar-track{background:transparent}.scrollbar-thin::-webkit-scrollbar-thumb{background-color:var(--scrollbar-thumb);border-radius:3px}.scrollbar-thin::-webkit-scrollbar-thumb:hover{background-color:var(--scrollbar-thumb-hover)}.scrollbar-thin::-webkit-scrollbar-thumb{background-color:transparent}.scrollbar-thin:hover::-webkit-scrollbar-thumb{background-color:var(--scrollbar-thumb)}.sidebar-transition{transition:transform .3s ease-in-out,width .3s ease-in-out}.sidebar-overlay{z-index:40}.sidebar-panel{z-index:50}@keyframes slideDown{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.dropdown-menu{animation:slideDown .15s ease-out}.sidebar-item:focus-visible{outline:2px solid var(--text-primary);outline-offset:-2px;border-radius:8px}.user-profile-item:hover .user-avatar{transform:scale(1.02)}.chat-title-fade{mask-image:linear-gradient(to right,black 85%,transparent 100%);-webkit-mask-image:linear-gradient(to right,black 85%,transparent 100%)}.theme-toggle-icon{transition:transform .3s ease,opacity .2s ease}.theme-toggle:hover .theme-toggle-icon{transform:rotate(15deg)}.theme-transition{transition:background-color .2s ease,color .2s ease,border-color .2s ease,box-shadow .2s ease}.dark .prose,[data-theme=dark] .prose{--tw-prose-body: var(--text-primary);--tw-prose-headings: var(--text-primary);--tw-prose-lead: var(--text-secondary);--tw-prose-links: var(--accent-primary);--tw-prose-bold: var(--text-primary);--tw-prose-counters: var(--text-tertiary);--tw-prose-bullets: var(--text-tertiary);--tw-prose-hr: var(--border-primary);--tw-prose-quotes: var(--text-secondary);--tw-prose-quote-borders: var(--border-primary);--tw-prose-captions: var(--text-tertiary);--tw-prose-code: var(--text-primary);--tw-prose-pre-code: var(--code-text);--tw-prose-pre-bg: var(--code-bg);--tw-prose-th-borders: var(--border-primary);--tw-prose-td-borders: var(--border-secondary)}@media(prefers-color-scheme:dark){:root:not(.light):not([data-theme=light]) .prose{--tw-prose-body: var(--text-primary);--tw-prose-headings: var(--text-primary);--tw-prose-lead: var(--text-secondary);--tw-prose-links: var(--accent-primary);--tw-prose-bold: var(--text-primary);--tw-prose-counters: var(--text-tertiary);--tw-prose-bullets: var(--text-tertiary);--tw-prose-hr: var(--border-primary);--tw-prose-quotes: var(--text-secondary);--tw-prose-quote-borders: var(--border-primary);--tw-prose-captions: var(--text-tertiary);--tw-prose-code: var(--text-primary);--tw-prose-pre-code: var(--code-text);--tw-prose-pre-bg: var(--code-bg);--tw-prose-th-borders: var(--border-primary);--tw-prose-td-borders: var(--border-secondary)}}.dark pre code.hljs,[data-theme=dark] pre code.hljs{background:var(--code-bg)!important}@media(prefers-color-scheme:dark){:root:not(.light):not([data-theme=light]) pre code.hljs{background:var(--code-bg)!important}}
@@ -0,0 +1,32 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>AI Agent</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <!-- Default SEO tags - dynamically updated by SEOHead component -->
8
+ <meta name="robots" content="noindex, nofollow" />
9
+ <meta name="googlebot" content="noindex, nofollow" />
10
+ <meta name="description" content="AI-powered chat interface" />
11
+ <script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
12
+ <link
13
+ rel="stylesheet"
14
+ href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github-dark.min.css"
15
+ />
16
+ <link
17
+ rel="stylesheet"
18
+ href="https://esm.sh/katex@0.16.8/dist/katex.min.css"
19
+ />
20
+ <script type="module" crossorigin src="/assets/index-Xh0IeurI.js"></script>
21
+ <link rel="stylesheet" crossorigin href="/assets/index-oGkkm3Z8.css">
22
+ </head>
23
+ <body style="overflow-x: hidden">
24
+ <div id="root"></div>
25
+
26
+ <script>
27
+ tailwind.config = {
28
+ darkMode: "class",
29
+ };
30
+ </script>
31
+ </body>
32
+ </html>
@@ -0,0 +1,298 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Spark - Native Components</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
11
+ <script>
12
+ tailwind.config = { darkMode: "class" };
13
+ </script>
14
+ <style>
15
+ :root {
16
+ --bg-primary: #ffffff;
17
+ --bg-secondary: #f9fafb;
18
+ --text-primary: #0d0d0d;
19
+ --text-secondary: #6b7280;
20
+ --border-color: #e5e7eb;
21
+ --accent: #10a37f;
22
+ }
23
+ .dark {
24
+ --bg-primary: #212121;
25
+ --bg-secondary: #171717;
26
+ --text-primary: #ececec;
27
+ --text-secondary: #9ca3af;
28
+ --border-color: #374151;
29
+ --accent: #10a37f;
30
+ }
31
+ body {
32
+ background: var(--bg-primary);
33
+ color: var(--text-primary);
34
+ }
35
+ .thinking-bubble {
36
+ background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
37
+ border-left: 3px solid var(--accent);
38
+ }
39
+ .dark .thinking-bubble {
40
+ background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
41
+ }
42
+ .callout-info { border-left-color: #3b82f6; background: #eff6ff; }
43
+ .callout-warning { border-left-color: #f59e0b; background: #fffbeb; }
44
+ .callout-error { border-left-color: #ef4444; background: #fef2f2; }
45
+ .callout-success { border-left-color: #10b981; background: #ecfdf5; }
46
+ .dark .callout-info { background: #1e3a5f; }
47
+ .dark .callout-warning { background: #422006; }
48
+ .dark .callout-error { background: #450a0a; }
49
+ .dark .callout-success { background: #064e3b; }
50
+ @keyframes pulse-border {
51
+ 0%, 100% { border-color: var(--accent); }
52
+ 50% { border-color: transparent; }
53
+ }
54
+ .streaming { animation: pulse-border 1s infinite; }
55
+ </style>
56
+ </head>
57
+ <body class="dark">
58
+ <div id="app" class="min-h-screen flex flex-col">
59
+ <!-- Header -->
60
+ <header class="border-b border-[var(--border-color)] p-4">
61
+ <div class="max-w-3xl mx-auto flex items-center justify-between">
62
+ <h1 class="text-xl font-semibold">Spark</h1>
63
+ <button onclick="toggleDark()" class="p-2 rounded hover:bg-[var(--bg-secondary)]">
64
+ <span id="theme-icon">🌙</span>
65
+ </button>
66
+ </div>
67
+ </header>
68
+
69
+ <!-- Messages -->
70
+ <main class="flex-1 overflow-y-auto p-4">
71
+ <div id="messages" class="max-w-3xl mx-auto space-y-4"></div>
72
+ </main>
73
+
74
+ <!-- Input -->
75
+ <footer class="border-t border-[var(--border-color)] p-4">
76
+ <form id="chat-form" class="max-w-3xl mx-auto flex gap-2">
77
+ <input
78
+ type="text"
79
+ id="input"
80
+ placeholder="Send a message..."
81
+ class="flex-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] px-4 py-3 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
82
+ />
83
+ <button
84
+ type="submit"
85
+ class="rounded-lg bg-[var(--accent)] px-6 py-3 text-white font-medium hover:opacity-90"
86
+ >
87
+ Send
88
+ </button>
89
+ </form>
90
+ </footer>
91
+ </div>
92
+
93
+ <script>
94
+ // State
95
+ let messages = [];
96
+ let isDark = true;
97
+
98
+ // Toggle dark mode
99
+ function toggleDark() {
100
+ isDark = !isDark;
101
+ document.body.classList.toggle('dark', isDark);
102
+ document.getElementById('theme-icon').textContent = isDark ? '🌙' : '☀️';
103
+ }
104
+
105
+ // Native component renderers
106
+ const components = {
107
+ text: (props) => marked.parse(props.text || '', { breaks: true }),
108
+
109
+ thinking: (props) => `
110
+ <div class="thinking-bubble rounded-lg p-4 my-3 italic text-[var(--text-secondary)]">
111
+ <div class="flex items-center gap-2 mb-2 text-sm font-medium text-[var(--accent)]">
112
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
114
+ </svg>
115
+ Thinking
116
+ </div>
117
+ <div>${props.thinking}</div>
118
+ </div>
119
+ `,
120
+
121
+ table: (props) => `
122
+ <div class="overflow-x-auto my-3">
123
+ <table class="min-w-full border border-[var(--border-color)] rounded-lg overflow-hidden">
124
+ ${props.headers ? `
125
+ <thead class="bg-[var(--bg-secondary)]">
126
+ <tr>
127
+ ${props.headers.map(h => `<th class="px-4 py-2 text-left font-medium">${h}</th>`).join('')}
128
+ </tr>
129
+ </thead>
130
+ ` : ''}
131
+ <tbody>
132
+ ${(props.rows || []).map((row, i) => `
133
+ <tr class="${i % 2 ? 'bg-[var(--bg-secondary)]' : ''}">
134
+ ${row.map(cell => `<td class="px-4 py-2 border-t border-[var(--border-color)]">${cell}</td>`).join('')}
135
+ </tr>
136
+ `).join('')}
137
+ </tbody>
138
+ </table>
139
+ </div>
140
+ `,
141
+
142
+ code: (props) => {
143
+ const highlighted = props.language
144
+ ? hljs.highlight(props.code, { language: props.language }).value
145
+ : hljs.highlightAuto(props.code).value;
146
+ return `
147
+ <div class="my-3 rounded-lg overflow-hidden border border-[var(--border-color)]">
148
+ <div class="bg-[var(--bg-secondary)] px-4 py-2 text-xs text-[var(--text-secondary)] flex justify-between items-center">
149
+ <span>${props.language || 'code'}</span>
150
+ <button onclick="copyCode(this)" class="hover:text-[var(--accent)]">Copy</button>
151
+ </div>
152
+ <pre class="p-4 overflow-x-auto bg-[#0d1117]"><code class="text-sm">${highlighted}</code></pre>
153
+ </div>
154
+ `;
155
+ },
156
+
157
+ callout: (props) => `
158
+ <div class="callout-${props.style || 'info'} border-l-4 rounded-r-lg p-4 my-3">
159
+ ${props.title ? `<div class="font-semibold mb-1">${props.title}</div>` : ''}
160
+ <div class="text-sm">${props.callout}</div>
161
+ </div>
162
+ `,
163
+
164
+ image: (props) => `
165
+ <div class="my-3">
166
+ <img src="${props.src}" alt="${props.alt || ''}" class="rounded-lg max-w-full" />
167
+ ${props.caption ? `<p class="text-sm text-[var(--text-secondary)] mt-2 text-center">${props.caption}</p>` : ''}
168
+ </div>
169
+ `,
170
+
171
+ button: (props) => `
172
+ <button
173
+ onclick="handleComponentAction('${props.action || ''}', ${JSON.stringify(props.payload || {})})"
174
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-medium hover:opacity-90 my-2"
175
+ >
176
+ ${props.label}
177
+ </button>
178
+ `
179
+ };
180
+
181
+ // Copy code helper
182
+ function copyCode(btn) {
183
+ const code = btn.closest('.rounded-lg').querySelector('code').textContent;
184
+ navigator.clipboard.writeText(code);
185
+ btn.textContent = 'Copied!';
186
+ setTimeout(() => btn.textContent = 'Copy', 2000);
187
+ }
188
+
189
+ // Component action handler (for interactive components)
190
+ function handleComponentAction(action, payload) {
191
+ console.log('Component action:', action, payload);
192
+ // Could send back to server or handle locally
193
+ }
194
+
195
+ // Render a single message
196
+ function renderMessage(msg) {
197
+ const wrapper = document.createElement('div');
198
+ wrapper.className = `rounded-lg p-4 ${msg.role === 'user'
199
+ ? 'bg-[var(--accent)] text-white ml-12'
200
+ : 'bg-[var(--bg-secondary)] mr-12'}`;
201
+
202
+ if (msg.role === 'user') {
203
+ wrapper.innerHTML = `<div>${msg.content}</div>`;
204
+ } else {
205
+ let html = '';
206
+ for (const part of msg.parts || [])
207
+ html += components[part.type]?.(part) || '';
208
+ wrapper.innerHTML = `<div class="prose prose-invert max-w-none">${html}</div>`;
209
+ }
210
+ return wrapper;
211
+ }
212
+
213
+ // Render all messages
214
+ function render() {
215
+ const container = document.getElementById('messages');
216
+ container.innerHTML = '';
217
+ messages.forEach(msg => container.appendChild(renderMessage(msg)));
218
+ container.scrollTop = container.scrollHeight;
219
+ }
220
+
221
+ // Stream response from server
222
+ async function streamResponse(userMessage) {
223
+ messages.push({ role: 'user', content: userMessage });
224
+ messages.push({ role: 'assistant', parts: [] });
225
+ render();
226
+
227
+ const response = await fetch('/chat/cycls', {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: JSON.stringify({
231
+ messages: messages.slice(0, -1).map(m => ({ role: m.role, content: m.content, parts: m.parts }))
232
+ })
233
+ });
234
+
235
+ const reader = response.body.getReader();
236
+ const decoder = new TextDecoder();
237
+ let buffer = '';
238
+ let assistantMsg = messages[messages.length - 1];
239
+ let currentPart = null;
240
+
241
+ while (true) {
242
+ const { done, value } = await reader.read();
243
+ if (done) break;
244
+
245
+ buffer += decoder.decode(value, { stream: true });
246
+ const lines = buffer.split('\n');
247
+ buffer = lines.pop() || '';
248
+
249
+ for (const line of lines) {
250
+ if (!line.startsWith('data: ')) continue;
251
+ const data = line.slice(6);
252
+ if (data === '[DONE]') continue;
253
+
254
+ try {
255
+ const item = JSON.parse(data);
256
+ const type = item.type;
257
+
258
+ // Same type as current? Append content
259
+ if (currentPart && currentPart.type === type) {
260
+ if (item.row) currentPart.rows.push(item.row);
261
+ else if (item[type]) currentPart[type] = (currentPart[type] || '') + item[type];
262
+ } else {
263
+ // New component
264
+ currentPart = { ...item };
265
+ if (item.headers) currentPart.rows = [];
266
+ assistantMsg.parts.push(currentPart);
267
+ }
268
+ render();
269
+ } catch (e) {
270
+ console.error('Parse error:', e, data);
271
+ }
272
+ }
273
+ }
274
+ assistantMsg.parts = assistantMsg.parts.filter(p =>
275
+ p.type !== 'text' || p.text?.trim()
276
+ );
277
+ render();
278
+ console.log(messages);
279
+ }
280
+
281
+ // Form submit
282
+ document.getElementById('chat-form').addEventListener('submit', async (e) => {
283
+ e.preventDefault();
284
+ const input = document.getElementById('input');
285
+ const message = input.value.trim();
286
+ if (!message) return;
287
+ input.value = '';
288
+ await streamResponse(message);
289
+ });
290
+
291
+ // Highlight code blocks after render
292
+ const observer = new MutationObserver(() => {
293
+ document.querySelectorAll('pre code:not(.hljs)').forEach(el => hljs.highlightElement(el));
294
+ });
295
+ observer.observe(document.getElementById('messages'), { childList: true, subtree: true });
296
+ </script>
297
+ </body>
298
+ </html>
cycls/web.py ADDED
@@ -0,0 +1,171 @@
1
+ import json, inspect
2
+ from pathlib import Path
3
+ from pydantic import BaseModel
4
+ from typing import Optional, Union, Any
5
+ from .auth import PK_LIVE, PK_TEST, JWKS_PROD, JWKS_TEST
6
+
7
+ class Config(BaseModel):
8
+ public_path: str = "theme"
9
+ header: Optional[str] = None
10
+ intro: Optional[str] = None
11
+ title: Optional[str] = None
12
+ prod: bool = False
13
+ auth: bool = False
14
+ plan: str = "free"
15
+ analytics: bool = False
16
+ org: Optional[str] = None
17
+ pk: Optional[str] = None
18
+ jwks: Optional[str] = None
19
+ state: Union[bool, str] = False
20
+
21
+ def set_prod(self, prod: bool):
22
+ self.prod = prod
23
+ self.pk = PK_LIVE if prod else PK_TEST
24
+ self.jwks = JWKS_PROD if prod else JWKS_TEST
25
+
26
+ async def openai_encoder(stream):
27
+ if inspect.isasyncgen(stream):
28
+ async for msg in stream:
29
+ if msg: yield f"data: {json.dumps({'choices': [{'delta': {'content': msg}}]})}\n\n"
30
+ else:
31
+ for msg in stream:
32
+ if msg: yield f"data: {json.dumps({'choices': [{'delta': {'content': msg}}]})}\n\n"
33
+ yield "data: [DONE]\n\n"
34
+
35
+ def sse(item):
36
+ if not item: return None
37
+ if not isinstance(item, dict): item = {"type": "text", "text": item}
38
+ return f"data: {json.dumps(item)}\n\n"
39
+
40
+ async def encoder(stream):
41
+ if inspect.isasyncgen(stream):
42
+ async for item in stream:
43
+ if msg := sse(item): yield msg
44
+ else:
45
+ for item in stream:
46
+ if msg := sse(item): yield msg
47
+ yield "data: [DONE]\n\n"
48
+
49
+ class Messages(list):
50
+ """A list that provides text-only messages by default, with .raw for full data."""
51
+ def __init__(self, raw_messages):
52
+ self._raw = raw_messages
53
+ text_messages = []
54
+ for m in raw_messages:
55
+ text_content = "".join(
56
+ p.get("text", "") for p in m.get("parts", []) if p.get("type") == "text"
57
+ )
58
+ text_messages.append({
59
+ "role": m.get("role"),
60
+ "content": m.get("content") or text_content
61
+ })
62
+ super().__init__(text_messages)
63
+
64
+ @property
65
+ def raw(self):
66
+ return self._raw
67
+
68
+ def web(func, config):
69
+ from fastapi import FastAPI, Request, HTTPException, status, Depends
70
+ from fastapi.responses import StreamingResponse
71
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
72
+ import jwt
73
+ from jwt import PyJWKClient
74
+ from pydantic import EmailStr
75
+ from typing import List, Optional, Any
76
+ from fastapi.staticfiles import StaticFiles
77
+
78
+ if isinstance(config, dict):
79
+ config = Config(**config)
80
+
81
+ jwks = PyJWKClient(config.jwks)
82
+
83
+ class User(BaseModel):
84
+ id: str
85
+ name: Optional[str] = None
86
+ email: EmailStr
87
+ org: Optional[str] = None
88
+ plans: List[str] = []
89
+
90
+ class Context(BaseModel):
91
+ messages: Any
92
+ user: Optional[User] = None
93
+ state: Optional[Any] = None
94
+
95
+ model_config = {"arbitrary_types_allowed": True}
96
+
97
+ @property
98
+ def last_message(self) -> str:
99
+ if self.messages:
100
+ return self.messages[-1].get("content", "")
101
+ return ""
102
+
103
+ @property
104
+ def kv(self):
105
+ return self.state.kv if self.state else None
106
+
107
+ @property
108
+ def fs(self):
109
+ return self.state.fs if self.state else None
110
+
111
+ app = FastAPI()
112
+ bearer_scheme = HTTPBearer()
113
+
114
+ def validate(bearer: HTTPAuthorizationCredentials = Depends(bearer_scheme)):
115
+ try:
116
+ key = jwks.get_signing_key_from_jwt(bearer.credentials)
117
+ decoded = jwt.decode(bearer.credentials, key.key, algorithms=["RS256"], leeway=10)
118
+ return {"type": "user",
119
+ "user": {"id": decoded.get("id"), "name": decoded.get("name"), "email": decoded.get("email"), "org": decoded.get("org"),
120
+ "plans": decoded.get("public", {}).get("plans", [])}}
121
+ except jwt.ExpiredSignatureError:
122
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired", headers={"WWW-Authenticate": "Bearer"})
123
+ except jwt.InvalidTokenError as e:
124
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e}", headers={"WWW-Authenticate": "Bearer"})
125
+ except Exception as e:
126
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Auth error: {e}", headers={"WWW-Authenticate": "Bearer"})
127
+
128
+ @app.post("/")
129
+ @app.post("/chat/cycls")
130
+ @app.post("/chat/completions")
131
+ async def back(request: Request, jwt: Optional[dict] = Depends(validate) if config.auth else None):
132
+ data = await request.json()
133
+ messages = data.get("messages")
134
+ user_data = jwt.get("user") if jwt else None
135
+ user = User(**user_data) if user_data else None
136
+
137
+ # Initialize state scoped to user
138
+ state_instance = None
139
+ if config.state:
140
+ from .state import create_state
141
+ user_id = user.id if user else "anonymous"
142
+ state_instance = await create_state(user_id)
143
+
144
+ context = Context(messages=Messages(messages), user=user, state=state_instance)
145
+ stream = await func(context) if inspect.iscoroutinefunction(func) else func(context)
146
+
147
+ if request.url.path == "/chat/completions":
148
+ stream = openai_encoder(stream)
149
+ elif request.url.path == "/chat/cycls":
150
+ stream = encoder(stream)
151
+ return StreamingResponse(stream, media_type="text/event-stream")
152
+
153
+ @app.get("/config")
154
+ async def get_config():
155
+ return config
156
+
157
+ if Path("public").is_dir():
158
+ app.mount("/public", StaticFiles(directory="public", html=True))
159
+ app.mount("/", StaticFiles(directory=config.public_path, html=True))
160
+
161
+ return app
162
+
163
+ def serve(func, config, name, port):
164
+ import uvicorn, logging
165
+ from dotenv import load_dotenv
166
+ load_dotenv()
167
+ if isinstance(config, dict):
168
+ config = Config(**config)
169
+ logging.getLogger("uvicorn.error").addFilter(lambda r: "0.0.0.0" not in r.getMessage())
170
+ print(f"\n🔨 {name} => http://localhost:{port}\n")
171
+ uvicorn.run(web(func, config), host="0.0.0.0", port=port)