heyhank 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -189,7 +189,7 @@ class ImagePullManager {
|
|
|
189
189
|
this.markReady(localTag);
|
|
190
190
|
} else {
|
|
191
191
|
// Pull failed — try local build for default image
|
|
192
|
-
if (localTag === "
|
|
192
|
+
if (localTag === "heyhank:latest") {
|
|
193
193
|
this.appendProgress(localTag, "Pull failed, falling back to local build...");
|
|
194
194
|
await this.doLocalBuild(localTag);
|
|
195
195
|
} else {
|
|
@@ -199,7 +199,7 @@ class ImagePullManager {
|
|
|
199
199
|
} catch (e) {
|
|
200
200
|
const reason = e instanceof Error ? e.message : String(e);
|
|
201
201
|
// Try local build fallback for default image
|
|
202
|
-
if (localTag === "
|
|
202
|
+
if (localTag === "heyhank:latest") {
|
|
203
203
|
this.appendProgress(localTag, `Pull error (${reason}), falling back to local build...`);
|
|
204
204
|
await this.doLocalBuild(localTag);
|
|
205
205
|
} else {
|
package/server/index.ts
CHANGED
|
@@ -300,6 +300,16 @@ const server = Bun.serve<SocketData>({
|
|
|
300
300
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
// ── Telephony Listen WebSocket — browser live audio listen ───────
|
|
304
|
+
const telListenMatch = url.pathname.match(/^\/ws\/telephony\/listen\/([a-f0-9-]+)$/);
|
|
305
|
+
if (telListenMatch) {
|
|
306
|
+
const upgraded = server.upgrade(req, {
|
|
307
|
+
data: { kind: "telephony-listen" as const, callId: telListenMatch[1] },
|
|
308
|
+
});
|
|
309
|
+
if (upgraded) return undefined;
|
|
310
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
311
|
+
}
|
|
312
|
+
|
|
303
313
|
// ── Federation WebSocket — peer node connections ─────────────────
|
|
304
314
|
if (url.pathname === "/ws/node") {
|
|
305
315
|
// Auth is handled inside the federation protocol (first frame)
|
|
@@ -333,6 +343,8 @@ const server = Bun.serve<SocketData>({
|
|
|
333
343
|
callManager.addFreeSwitchSocket(data.callId, ws);
|
|
334
344
|
} else if (data.kind === "telephony-transcript") {
|
|
335
345
|
callManager.addTranscriptSocket(data.callId, ws);
|
|
346
|
+
} else if (data.kind === "telephony-listen") {
|
|
347
|
+
callManager.addListenSocket(data.callId, ws);
|
|
336
348
|
}
|
|
337
349
|
},
|
|
338
350
|
message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
|
|
@@ -349,8 +361,8 @@ const server = Bun.serve<SocketData>({
|
|
|
349
361
|
const handler = (ws as unknown as Record<string, unknown>).__federationOnMessage as ((data: string | Buffer) => void) | undefined;
|
|
350
362
|
handler?.(typeof msg === "string" ? msg : msg.toString());
|
|
351
363
|
} else if (data.kind === "telephony-audio") {
|
|
352
|
-
//
|
|
353
|
-
if (
|
|
364
|
+
// mod_audio_fork: text frames = metadata, binary frames = PCM audio
|
|
365
|
+
if (typeof msg !== "string") {
|
|
354
366
|
callManager.handleFreeSwitchAudio(data.callId, msg as Buffer);
|
|
355
367
|
}
|
|
356
368
|
}
|
|
@@ -374,6 +386,8 @@ const server = Bun.serve<SocketData>({
|
|
|
374
386
|
callManager.removeFreeSwitchSocket(data.callId, ws);
|
|
375
387
|
} else if (data.kind === "telephony-transcript") {
|
|
376
388
|
callManager.removeTranscriptSocket(data.callId, ws);
|
|
389
|
+
} else if (data.kind === "telephony-listen") {
|
|
390
|
+
callManager.removeListenSocket(data.callId, ws);
|
|
377
391
|
}
|
|
378
392
|
},
|
|
379
393
|
},
|
|
@@ -440,6 +454,15 @@ startPeriodicCheck();
|
|
|
440
454
|
|
|
441
455
|
// ── Reminder scheduler ──────────────────────────────────────────────────────
|
|
442
456
|
startReminderScheduler();
|
|
457
|
+
|
|
458
|
+
// ── Telephony inbound listener ──────────────────────────────────────────────
|
|
459
|
+
// Subscribes to FreeSWITCH ESL CHANNEL_CREATE events and bootstraps inbound calls.
|
|
460
|
+
// No-op if `inboundEnabled` is false in telephony settings.
|
|
461
|
+
try {
|
|
462
|
+
callManager.startInboundListener();
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.error("[server] Failed to start telephony inbound listener:", err);
|
|
465
|
+
}
|
|
443
466
|
if (isRunningAsService()) {
|
|
444
467
|
setServiceMode(true);
|
|
445
468
|
console.log("[server] Running as background service (auto-update available)");
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// ─── Streaming LLM Providers with Tool Calling ──────────────────────────────
|
|
2
|
+
// Used by Hank-UI chat endpoint for text-based providers (not Gemini Live voice).
|
|
3
|
+
|
|
4
|
+
export interface ContentPart {
|
|
5
|
+
type: "text" | "image_url" | "document";
|
|
6
|
+
text?: string;
|
|
7
|
+
image_url?: { url: string; detail?: "auto" | "low" | "high" };
|
|
8
|
+
document?: { url: string; mimeType: string; name?: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ChatMessage {
|
|
12
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
13
|
+
content: string | ContentPart[];
|
|
14
|
+
tool_call_id?: string;
|
|
15
|
+
tool_calls?: ToolCall[];
|
|
16
|
+
/** OpenAI-compatible tool name for role: "tool" messages */
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ToolCall {
|
|
21
|
+
id: string;
|
|
22
|
+
type: "function";
|
|
23
|
+
function: {
|
|
24
|
+
name: string;
|
|
25
|
+
arguments: string; // JSON string
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StreamEvent {
|
|
30
|
+
type: "text" | "tool_call" | "tool_result" | "done" | "error";
|
|
31
|
+
content?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
args?: Record<string, unknown>;
|
|
34
|
+
tool_call_id?: string;
|
|
35
|
+
result?: unknown;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StreamProviderConfig {
|
|
40
|
+
provider: "claude" | "openai" | "ollama" | "openrouter" | "gemini-text";
|
|
41
|
+
model: string;
|
|
42
|
+
apiKey?: string;
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
temperature?: number;
|
|
45
|
+
maxTokens?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Extract plain text from ChatMessage content */
|
|
49
|
+
function getTextContent(content: string | ContentPart[]): string {
|
|
50
|
+
if (typeof content === "string") return content;
|
|
51
|
+
return content.filter(p => p.type === "text").map(p => p.text || "").join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extract base64 data and mime type from a data URL */
|
|
55
|
+
function parseDataUrl(url: string): { mimeType: string; data: string } | null {
|
|
56
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
57
|
+
if (!match) return null;
|
|
58
|
+
return { mimeType: match[1], data: match[2] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Convert multimodal content to Claude format */
|
|
62
|
+
function toClaudeContent(content: string | ContentPart[]): string | Array<Record<string, unknown>> {
|
|
63
|
+
if (typeof content === "string") return content;
|
|
64
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
65
|
+
for (const p of content) {
|
|
66
|
+
if (p.type === "text" && p.text) {
|
|
67
|
+
parts.push({ type: "text", text: p.text });
|
|
68
|
+
} else if (p.type === "image_url" && p.image_url?.url) {
|
|
69
|
+
const parsed = parseDataUrl(p.image_url.url);
|
|
70
|
+
if (parsed) {
|
|
71
|
+
parts.push({ type: "image", source: { type: "base64", media_type: parsed.mimeType, data: parsed.data } });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return parts.length > 0 ? parts : getTextContent(content);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Convert multimodal content to OpenAI format */
|
|
79
|
+
function toOpenAIContent(content: string | ContentPart[]): string | Array<Record<string, unknown>> {
|
|
80
|
+
if (typeof content === "string") return content;
|
|
81
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
82
|
+
for (const p of content) {
|
|
83
|
+
if (p.type === "text" && p.text) {
|
|
84
|
+
parts.push({ type: "text", text: p.text });
|
|
85
|
+
} else if (p.type === "image_url" && p.image_url?.url) {
|
|
86
|
+
parts.push({ type: "image_url", image_url: { url: p.image_url.url, detail: p.image_url.detail || "auto" } });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return parts.length > 0 ? parts : getTextContent(content);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Convert multimodal content to Gemini format parts */
|
|
93
|
+
function toGeminiParts(content: string | ContentPart[]): Array<Record<string, unknown>> {
|
|
94
|
+
if (typeof content === "string") return [{ text: content }];
|
|
95
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
96
|
+
for (const p of content) {
|
|
97
|
+
if (p.type === "text" && p.text) {
|
|
98
|
+
parts.push({ text: p.text });
|
|
99
|
+
} else if (p.type === "image_url" && p.image_url?.url) {
|
|
100
|
+
const parsed = parseDataUrl(p.image_url.url);
|
|
101
|
+
if (parsed) {
|
|
102
|
+
parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return parts.length > 0 ? parts : [{ text: getTextContent(content) }];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Claude (Anthropic API) ─────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export async function* streamClaude(
|
|
112
|
+
messages: ChatMessage[],
|
|
113
|
+
tools: any[], // OpenAI format tools
|
|
114
|
+
config: StreamProviderConfig,
|
|
115
|
+
): AsyncGenerator<StreamEvent> {
|
|
116
|
+
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
117
|
+
if (!apiKey) throw new Error("Anthropic API key required");
|
|
118
|
+
|
|
119
|
+
// Convert OpenAI tool format to Claude tool format
|
|
120
|
+
const claudeTools = tools.map(t => ({
|
|
121
|
+
name: t.function.name,
|
|
122
|
+
description: t.function.description,
|
|
123
|
+
input_schema: t.function.parameters,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
// Separate system message
|
|
127
|
+
const systemMsg = messages.filter(m => m.role === "system").map(m => getTextContent(m.content)).join("\n");
|
|
128
|
+
const chatMessages = messages.filter(m => m.role !== "system").map(m => {
|
|
129
|
+
if (m.role === "tool") {
|
|
130
|
+
return {
|
|
131
|
+
role: "user" as const,
|
|
132
|
+
content: [{
|
|
133
|
+
type: "tool_result" as const,
|
|
134
|
+
tool_use_id: m.tool_call_id || "",
|
|
135
|
+
content: getTextContent(m.content),
|
|
136
|
+
}],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
140
|
+
return {
|
|
141
|
+
role: "assistant" as const,
|
|
142
|
+
content: m.tool_calls.map(tc => ({
|
|
143
|
+
type: "tool_use" as const,
|
|
144
|
+
id: tc.id,
|
|
145
|
+
name: tc.function.name,
|
|
146
|
+
input: JSON.parse(tc.function.arguments),
|
|
147
|
+
})),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { role: m.role as "user" | "assistant", content: toClaudeContent(m.content) };
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"x-api-key": apiKey,
|
|
158
|
+
"anthropic-version": "2023-06-01",
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
model: config.model || "claude-sonnet-4-20250514",
|
|
162
|
+
max_tokens: config.maxTokens || 4096,
|
|
163
|
+
system: systemMsg || undefined,
|
|
164
|
+
messages: chatMessages,
|
|
165
|
+
tools: claudeTools.length > 0 ? claudeTools : undefined,
|
|
166
|
+
stream: true,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const text = await response.text();
|
|
172
|
+
yield { type: "error", error: `Claude error ${response.status}: ${text}` };
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const reader = response.body?.getReader();
|
|
177
|
+
if (!reader) { yield { type: "error", error: "No response body" }; return; }
|
|
178
|
+
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
let buffer = "";
|
|
181
|
+
let currentToolUse: { id: string; name: string; argsJson: string } | null = null;
|
|
182
|
+
|
|
183
|
+
while (true) {
|
|
184
|
+
const { done, value } = await reader.read();
|
|
185
|
+
if (done) break;
|
|
186
|
+
buffer += decoder.decode(value, { stream: true });
|
|
187
|
+
const lines = buffer.split("\n");
|
|
188
|
+
buffer = lines.pop() || "";
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (!line.startsWith("data: ")) continue;
|
|
192
|
+
const data = line.slice(6).trim();
|
|
193
|
+
if (data === "[DONE]") { yield { type: "done" }; return; }
|
|
194
|
+
try {
|
|
195
|
+
const event = JSON.parse(data);
|
|
196
|
+
if (event.type === "content_block_start") {
|
|
197
|
+
if (event.content_block?.type === "tool_use") {
|
|
198
|
+
currentToolUse = { id: event.content_block.id, name: event.content_block.name, argsJson: "" };
|
|
199
|
+
}
|
|
200
|
+
} else if (event.type === "content_block_delta") {
|
|
201
|
+
if (event.delta?.type === "text_delta") {
|
|
202
|
+
yield { type: "text", content: event.delta.text };
|
|
203
|
+
} else if (event.delta?.type === "input_json_delta" && currentToolUse) {
|
|
204
|
+
currentToolUse.argsJson += event.delta.partial_json;
|
|
205
|
+
}
|
|
206
|
+
} else if (event.type === "content_block_stop" && currentToolUse) {
|
|
207
|
+
try {
|
|
208
|
+
const args = JSON.parse(currentToolUse.argsJson || "{}");
|
|
209
|
+
yield { type: "tool_call", name: currentToolUse.name, args, tool_call_id: currentToolUse.id };
|
|
210
|
+
} catch {
|
|
211
|
+
yield { type: "tool_call", name: currentToolUse.name, args: {}, tool_call_id: currentToolUse.id };
|
|
212
|
+
}
|
|
213
|
+
currentToolUse = null;
|
|
214
|
+
} else if (event.type === "message_stop") {
|
|
215
|
+
yield { type: "done" };
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
} catch { /* skip */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
yield { type: "done" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── OpenAI-compatible (OpenAI, OpenRouter, Ollama with /v1/chat/completions) ─
|
|
225
|
+
|
|
226
|
+
export async function* streamOpenAI(
|
|
227
|
+
messages: ChatMessage[],
|
|
228
|
+
tools: any[],
|
|
229
|
+
config: StreamProviderConfig,
|
|
230
|
+
): AsyncGenerator<StreamEvent> {
|
|
231
|
+
let baseUrl: string;
|
|
232
|
+
let headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
233
|
+
|
|
234
|
+
switch (config.provider) {
|
|
235
|
+
case "openai":
|
|
236
|
+
baseUrl = config.baseUrl || "https://api.openai.com/v1";
|
|
237
|
+
headers["Authorization"] = `Bearer ${config.apiKey || process.env.OPENAI_API_KEY}`;
|
|
238
|
+
break;
|
|
239
|
+
case "openrouter":
|
|
240
|
+
baseUrl = "https://openrouter.ai/api/v1";
|
|
241
|
+
headers["Authorization"] = `Bearer ${config.apiKey || process.env.OPENROUTER_API_KEY}`;
|
|
242
|
+
headers["HTTP-Referer"] = "https://heyhank.ai";
|
|
243
|
+
headers["X-Title"] = "HeyHank";
|
|
244
|
+
break;
|
|
245
|
+
case "ollama":
|
|
246
|
+
baseUrl = (config.baseUrl || "http://localhost:11434") + "/v1";
|
|
247
|
+
break;
|
|
248
|
+
default:
|
|
249
|
+
baseUrl = config.baseUrl || "https://api.openai.com/v1";
|
|
250
|
+
if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Convert messages to OpenAI format (with multimodal support)
|
|
254
|
+
const isOllama = config.provider === "ollama";
|
|
255
|
+
const openaiMessages = messages.map(m => {
|
|
256
|
+
if (isOllama && typeof m.content !== "string" && Array.isArray(m.content)) {
|
|
257
|
+
// Ollama uses `images` field for base64 images
|
|
258
|
+
const text = getTextContent(m.content);
|
|
259
|
+
const images: string[] = [];
|
|
260
|
+
for (const p of m.content) {
|
|
261
|
+
if (p.type === "image_url" && p.image_url?.url) {
|
|
262
|
+
const parsed = parseDataUrl(p.image_url.url);
|
|
263
|
+
if (parsed) images.push(parsed.data);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const msg: any = { role: m.role, content: text };
|
|
267
|
+
if (images.length > 0) msg.images = images;
|
|
268
|
+
if (m.tool_call_id) msg.tool_call_id = m.tool_call_id;
|
|
269
|
+
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
|
270
|
+
return msg;
|
|
271
|
+
}
|
|
272
|
+
const msg: any = { role: m.role, content: toOpenAIContent(m.content) };
|
|
273
|
+
if (m.tool_call_id) msg.tool_call_id = m.tool_call_id;
|
|
274
|
+
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
|
275
|
+
return msg;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const body: any = {
|
|
279
|
+
model: config.model,
|
|
280
|
+
messages: openaiMessages,
|
|
281
|
+
stream: true,
|
|
282
|
+
temperature: config.temperature ?? 0.7,
|
|
283
|
+
max_tokens: config.maxTokens ?? 4096,
|
|
284
|
+
};
|
|
285
|
+
if (tools.length > 0) body.tools = tools;
|
|
286
|
+
|
|
287
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers,
|
|
290
|
+
body: JSON.stringify(body),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
const text = await response.text();
|
|
295
|
+
yield { type: "error", error: `${config.provider} error ${response.status}: ${text}` };
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const reader = response.body?.getReader();
|
|
300
|
+
if (!reader) { yield { type: "error", error: "No response body" }; return; }
|
|
301
|
+
|
|
302
|
+
const decoder = new TextDecoder();
|
|
303
|
+
let buf = "";
|
|
304
|
+
const toolCalls: Map<number, { id: string; name: string; args: string }> = new Map();
|
|
305
|
+
|
|
306
|
+
while (true) {
|
|
307
|
+
const { done, value } = await reader.read();
|
|
308
|
+
if (done) break;
|
|
309
|
+
buf += decoder.decode(value, { stream: true });
|
|
310
|
+
const lines = buf.split("\n");
|
|
311
|
+
buf = lines.pop() || "";
|
|
312
|
+
|
|
313
|
+
for (const line of lines) {
|
|
314
|
+
if (!line.startsWith("data: ")) continue;
|
|
315
|
+
const data = line.slice(6).trim();
|
|
316
|
+
if (data === "[DONE]") {
|
|
317
|
+
// Emit any accumulated tool calls
|
|
318
|
+
for (const tc of toolCalls.values()) {
|
|
319
|
+
try {
|
|
320
|
+
const args = JSON.parse(tc.args || "{}");
|
|
321
|
+
yield { type: "tool_call", name: tc.name, args, tool_call_id: tc.id };
|
|
322
|
+
} catch {
|
|
323
|
+
yield { type: "tool_call", name: tc.name, args: {}, tool_call_id: tc.id };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
yield { type: "done" };
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(data);
|
|
331
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
332
|
+
if (!delta) continue;
|
|
333
|
+
|
|
334
|
+
if (delta.content) {
|
|
335
|
+
yield { type: "text", content: delta.content };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (delta.tool_calls) {
|
|
339
|
+
for (const tc of delta.tool_calls) {
|
|
340
|
+
const idx = tc.index ?? 0;
|
|
341
|
+
if (!toolCalls.has(idx)) {
|
|
342
|
+
toolCalls.set(idx, { id: tc.id || `call_${idx}`, name: "", args: "" });
|
|
343
|
+
}
|
|
344
|
+
const entry = toolCalls.get(idx)!;
|
|
345
|
+
if (tc.id) entry.id = tc.id;
|
|
346
|
+
if (tc.function?.name) entry.name = tc.function.name;
|
|
347
|
+
if (tc.function?.arguments) entry.args += tc.function.arguments;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check finish_reason
|
|
352
|
+
const finishReason = parsed.choices?.[0]?.finish_reason;
|
|
353
|
+
if (finishReason === "tool_calls" || finishReason === "stop") {
|
|
354
|
+
for (const tc of toolCalls.values()) {
|
|
355
|
+
try {
|
|
356
|
+
const args = JSON.parse(tc.args || "{}");
|
|
357
|
+
yield { type: "tool_call", name: tc.name, args, tool_call_id: tc.id };
|
|
358
|
+
} catch {
|
|
359
|
+
yield { type: "tool_call", name: tc.name, args: {}, tool_call_id: tc.id };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
toolCalls.clear();
|
|
363
|
+
if (finishReason === "stop") {
|
|
364
|
+
yield { type: "done" };
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch { /* skip */ }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
yield { type: "done" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Gemini Text (non-Live, REST streaming) ─────────────────────────────────
|
|
375
|
+
|
|
376
|
+
export async function* streamGeminiText(
|
|
377
|
+
messages: ChatMessage[],
|
|
378
|
+
tools: any[],
|
|
379
|
+
config: StreamProviderConfig,
|
|
380
|
+
): AsyncGenerator<StreamEvent> {
|
|
381
|
+
const apiKey = config.apiKey || process.env.GEMINI_API_KEY;
|
|
382
|
+
if (!apiKey) throw new Error("Gemini API key required");
|
|
383
|
+
|
|
384
|
+
const model = config.model || "gemini-2.5-flash";
|
|
385
|
+
|
|
386
|
+
// Convert to Gemini format
|
|
387
|
+
const systemInstruction = messages
|
|
388
|
+
.filter(m => m.role === "system")
|
|
389
|
+
.map(m => getTextContent(m.content))
|
|
390
|
+
.join("\n");
|
|
391
|
+
|
|
392
|
+
const contents = messages
|
|
393
|
+
.filter(m => m.role !== "system")
|
|
394
|
+
.map(m => {
|
|
395
|
+
if (m.role === "tool") {
|
|
396
|
+
return {
|
|
397
|
+
role: "function" as const,
|
|
398
|
+
parts: [{
|
|
399
|
+
functionResponse: {
|
|
400
|
+
name: (m as any).name || m.tool_call_id || "unknown",
|
|
401
|
+
response: { result: safeJsonParse(getTextContent(m.content)) },
|
|
402
|
+
},
|
|
403
|
+
}],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
407
|
+
return {
|
|
408
|
+
role: "model" as const,
|
|
409
|
+
parts: m.tool_calls.map(tc => ({
|
|
410
|
+
functionCall: {
|
|
411
|
+
name: tc.function.name,
|
|
412
|
+
args: JSON.parse(tc.function.arguments || "{}"),
|
|
413
|
+
},
|
|
414
|
+
})),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
role: m.role === "assistant" ? "model" as const : "user" as const,
|
|
419
|
+
parts: toGeminiParts(m.content),
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Convert OpenAI tools to Gemini format
|
|
424
|
+
const geminiTools = tools.length > 0 ? [{
|
|
425
|
+
functionDeclarations: tools.map(t => ({
|
|
426
|
+
name: t.function.name,
|
|
427
|
+
description: t.function.description,
|
|
428
|
+
parameters: convertToGeminiSchema(t.function.parameters),
|
|
429
|
+
})),
|
|
430
|
+
}] : undefined;
|
|
431
|
+
|
|
432
|
+
const response = await fetch(
|
|
433
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}&alt=sse`,
|
|
434
|
+
{
|
|
435
|
+
method: "POST",
|
|
436
|
+
headers: { "Content-Type": "application/json" },
|
|
437
|
+
body: JSON.stringify({
|
|
438
|
+
contents,
|
|
439
|
+
systemInstruction: systemInstruction ? { parts: [{ text: systemInstruction }] } : undefined,
|
|
440
|
+
tools: geminiTools,
|
|
441
|
+
toolConfig: geminiTools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
|
|
442
|
+
generationConfig: {
|
|
443
|
+
temperature: config.temperature ?? 0.7,
|
|
444
|
+
maxOutputTokens: config.maxTokens ?? 8192,
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
if (!response.ok) {
|
|
451
|
+
const text = await response.text();
|
|
452
|
+
yield { type: "error", error: `Gemini error ${response.status}: ${text}` };
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const reader = response.body?.getReader();
|
|
457
|
+
if (!reader) { yield { type: "error", error: "No response body" }; return; }
|
|
458
|
+
|
|
459
|
+
const decoder = new TextDecoder();
|
|
460
|
+
let buf = "";
|
|
461
|
+
let callCounter = 0;
|
|
462
|
+
|
|
463
|
+
while (true) {
|
|
464
|
+
const { done, value } = await reader.read();
|
|
465
|
+
if (done) break;
|
|
466
|
+
buf += decoder.decode(value, { stream: true });
|
|
467
|
+
const lines = buf.split("\n");
|
|
468
|
+
buf = lines.pop() || "";
|
|
469
|
+
|
|
470
|
+
for (const line of lines) {
|
|
471
|
+
if (!line.startsWith("data: ")) continue;
|
|
472
|
+
const data = line.slice(6).trim();
|
|
473
|
+
if (!data) continue;
|
|
474
|
+
try {
|
|
475
|
+
const parsed = JSON.parse(data);
|
|
476
|
+
|
|
477
|
+
// Check for API-level errors (e.g. safety block, invalid request)
|
|
478
|
+
if (parsed.error) {
|
|
479
|
+
yield { type: "error", error: `Gemini API error: ${parsed.error.message || JSON.stringify(parsed.error)}` };
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const candidate = parsed.candidates?.[0];
|
|
484
|
+
const finishReason = candidate?.finishReason;
|
|
485
|
+
const parts = candidate?.content?.parts;
|
|
486
|
+
|
|
487
|
+
// Handle finish reasons that indicate the response is done or blocked
|
|
488
|
+
if (finishReason && finishReason !== "STOP") {
|
|
489
|
+
if (finishReason === "MAX_TOKENS") {
|
|
490
|
+
yield { type: "text", content: "\n\n[Antwort wurde wegen Token-Limit abgeschnitten]" };
|
|
491
|
+
} else if (finishReason === "SAFETY") {
|
|
492
|
+
yield { type: "error", error: "Gemini hat die Antwort aus Sicherheitsgründen blockiert." };
|
|
493
|
+
return;
|
|
494
|
+
} else if (finishReason === "RECITATION") {
|
|
495
|
+
yield { type: "error", error: "Gemini hat die Antwort wegen Urheberrechtsbedenken blockiert." };
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!parts || parts.length === 0) continue;
|
|
501
|
+
for (const part of parts) {
|
|
502
|
+
if (part.text) {
|
|
503
|
+
yield { type: "text", content: part.text };
|
|
504
|
+
}
|
|
505
|
+
if (part.functionCall) {
|
|
506
|
+
yield {
|
|
507
|
+
type: "tool_call",
|
|
508
|
+
name: part.functionCall.name,
|
|
509
|
+
args: part.functionCall.args || {},
|
|
510
|
+
tool_call_id: `gemini_call_${callCounter++}`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (err) {
|
|
515
|
+
console.error("[Gemini stream] Failed to parse chunk:", err, "raw:", data.substring(0, 200));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
yield { type: "done" };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function safeJsonParse(s: string): unknown {
|
|
523
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function convertToGeminiSchema(schema: any): any {
|
|
527
|
+
if (!schema) return { type: "OBJECT", properties: {} };
|
|
528
|
+
const result: any = {};
|
|
529
|
+
result.type = (schema.type || "object").toUpperCase();
|
|
530
|
+
if (schema.properties) {
|
|
531
|
+
result.properties = {};
|
|
532
|
+
for (const [key, val] of Object.entries(schema.properties)) {
|
|
533
|
+
result.properties[key] = convertToGeminiSchema(val);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (schema.required) result.required = schema.required;
|
|
537
|
+
if (schema.description) result.description = schema.description;
|
|
538
|
+
if (schema.enum) result.enum = schema.enum;
|
|
539
|
+
if (schema.items) result.items = convertToGeminiSchema(schema.items);
|
|
540
|
+
return result;
|
|
541
|
+
}
|
package/server/llm-providers.ts
CHANGED
|
@@ -64,6 +64,12 @@ async function callOllama(
|
|
|
64
64
|
config: LLMProviderConfig,
|
|
65
65
|
): Promise<LLMResponse> {
|
|
66
66
|
const baseUrl = config.baseUrl || "http://localhost:11434";
|
|
67
|
+
|
|
68
|
+
// Warn about insecure remote Ollama URLs
|
|
69
|
+
if (baseUrl.startsWith("http://") && !baseUrl.includes("localhost") && !baseUrl.includes("127.0.0.1") && !baseUrl.includes(".ts.net")) {
|
|
70
|
+
console.warn(`[llm-providers] WARNING: Ollama URL "${baseUrl}" uses HTTP over a potentially public network. Consider using Tailscale (.ts.net) for secure remote access.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
68
74
|
method: "POST",
|
|
69
75
|
headers: { "Content-Type": "application/json" },
|
|
@@ -112,6 +118,12 @@ export async function* streamOllama(
|
|
|
112
118
|
config: LLMProviderConfig,
|
|
113
119
|
): AsyncGenerator<LLMStreamChunk> {
|
|
114
120
|
const baseUrl = config.baseUrl || "http://localhost:11434";
|
|
121
|
+
|
|
122
|
+
// Warn about insecure remote Ollama URLs
|
|
123
|
+
if (baseUrl.startsWith("http://") && !baseUrl.includes("localhost") && !baseUrl.includes("127.0.0.1") && !baseUrl.includes(".ts.net")) {
|
|
124
|
+
console.warn(`[llm-providers] WARNING: Ollama URL "${baseUrl}" uses HTTP over a potentially public network. Consider using Tailscale (.ts.net) for secure remote access.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
115
127
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
116
128
|
method: "POST",
|
|
117
129
|
headers: { "Content-Type": "application/json" },
|