smolerclaw 0.1.0
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.
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/release.yml +67 -0
- package/bun.lock +33 -0
- package/dist/index.js +321 -0
- package/dist/tinyclaw.exe +0 -0
- package/install.ps1 +119 -0
- package/package.json +25 -0
- package/skills/business.md +77 -0
- package/skills/default.md +77 -0
- package/src/ansi.ts +164 -0
- package/src/approval.ts +74 -0
- package/src/auth.ts +125 -0
- package/src/briefing.ts +52 -0
- package/src/claude.ts +267 -0
- package/src/cli.ts +137 -0
- package/src/clipboard.ts +27 -0
- package/src/config.ts +87 -0
- package/src/context-window.ts +190 -0
- package/src/context.ts +125 -0
- package/src/decisions.ts +122 -0
- package/src/email.ts +123 -0
- package/src/errors.ts +78 -0
- package/src/export.ts +82 -0
- package/src/finance.ts +148 -0
- package/src/git.ts +62 -0
- package/src/history.ts +100 -0
- package/src/images.ts +68 -0
- package/src/index.ts +1431 -0
- package/src/investigate.ts +415 -0
- package/src/markdown.ts +125 -0
- package/src/memos.ts +191 -0
- package/src/models.ts +94 -0
- package/src/monitor.ts +169 -0
- package/src/morning.ts +108 -0
- package/src/news.ts +329 -0
- package/src/openai-provider.ts +127 -0
- package/src/people.ts +472 -0
- package/src/personas.ts +99 -0
- package/src/platform.ts +84 -0
- package/src/plugins.ts +125 -0
- package/src/pomodoro.ts +169 -0
- package/src/providers.ts +70 -0
- package/src/retry.ts +108 -0
- package/src/session.ts +128 -0
- package/src/skills.ts +102 -0
- package/src/tasks.ts +418 -0
- package/src/tokens.ts +102 -0
- package/src/tool-safety.ts +100 -0
- package/src/tools.ts +1479 -0
- package/src/tui.ts +693 -0
- package/src/types.ts +55 -0
- package/src/undo.ts +83 -0
- package/src/windows.ts +299 -0
- package/src/workflows.ts +197 -0
- package/tests/ansi.test.ts +58 -0
- package/tests/approval.test.ts +43 -0
- package/tests/briefing.test.ts +10 -0
- package/tests/cli.test.ts +53 -0
- package/tests/context-window.test.ts +83 -0
- package/tests/images.test.ts +28 -0
- package/tests/memos.test.ts +116 -0
- package/tests/models.test.ts +34 -0
- package/tests/news.test.ts +13 -0
- package/tests/path-guard.test.ts +37 -0
- package/tests/people.test.ts +204 -0
- package/tests/skills.test.ts +35 -0
- package/tests/ssrf.test.ts +80 -0
- package/tests/tasks.test.ts +152 -0
- package/tests/tokens.test.ts +44 -0
- package/tests/tool-safety.test.ts +55 -0
- package/tests/windows-security.test.ts +59 -0
- package/tests/windows.test.ts +20 -0
- package/tsconfig.json +19 -0
package/src/news.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* News radar — fetches headlines from RSS feeds and web sources.
|
|
3
|
+
* Categories: business, tech, finance, brazil, world.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ─── RSS Feed Sources ───────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface NewsSource {
|
|
9
|
+
name: string
|
|
10
|
+
url: string
|
|
11
|
+
category: NewsCategory
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type NewsCategory = 'business' | 'tech' | 'finance' | 'brazil' | 'world'
|
|
15
|
+
|
|
16
|
+
const FEEDS: readonly NewsSource[] = [
|
|
17
|
+
// Business & Economy
|
|
18
|
+
{ name: 'InfoMoney', url: 'https://www.infomoney.com.br/feed/', category: 'finance' },
|
|
19
|
+
{ name: 'Valor Economico', url: 'https://pox.globo.com/rss/valor/', category: 'business' },
|
|
20
|
+
{ name: 'Bloomberg Linea BR', url: 'https://www.bloomberglinea.com.br/feed/', category: 'finance' },
|
|
21
|
+
|
|
22
|
+
// Tech
|
|
23
|
+
{ name: 'TechCrunch', url: 'https://techcrunch.com/feed/', category: 'tech' },
|
|
24
|
+
{ name: 'Hacker News (best)', url: 'https://hnrss.org/best', category: 'tech' },
|
|
25
|
+
{ name: 'The Verge', url: 'https://www.theverge.com/rss/index.xml', category: 'tech' },
|
|
26
|
+
|
|
27
|
+
// Brazil
|
|
28
|
+
{ name: 'G1', url: 'https://g1.globo.com/rss/g1/', category: 'brazil' },
|
|
29
|
+
{ name: 'Folha', url: 'https://feeds.folha.uol.com.br/folha/cotidiano/rss091.xml', category: 'brazil' },
|
|
30
|
+
|
|
31
|
+
// World
|
|
32
|
+
{ name: 'BBC World', url: 'https://feeds.bbci.co.uk/news/world/rss.xml', category: 'world' },
|
|
33
|
+
{ name: 'Reuters', url: 'https://www.reutersagency.com/feed/', category: 'world' },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024 // 2 MB max per feed
|
|
39
|
+
const MAX_ITEMS_PER_FEED = 10
|
|
40
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
41
|
+
|
|
42
|
+
// ─── RSS Parser (minimal, no dependencies) ──────────────────
|
|
43
|
+
|
|
44
|
+
interface NewsItem {
|
|
45
|
+
title: string
|
|
46
|
+
link: string
|
|
47
|
+
source: string
|
|
48
|
+
category: NewsCategory
|
|
49
|
+
pubDate?: Date
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse RSS/Atom XML to extract news items.
|
|
54
|
+
* Minimal parser — no dependency needed.
|
|
55
|
+
*/
|
|
56
|
+
function parseRss(xml: string, source: string, category: NewsCategory): NewsItem[] {
|
|
57
|
+
const items: NewsItem[] = []
|
|
58
|
+
|
|
59
|
+
// Try RSS <item> format
|
|
60
|
+
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi
|
|
61
|
+
let match: RegExpExecArray | null
|
|
62
|
+
|
|
63
|
+
while ((match = itemRegex.exec(xml)) !== null) {
|
|
64
|
+
const block = match[1]
|
|
65
|
+
const item = parseBlock(block, source, category)
|
|
66
|
+
if (item) items.push(item)
|
|
67
|
+
if (items.length >= MAX_ITEMS_PER_FEED) break
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If no <item>, try Atom <entry>
|
|
71
|
+
if (items.length === 0) {
|
|
72
|
+
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi
|
|
73
|
+
while ((match = entryRegex.exec(xml)) !== null) {
|
|
74
|
+
const block = match[1]
|
|
75
|
+
const item = parseBlock(block, source, category)
|
|
76
|
+
if (item) items.push(item)
|
|
77
|
+
if (items.length >= MAX_ITEMS_PER_FEED) break
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return items
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseBlock(block: string, source: string, category: NewsCategory): NewsItem | null {
|
|
85
|
+
const title = extractTag(block, 'title')
|
|
86
|
+
if (!title) return null
|
|
87
|
+
|
|
88
|
+
const rawLink = extractTag(block, 'link') || extractAtomLink(block)
|
|
89
|
+
const link = sanitizeLink(rawLink)
|
|
90
|
+
const pubDateStr = extractTag(block, 'pubDate') || extractTag(block, 'published') || extractTag(block, 'updated')
|
|
91
|
+
|
|
92
|
+
let pubDate: Date | undefined
|
|
93
|
+
if (pubDateStr) {
|
|
94
|
+
const d = new Date(pubDateStr)
|
|
95
|
+
pubDate = isNaN(d.getTime()) ? undefined : d
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
title: cleanHtml(title),
|
|
100
|
+
link,
|
|
101
|
+
source,
|
|
102
|
+
category,
|
|
103
|
+
pubDate,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate a link is a safe HTTP(S) URL.
|
|
109
|
+
* Rejects javascript:, data:, and other schemes.
|
|
110
|
+
*/
|
|
111
|
+
function sanitizeLink(link: string | null): string {
|
|
112
|
+
if (!link) return ''
|
|
113
|
+
const trimmed = link.trim()
|
|
114
|
+
if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) {
|
|
115
|
+
return trimmed
|
|
116
|
+
}
|
|
117
|
+
return '' // reject non-HTTP links
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Escape regex special characters in a string.
|
|
122
|
+
*/
|
|
123
|
+
function escapeRegex(s: string): string {
|
|
124
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractTag(xml: string, tag: string): string | null {
|
|
128
|
+
const escaped = escapeRegex(tag)
|
|
129
|
+
|
|
130
|
+
// Handle CDATA
|
|
131
|
+
const cdataRegex = new RegExp(`<${escaped}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${escaped}>`, 'i')
|
|
132
|
+
const cdataMatch = cdataRegex.exec(xml)
|
|
133
|
+
if (cdataMatch) return cdataMatch[1].trim()
|
|
134
|
+
|
|
135
|
+
// Plain text
|
|
136
|
+
const regex = new RegExp(`<${escaped}[^>]*>([\\s\\S]*?)</${escaped}>`, 'i')
|
|
137
|
+
const match = regex.exec(xml)
|
|
138
|
+
return match ? match[1].trim() : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function extractAtomLink(xml: string): string | null {
|
|
142
|
+
const regex = /<link[^>]+href="([^"]+)"[^>]*\/?>/i
|
|
143
|
+
const match = regex.exec(xml)
|
|
144
|
+
return match ? match[1] : null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cleanHtml(text: string): string {
|
|
148
|
+
return text
|
|
149
|
+
.replace(/<[^>]+>/g, '')
|
|
150
|
+
.replace(/&/g, '&')
|
|
151
|
+
.replace(/</g, '<')
|
|
152
|
+
.replace(/>/g, '>')
|
|
153
|
+
.replace(/"/g, '"')
|
|
154
|
+
.replace(/'/g, "'")
|
|
155
|
+
.replace(/ /g, ' ')
|
|
156
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
157
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
158
|
+
.trim()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Public API ─────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fetch news from all or filtered categories.
|
|
165
|
+
* Returns formatted text output.
|
|
166
|
+
*/
|
|
167
|
+
export async function fetchNews(
|
|
168
|
+
categories?: NewsCategory[],
|
|
169
|
+
maxPerSource = 5,
|
|
170
|
+
): Promise<string> {
|
|
171
|
+
// Validate maxPerSource
|
|
172
|
+
const cappedMax = Math.max(1, Math.min(maxPerSource, MAX_ITEMS_PER_FEED))
|
|
173
|
+
|
|
174
|
+
// Guard against empty categories array
|
|
175
|
+
if (categories && categories.length === 0) {
|
|
176
|
+
return getNewsCategories()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const feeds = categories
|
|
180
|
+
? FEEDS.filter((f) => categories.includes(f.category))
|
|
181
|
+
: FEEDS
|
|
182
|
+
|
|
183
|
+
const results = await Promise.allSettled(
|
|
184
|
+
feeds.map((feed) => fetchFeed(feed, cappedMax)),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const allItems: NewsItem[] = []
|
|
188
|
+
const errors: string[] = []
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < results.length; i++) {
|
|
191
|
+
const result = results[i]
|
|
192
|
+
if (result.status === 'fulfilled') {
|
|
193
|
+
allItems.push(...result.value)
|
|
194
|
+
} else {
|
|
195
|
+
errors.push(`${feeds[i].name}: ${summarizeError(result.reason)}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (allItems.length === 0) {
|
|
200
|
+
return errors.length > 0
|
|
201
|
+
? `Nenhuma noticia encontrada.\nFalhas: ${errors.join(', ')}`
|
|
202
|
+
: 'Nenhuma noticia encontrada.'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Sort by date (newest first)
|
|
206
|
+
allItems.sort((a, b) => {
|
|
207
|
+
const da = a.pubDate?.getTime() || 0
|
|
208
|
+
const db = b.pubDate?.getTime() || 0
|
|
209
|
+
return db - da
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return formatNews(allItems, errors)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summarizeError(err: unknown): string {
|
|
216
|
+
if (err instanceof Error) {
|
|
217
|
+
if (err.name === 'AbortError') return 'timeout'
|
|
218
|
+
return err.message.slice(0, 80)
|
|
219
|
+
}
|
|
220
|
+
return 'unreachable'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function fetchFeed(source: NewsSource, maxItems: number): Promise<NewsItem[]> {
|
|
224
|
+
const controller = new AbortController()
|
|
225
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const resp = await fetch(source.url, {
|
|
229
|
+
signal: controller.signal,
|
|
230
|
+
headers: {
|
|
231
|
+
'User-Agent': 'smolerclaw/1.0 (news-radar)',
|
|
232
|
+
'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml',
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
clearTimeout(timeout)
|
|
236
|
+
|
|
237
|
+
if (!resp.ok) return []
|
|
238
|
+
|
|
239
|
+
// Check content-length before reading body
|
|
240
|
+
const contentLength = resp.headers.get('content-length')
|
|
241
|
+
if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
|
|
242
|
+
return [] // skip oversized feeds
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Read body with size cap
|
|
246
|
+
const reader = resp.body?.getReader()
|
|
247
|
+
if (!reader) return []
|
|
248
|
+
|
|
249
|
+
const chunks: Uint8Array[] = []
|
|
250
|
+
let totalBytes = 0
|
|
251
|
+
|
|
252
|
+
while (true) {
|
|
253
|
+
const { done, value } = await reader.read()
|
|
254
|
+
if (done) break
|
|
255
|
+
totalBytes += value.byteLength
|
|
256
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
257
|
+
reader.cancel()
|
|
258
|
+
return [] // body too large
|
|
259
|
+
}
|
|
260
|
+
chunks.push(value)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const xml = new TextDecoder().decode(Buffer.concat(chunks))
|
|
264
|
+
const items = parseRss(xml, source.name, source.category)
|
|
265
|
+
return items.slice(0, maxItems)
|
|
266
|
+
} catch (err) {
|
|
267
|
+
clearTimeout(timeout)
|
|
268
|
+
// Log errors for debugging but don't crash
|
|
269
|
+
if (process.env.DEBUG) {
|
|
270
|
+
console.error(`[news] ${source.name}: ${err instanceof Error ? err.message : err}`)
|
|
271
|
+
}
|
|
272
|
+
return []
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatNews(items: NewsItem[], errors: string[]): string {
|
|
277
|
+
const categoryLabels: Record<NewsCategory, string> = {
|
|
278
|
+
business: 'Negocios',
|
|
279
|
+
tech: 'Tecnologia',
|
|
280
|
+
finance: 'Financas',
|
|
281
|
+
brazil: 'Brasil',
|
|
282
|
+
world: 'Mundo',
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Group by category (immutable approach)
|
|
286
|
+
const grouped = new Map<NewsCategory, NewsItem[]>()
|
|
287
|
+
for (const item of items) {
|
|
288
|
+
const existing = grouped.get(item.category) || []
|
|
289
|
+
grouped.set(item.category, [...existing, item])
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const sections: string[] = []
|
|
293
|
+
const categoryOrder: NewsCategory[] = ['finance', 'business', 'tech', 'brazil', 'world']
|
|
294
|
+
|
|
295
|
+
for (const cat of categoryOrder) {
|
|
296
|
+
const catItems = grouped.get(cat)
|
|
297
|
+
if (!catItems || catItems.length === 0) continue
|
|
298
|
+
|
|
299
|
+
const label = categoryLabels[cat]
|
|
300
|
+
const lines = catItems.slice(0, 8).map((item) => {
|
|
301
|
+
const time = item.pubDate
|
|
302
|
+
? item.pubDate.toLocaleTimeString('pt-BR', {
|
|
303
|
+
hour: '2-digit',
|
|
304
|
+
minute: '2-digit',
|
|
305
|
+
timeZone: 'America/Sao_Paulo',
|
|
306
|
+
})
|
|
307
|
+
: ''
|
|
308
|
+
const timeStr = time ? `[${time}]` : ''
|
|
309
|
+
return ` ${timeStr} ${item.title} (${item.source})`
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
sections.push(`--- ${label} ---\n${lines.join('\n')}`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let output = sections.join('\n\n')
|
|
316
|
+
|
|
317
|
+
if (errors.length > 0) {
|
|
318
|
+
output += `\n\n(Fontes indisponiveis: ${errors.join(', ')})`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return output
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get list of available categories.
|
|
326
|
+
*/
|
|
327
|
+
export function getNewsCategories(): string {
|
|
328
|
+
return 'Categorias: business, tech, finance, brazil, world\nUso: /news [categoria]'
|
|
329
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Message, ChatEvent, ToolApprovalMode } from './types'
|
|
2
|
+
import type { ApprovalCallback } from './approval'
|
|
3
|
+
import type { LLMProvider } from './providers'
|
|
4
|
+
|
|
5
|
+
const OPENAI_BASE = 'https://api.openai.com/v1'
|
|
6
|
+
const OLLAMA_BASE = 'http://localhost:11434/v1'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OpenAI-compatible provider.
|
|
10
|
+
* Works with OpenAI API, Ollama (local), and any OpenAI-compatible endpoint.
|
|
11
|
+
*/
|
|
12
|
+
export class OpenAICompatProvider implements LLMProvider {
|
|
13
|
+
readonly name: string
|
|
14
|
+
private apiKey: string
|
|
15
|
+
private baseUrl: string
|
|
16
|
+
private model: string
|
|
17
|
+
private maxTokens: number
|
|
18
|
+
private approvalMode: ToolApprovalMode = 'auto'
|
|
19
|
+
private approvalCallback: ApprovalCallback | null = null
|
|
20
|
+
private autoApproveAll = false
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
provider: 'openai' | 'ollama',
|
|
24
|
+
model: string,
|
|
25
|
+
maxTokens: number,
|
|
26
|
+
) {
|
|
27
|
+
this.name = provider
|
|
28
|
+
this.model = model
|
|
29
|
+
this.maxTokens = maxTokens
|
|
30
|
+
|
|
31
|
+
if (provider === 'ollama') {
|
|
32
|
+
this.apiKey = 'ollama' // Ollama doesn't need a real key
|
|
33
|
+
this.baseUrl = process.env.OLLAMA_BASE_URL || OLLAMA_BASE
|
|
34
|
+
} else {
|
|
35
|
+
this.apiKey = process.env.OPENAI_API_KEY || ''
|
|
36
|
+
this.baseUrl = process.env.OPENAI_BASE_URL || OPENAI_BASE
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setModel(model: string): void { this.model = model }
|
|
41
|
+
setApprovalMode(mode: ToolApprovalMode): void { this.approvalMode = mode }
|
|
42
|
+
setApprovalCallback(cb: ApprovalCallback): void { this.approvalCallback = cb }
|
|
43
|
+
setAutoApproveAll(value: boolean): void { this.autoApproveAll = value }
|
|
44
|
+
|
|
45
|
+
async *chat(
|
|
46
|
+
messages: Message[],
|
|
47
|
+
systemPrompt: string,
|
|
48
|
+
enableTools = true,
|
|
49
|
+
): AsyncGenerator<ChatEvent> {
|
|
50
|
+
if (!this.apiKey && this.name !== 'ollama') {
|
|
51
|
+
yield { type: 'error', error: `No API key found. Set OPENAI_API_KEY env var.` }
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const apiMessages = [
|
|
56
|
+
{ role: 'system', content: systemPrompt },
|
|
57
|
+
...messages.map((m) => ({ role: m.role, content: m.content })),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const resp = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
model: this.model,
|
|
69
|
+
messages: apiMessages,
|
|
70
|
+
max_tokens: this.maxTokens,
|
|
71
|
+
stream: true,
|
|
72
|
+
}),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const err = await resp.text()
|
|
77
|
+
yield { type: 'error', error: `${this.name} API error ${resp.status}: ${err.slice(0, 200)}` }
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!resp.body) {
|
|
82
|
+
yield { type: 'error', error: 'No response body' }
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const reader = resp.body.getReader()
|
|
87
|
+
const decoder = new TextDecoder()
|
|
88
|
+
let buffer = ''
|
|
89
|
+
let inputEstimate = systemPrompt.length + messages.reduce((s, m) => s + m.content.length, 0)
|
|
90
|
+
let outputChars = 0
|
|
91
|
+
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader.read()
|
|
94
|
+
if (done) break
|
|
95
|
+
|
|
96
|
+
buffer += decoder.decode(value, { stream: true })
|
|
97
|
+
const lines = buffer.split('\n')
|
|
98
|
+
buffer = lines.pop() || ''
|
|
99
|
+
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (!line.startsWith('data: ')) continue
|
|
102
|
+
const data = line.slice(6).trim()
|
|
103
|
+
if (data === '[DONE]') continue
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(data)
|
|
107
|
+
const delta = parsed.choices?.[0]?.delta
|
|
108
|
+
if (delta?.content) {
|
|
109
|
+
yield { type: 'text', text: delta.content }
|
|
110
|
+
outputChars += delta.content.length
|
|
111
|
+
}
|
|
112
|
+
} catch { /* skip malformed SSE */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Estimate token usage (rough: 4 chars per token)
|
|
117
|
+
yield {
|
|
118
|
+
type: 'usage',
|
|
119
|
+
inputTokens: Math.ceil(inputEstimate / 3.5),
|
|
120
|
+
outputTokens: Math.ceil(outputChars / 3.5),
|
|
121
|
+
}
|
|
122
|
+
yield { type: 'done' }
|
|
123
|
+
} catch (err) {
|
|
124
|
+
yield { type: 'error', error: err instanceof Error ? err.message : String(err) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|