@wenathlan/devthink 1.1.1
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/CHANGELOG.md +44 -0
- package/LICENSE +674 -0
- package/README.md +170 -0
- package/build.ts +41 -0
- package/check-secrets.ts +52 -0
- package/compatibility.ts +37 -0
- package/config.ts +240 -0
- package/devthink.ts +360 -0
- package/identity.ts +136 -0
- package/ids.ts +22 -0
- package/memory.ts +95 -0
- package/modes.ts +43 -0
- package/package.json +67 -0
- package/plugin.ts +68 -0
- package/providers.ts +179 -0
- package/server.ts +227 -0
- package/session.ts +155 -0
- package/storage.ts +51 -0
- package/stream.ts +130 -0
- package/sync.ts +27 -0
- package/ui.ts +122 -0
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wenathlan/devthink",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "DevThink is an AI development workbench with provider-owned endpoints, normalized streaming, sessions, memory, an embedded gateway and a terminal/web interface.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"devthink": "./devthink.ts"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./devthink.ts",
|
|
12
|
+
"./config": "./config.ts",
|
|
13
|
+
"./identity": "./identity.ts",
|
|
14
|
+
"./memory": "./memory.ts",
|
|
15
|
+
"./modes": "./modes.ts",
|
|
16
|
+
"./plugin": "./plugin.ts",
|
|
17
|
+
"./providers": "./providers.ts",
|
|
18
|
+
"./server": "./server.ts",
|
|
19
|
+
"./session": "./session.ts",
|
|
20
|
+
"./stream": "./stream.ts",
|
|
21
|
+
"./sync": "./sync.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"*.ts",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"release-notes"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"start": "bun run devthink.ts",
|
|
32
|
+
"dev": "bun --watch run devthink.ts",
|
|
33
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
34
|
+
"check": "bun run check:secrets && bun run test",
|
|
35
|
+
"check:secrets": "bun run check-secrets.ts",
|
|
36
|
+
"build:binary": "bun run build.ts",
|
|
37
|
+
"build": "bun build ./devthink.ts --outdir dist --target bun",
|
|
38
|
+
"release": "bun run check && bun run build:binary"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.1.1",
|
|
42
|
+
"node": ">=26.7.0"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"ai",
|
|
46
|
+
"cli",
|
|
47
|
+
"developer-tools",
|
|
48
|
+
"llm",
|
|
49
|
+
"streaming",
|
|
50
|
+
"terminal",
|
|
51
|
+
"provider-endpoints",
|
|
52
|
+
"embedded-gateway",
|
|
53
|
+
"web-workbench"
|
|
54
|
+
],
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "https://github.com/wenathlan/devthink"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://wenathlan.github.io/devthink/",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/wenathlan/devthink/issues"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public",
|
|
65
|
+
"registry": "https://registry.npmjs.org"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/plugin.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type PluginContext = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
log: (message: string) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PluginDefinition = {
|
|
11
|
+
name: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
hooks?: Record<string, (payload: unknown, context: PluginContext) => unknown | Promise<unknown>>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PluginManifest = {
|
|
17
|
+
name: string;
|
|
18
|
+
entry: string;
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function pluginRoot(): string {
|
|
23
|
+
return join(process.env.DEVTHINK_HOME || join(process.cwd(), ".devthink"), "plugins");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readManifest(path: string): PluginManifest | undefined {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as PluginManifest;
|
|
29
|
+
if (!parsed.name || !parsed.entry) return undefined;
|
|
30
|
+
return parsed;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function importPlugin(manifestPath: string, manifest: PluginManifest): Promise<PluginDefinition> {
|
|
37
|
+
const entry = resolve(join(manifestPath, "..", manifest.entry));
|
|
38
|
+
const loaded = (await import(pathToFileURL(entry).href)) as { default?: PluginDefinition } & PluginDefinition;
|
|
39
|
+
return loaded.default || loaded;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listPluginManifests(root = pluginRoot()): PluginManifest[] {
|
|
43
|
+
if (!existsSync(root)) return [];
|
|
44
|
+
return readdirSync(root, { withFileTypes: true }).filter((item) => item.isDirectory()).map((item) => readManifest(join(root, item.name, "devthink.plugin.json"))).filter((item): item is PluginManifest => Boolean(item));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function loadPlugins(root = pluginRoot()): Promise<PluginDefinition[]> {
|
|
48
|
+
const manifests = listPluginManifests(root).filter((manifest) => manifest.enabled !== false);
|
|
49
|
+
const plugins: PluginDefinition[] = [];
|
|
50
|
+
for (const manifest of manifests) {
|
|
51
|
+
const path = join(root, manifest.name, "devthink.plugin.json");
|
|
52
|
+
try {
|
|
53
|
+
plugins.push(await importPlugin(path, manifest));
|
|
54
|
+
} catch {
|
|
55
|
+
// A broken optional plugin does not prevent the core CLI from starting.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return plugins;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runHook(plugins: PluginDefinition[], hook: string, payload: unknown, context: PluginContext): Promise<unknown[]> {
|
|
62
|
+
const results: unknown[] = [];
|
|
63
|
+
for (const plugin of plugins) {
|
|
64
|
+
const handler = plugin.hooks?.[hook];
|
|
65
|
+
if (handler) results.push(await handler(payload, context));
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
package/providers.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { resolveCredential, type DevThinkConfig, type DevThinkPaths } from "./config.ts";
|
|
2
|
+
import { redactProviderError, retryDelay, type ProviderProtocol } from "./compatibility.ts";
|
|
3
|
+
import { parseEventStream, type ChatEvent } from "./stream.ts";
|
|
4
|
+
|
|
5
|
+
export type ChatRole = "system" | "user" | "assistant";
|
|
6
|
+
|
|
7
|
+
export type ChatMessage = {
|
|
8
|
+
role: ChatRole;
|
|
9
|
+
content: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ChatRequest = {
|
|
13
|
+
provider: string;
|
|
14
|
+
model: string;
|
|
15
|
+
messages: ChatMessage[];
|
|
16
|
+
temperature?: number;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ModelInfo = {
|
|
22
|
+
id: string;
|
|
23
|
+
provider: string;
|
|
24
|
+
contextWindow?: number;
|
|
25
|
+
supportsStreaming: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ProviderDefinition = {
|
|
29
|
+
id: string;
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
protocol: ProviderProtocol;
|
|
32
|
+
env: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PROVIDERS: ProviderDefinition[] = [
|
|
36
|
+
{ id: "openai", baseUrl: "https://api.openai.com/v1", protocol: "openai", env: "OPENAI_API_KEY" },
|
|
37
|
+
{ id: "zai", baseUrl: "https://api.z.ai/api/paas/v4", protocol: "openai", env: "ZAI_API_KEY" },
|
|
38
|
+
{ id: "openrouter", baseUrl: "https://openrouter.ai/api/v1", protocol: "openai", env: "OPENROUTER_API_KEY" },
|
|
39
|
+
{ id: "anthropic", baseUrl: "https://api.anthropic.com", protocol: "anthropic", env: "ANTHROPIC_API_KEY" },
|
|
40
|
+
{ id: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", protocol: "google", env: "GEMINI_API_KEY" },
|
|
41
|
+
{ id: "qwen", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", protocol: "openai", env: "QWEN_API_KEY" },
|
|
42
|
+
{ id: "deepseek", baseUrl: "https://api.deepseek.com", protocol: "openai", env: "DEEPSEEK_API_KEY" },
|
|
43
|
+
{ id: "groq", baseUrl: "https://api.groq.com/openai/v1", protocol: "openai", env: "GROQ_API_KEY" },
|
|
44
|
+
{ id: "mistral", baseUrl: "https://api.mistral.ai/v1", protocol: "openai", env: "MISTRAL_API_KEY" },
|
|
45
|
+
{ id: "xai", baseUrl: "https://api.x.ai/v1", protocol: "openai", env: "XAI_API_KEY" },
|
|
46
|
+
{ id: "ollama", protocol: "openai", env: "OLLAMA_API_KEY" },
|
|
47
|
+
{ id: "mimo", baseUrl: "https://api.xiaomimimo.com/v1", protocol: "openai", env: "MIMO_API_KEY" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function findProvider(provider: string): ProviderDefinition {
|
|
51
|
+
const definition = PROVIDERS.find((item) => item.id === provider.toLowerCase());
|
|
52
|
+
if (!definition) throw new Error(`Unknown provider: ${provider}. Use devthink providers.`);
|
|
53
|
+
return definition;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function joinUrl(baseUrl: string, path: string): string {
|
|
57
|
+
return `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cleanError(body: string, status: number): Error {
|
|
61
|
+
let detail = body.trim();
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
64
|
+
const error = (parsed.error || parsed.message || parsed.detail) as unknown;
|
|
65
|
+
if (typeof error === "string") detail = error;
|
|
66
|
+
if (error && typeof error === "object") detail = JSON.stringify(error);
|
|
67
|
+
} catch {
|
|
68
|
+
detail = body.trim();
|
|
69
|
+
}
|
|
70
|
+
const safe = redactProviderError(detail);
|
|
71
|
+
return new Error(`${status} ${safe || "Provider request failed."}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Uses the provider's configured endpoint directly; custom gateways are represented by `providers.<id>.baseUrl`. */
|
|
75
|
+
async function fetchWithRetry(input: string, init: RequestInit, attempts = 3): Promise<Response> {
|
|
76
|
+
let lastError: unknown;
|
|
77
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(input, init);
|
|
80
|
+
if (response.ok || response.status < 500 || attempt === attempts) return response;
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt - 1)));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
lastError = error;
|
|
84
|
+
if (attempt === attempts) throw error;
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt - 1)));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastError instanceof Error ? lastError : new Error("Provider request failed.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildOpenAiHeaders(credential: string): HeadersInit {
|
|
92
|
+
return { accept: "text/event-stream", "content-type": "application/json", authorization: `Bearer ${credential}` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildAnthropicHeaders(credential: string): HeadersInit {
|
|
96
|
+
return { accept: "text/event-stream", "content-type": "application/json", "x-api-key": credential, "anthropic-version": "2023-06-01" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildGoogleBody(request: ChatRequest): Record<string, unknown> {
|
|
100
|
+
const system = request.messages.filter((message) => message.role === "system").map((message) => ({ text: message.content }));
|
|
101
|
+
const contents = request.messages.filter((message) => message.role !== "system").map((message) => ({
|
|
102
|
+
role: message.role === "assistant" ? "model" : "user",
|
|
103
|
+
parts: [{ text: message.content }],
|
|
104
|
+
}));
|
|
105
|
+
return {
|
|
106
|
+
...(system.length ? { systemInstruction: { parts: system } } : {}),
|
|
107
|
+
contents,
|
|
108
|
+
generationConfig: {
|
|
109
|
+
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
|
|
110
|
+
...(request.maxTokens === undefined ? {} : { maxOutputTokens: request.maxTokens }),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildAnthropicBody(request: ChatRequest): Record<string, unknown> {
|
|
116
|
+
const system = request.messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
|
|
117
|
+
return {
|
|
118
|
+
model: request.model,
|
|
119
|
+
messages: request.messages.filter((message) => message.role !== "system"),
|
|
120
|
+
max_tokens: request.maxTokens ?? 4096,
|
|
121
|
+
stream: true,
|
|
122
|
+
...(system ? { system } : {}),
|
|
123
|
+
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildOpenAiBody(request: ChatRequest): Record<string, unknown> {
|
|
128
|
+
return {
|
|
129
|
+
model: request.model,
|
|
130
|
+
messages: request.messages,
|
|
131
|
+
stream: true,
|
|
132
|
+
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
|
|
133
|
+
...(request.maxTokens === undefined ? {} : { max_tokens: request.maxTokens }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function listProviders(): ProviderDefinition[] {
|
|
138
|
+
return PROVIDERS.map((provider) => ({ ...provider }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveProvider(provider: string, config: DevThinkConfig, paths?: DevThinkPaths): ProviderDefinition & { baseUrl: string; credential: string } {
|
|
142
|
+
const definition = findProvider(provider);
|
|
143
|
+
const isActive = provider === (config.activeProvider || config.provider);
|
|
144
|
+
const baseUrl = config.providers?.[provider]?.baseUrl || (isActive ? config.baseUrl : undefined) || definition.baseUrl;
|
|
145
|
+
if (!baseUrl) throw new Error(`Provider ${provider} requires baseUrl in config or --base-url.`);
|
|
146
|
+
const credential = resolveCredential(provider, config, paths);
|
|
147
|
+
if (!credential) throw new Error(`No credential found for ${provider}. Set ${definition.env} or configure it explicitly.`);
|
|
148
|
+
return { ...definition, baseUrl, credential };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function streamChat(request: ChatRequest, config: DevThinkConfig, paths?: DevThinkPaths): Promise<AsyncGenerator<ChatEvent>> {
|
|
152
|
+
const provider = resolveProvider(request.provider, config, paths);
|
|
153
|
+
const url = provider.protocol === "google"
|
|
154
|
+
? `${joinUrl(provider.baseUrl, `models/${encodeURIComponent(request.model)}:streamGenerateContent`)}?alt=sse`
|
|
155
|
+
: joinUrl(provider.baseUrl, provider.protocol === "anthropic" ? "/v1/messages" : "/chat/completions");
|
|
156
|
+
const headers = provider.protocol === "google"
|
|
157
|
+
? { accept: "text/event-stream", "content-type": "application/json", "x-goog-api-key": provider.credential }
|
|
158
|
+
: provider.protocol === "anthropic" ? buildAnthropicHeaders(provider.credential) : buildOpenAiHeaders(provider.credential);
|
|
159
|
+
const body = provider.protocol === "google" ? buildGoogleBody(request) : provider.protocol === "anthropic" ? buildAnthropicBody(request) : buildOpenAiBody(request);
|
|
160
|
+
const response = await fetchWithRetry(url, { method: "POST", headers, body: JSON.stringify(body), signal: request.signal });
|
|
161
|
+
if (!response.ok) throw cleanError(await response.text(), response.status);
|
|
162
|
+
return parseEventStream(response, { provider: provider.id, model: request.model, signal: request.signal });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function listModels(providerName: string, config: DevThinkConfig, paths?: DevThinkPaths): Promise<ModelInfo[]> {
|
|
166
|
+
const provider = resolveProvider(providerName, config, paths);
|
|
167
|
+
if (provider.protocol === "google") {
|
|
168
|
+
const response = await fetchWithRetry(joinUrl(provider.baseUrl, "models"), { headers: { "x-goog-api-key": provider.credential } });
|
|
169
|
+
if (!response.ok) throw cleanError(await response.text(), response.status);
|
|
170
|
+
const payload = (await response.json()) as { models?: Array<{ name?: string; inputTokenLimit?: number }> };
|
|
171
|
+
return (payload.models || []).map((model) => ({ id: (model.name || "").replace(/^models\//, ""), provider: provider.id, contextWindow: model.inputTokenLimit, supportsStreaming: true }));
|
|
172
|
+
}
|
|
173
|
+
const path = provider.protocol === "anthropic" ? "/v1/models" : "/models";
|
|
174
|
+
const headers = provider.protocol === "anthropic" ? buildAnthropicHeaders(provider.credential) : buildOpenAiHeaders(provider.credential);
|
|
175
|
+
const response = await fetchWithRetry(joinUrl(provider.baseUrl, path), { headers });
|
|
176
|
+
if (!response.ok) throw cleanError(await response.text(), response.status);
|
|
177
|
+
const payload = (await response.json()) as { data?: Array<{ id?: string; context_length?: number }> };
|
|
178
|
+
return (payload.data || []).filter((model) => model.id).map((model) => ({ id: model.id as string, provider: provider.id, contextWindow: model.context_length, supportsStreaming: true }));
|
|
179
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { randomInt } from "node:crypto";
|
|
3
|
+
import { isAllowedOrigin } from "./compatibility.ts";
|
|
4
|
+
import { listModels, listProviders, streamChat, type ChatMessage } from "./providers.ts";
|
|
5
|
+
import { ensurePaths, resolvePaths, saveConfig, type DevThinkConfig, type DevThinkPaths } from "./config.ts";
|
|
6
|
+
import { consumePairing, getIdentity, pairingStatus, revokeBrowserSessions, verifyBrowserSession } from "./identity.ts";
|
|
7
|
+
import { appendMessage, createSession, createTab, listSessions, loadSession, loadWorkspace, updateTab, type SessionSection } from "./session.ts";
|
|
8
|
+
|
|
9
|
+
export type ServerOptions = {
|
|
10
|
+
port?: number;
|
|
11
|
+
config: DevThinkConfig;
|
|
12
|
+
paths?: DevThinkPaths;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ServerHandle = {
|
|
16
|
+
address: string;
|
|
17
|
+
port: number;
|
|
18
|
+
stop: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function writeJson(response: ServerResponse, status: number, payload: unknown, origin?: string): void {
|
|
22
|
+
if (origin) response.setHeader("access-control-allow-origin", origin);
|
|
23
|
+
response.setHeader("vary", "Origin");
|
|
24
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
25
|
+
response.end(JSON.stringify(payload));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeOrigin(request: IncomingMessage, config: DevThinkConfig): string | undefined {
|
|
29
|
+
const origin = request.headers.origin;
|
|
30
|
+
return isAllowedOrigin(origin, config.web?.allowedOrigins) ? origin : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bearerToken(request: IncomingMessage): string | undefined {
|
|
34
|
+
const value = request.headers.authorization;
|
|
35
|
+
return value?.startsWith("Bearer ") ? value.slice("Bearer ".length).trim() || undefined : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeEvent(response: ServerResponse, type: string, payload: unknown): void {
|
|
39
|
+
response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readBody(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
43
|
+
const chunks: Buffer[] = [];
|
|
44
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
45
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
46
|
+
const parsed: unknown = raw ? JSON.parse(raw) : {};
|
|
47
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function messagesFromBody(value: unknown): ChatMessage[] {
|
|
51
|
+
if (!Array.isArray(value)) throw new Error("messages must be an array.");
|
|
52
|
+
return value.map((item) => {
|
|
53
|
+
const message = item as Record<string, unknown>;
|
|
54
|
+
if (!message.content || !["system", "user", "assistant"].includes(String(message.role))) throw new Error("Each message needs a supported role and content.");
|
|
55
|
+
return { role: message.role as ChatMessage["role"], content: String(message.content) };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sectionFrom(value: unknown): SessionSection {
|
|
60
|
+
return ["chat", "inspector", "settings", "memory", "providers", "projects", "routes", "usage"].includes(String(value)) ? value as SessionSection : "chat";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function routeParts(pathname: string): string[] {
|
|
64
|
+
return pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function streamChatRoute(request: IncomingMessage, response: ServerResponse, config: DevThinkConfig, paths: DevThinkPaths, origin?: string): Promise<void> {
|
|
68
|
+
const body = await readBody(request);
|
|
69
|
+
const provider = String(body.provider || config.provider || "");
|
|
70
|
+
const model = String(body.model || config.model || "");
|
|
71
|
+
if (!provider || !model) return writeJson(response, 400, { error: "provider and model are required." }, origin);
|
|
72
|
+
const incoming = messagesFromBody(body.messages);
|
|
73
|
+
const prompt = [...incoming].reverse().find((message) => message.role === "user");
|
|
74
|
+
if (!prompt) return writeJson(response, 400, { error: "messages must include a user message." }, origin);
|
|
75
|
+
const sessionId = typeof body.sessionId === "string" ? body.sessionId : undefined;
|
|
76
|
+
const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId : undefined;
|
|
77
|
+
const tabId = typeof body.tabId === "string" ? body.tabId : undefined;
|
|
78
|
+
const sectionId = sectionFrom(body.sectionId);
|
|
79
|
+
let session = sessionId ? loadSession(paths, sessionId) : undefined;
|
|
80
|
+
if (sessionId && !session) return writeJson(response, 404, { error: "Session not found." }, origin);
|
|
81
|
+
if (!session) session = createSession(paths, { mode: typeof body.mode === "string" ? body.mode : "chat", model, provider, workspaceId, tabId, sectionId, title: typeof body.title === "string" ? body.title : undefined });
|
|
82
|
+
if (workspaceId && session.workspaceId !== workspaceId) return writeJson(response, 409, { error: "workspaceId does not match the session." }, origin);
|
|
83
|
+
if (tabId && !session.tabs.some((tab) => tab.id === tabId)) return writeJson(response, 404, { error: "Tab not found." }, origin);
|
|
84
|
+
session = appendMessage(paths, session, prompt, { tabId: tabId || session.activeTabId, sectionId });
|
|
85
|
+
const history = session.messages.map(({ role, content }) => ({ role, content }));
|
|
86
|
+
const events = await streamChat({ provider, model, messages: history, temperature: typeof body.temperature === "number" ? body.temperature : config.temperature, maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : config.maxTokens }, config, paths);
|
|
87
|
+
if (origin) response.setHeader("access-control-allow-origin", origin);
|
|
88
|
+
response.setHeader("vary", "Origin");
|
|
89
|
+
response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream; charset=utf-8" });
|
|
90
|
+
writeEvent(response, "identity", { workspaceId: session.workspaceId, sessionId: session.id, tabId: tabId || session.activeTabId, sectionId });
|
|
91
|
+
let text = "";
|
|
92
|
+
for await (const event of events) {
|
|
93
|
+
if (event.type === "text") text += event.text;
|
|
94
|
+
writeEvent(response, event.type, event);
|
|
95
|
+
}
|
|
96
|
+
const persisted = appendMessage(paths, session, { role: "assistant", content: text }, { tabId: tabId || session.activeTabId, sectionId });
|
|
97
|
+
writeEvent(response, "persisted", { workspaceId: persisted.workspaceId, sessionId: persisted.id, tabId: tabId || persisted.activeTabId, messageId: persisted.messages.at(-1)?.id });
|
|
98
|
+
response.end();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function route(request: IncomingMessage, response: ServerResponse, config: DevThinkConfig, paths: DevThinkPaths): Promise<void> {
|
|
102
|
+
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
103
|
+
const origin = safeOrigin(request, config);
|
|
104
|
+
if (request.method === "OPTIONS") {
|
|
105
|
+
if (origin) response.setHeader("access-control-allow-origin", origin);
|
|
106
|
+
response.setHeader("access-control-allow-methods", "GET, POST, PATCH, PUT, OPTIONS");
|
|
107
|
+
response.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
108
|
+
response.setHeader("vary", "Origin");
|
|
109
|
+
response.writeHead(204);
|
|
110
|
+
return response.end();
|
|
111
|
+
}
|
|
112
|
+
if (request.headers.origin && !origin) return writeJson(response, 403, { error: "Origin is not allowed by web.allowedOrigins." });
|
|
113
|
+
if (request.method === "GET" && url.pathname === "/health") return writeJson(response, 200, { status: "ok", service: "devthink" }, origin);
|
|
114
|
+
if (request.method === "GET" && url.pathname === "/providers") return writeJson(response, 200, listProviders().map(({ id, protocol, env }) => ({ id, protocol, env })), origin);
|
|
115
|
+
if (request.method === "GET" && url.pathname === "/models") {
|
|
116
|
+
const provider = url.searchParams.get("provider") || config.provider;
|
|
117
|
+
if (!provider) return writeJson(response, 400, { error: "provider is required." }, origin);
|
|
118
|
+
return writeJson(response, 200, await listModels(provider, config, paths), origin);
|
|
119
|
+
}
|
|
120
|
+
const parts = routeParts(url.pathname);
|
|
121
|
+
if (request.method === "POST" && url.pathname === "/pairings/consume") {
|
|
122
|
+
const body = await readBody(request);
|
|
123
|
+
const pairingId = typeof body.pairingId === "string" ? body.pairingId : "";
|
|
124
|
+
const code = typeof body.code === "string" ? body.code.trim().toUpperCase() : "";
|
|
125
|
+
const paired = consumePairing(paths, pairingId, code);
|
|
126
|
+
return paired ? writeJson(response, 201, { token: paired.token, userId: paired.identity.userId, expiresAt: paired.expiresAt }, origin) : writeJson(response, 401, { error: "Pairing code is invalid, expired, or already used." }, origin);
|
|
127
|
+
}
|
|
128
|
+
const browserSession = origin ? verifyBrowserSession(paths, bearerToken(request)) : undefined;
|
|
129
|
+
if (origin && !browserSession) return writeJson(response, 401, { error: "A current browser pairing token is required." }, origin);
|
|
130
|
+
if (request.method === "GET" && url.pathname === "/identity") return writeJson(response, 200, { identity: getIdentity(paths), pairing: pairingStatus(paths) }, origin);
|
|
131
|
+
if (request.method === "POST" && url.pathname === "/pairings/revoke") return writeJson(response, 200, { revoked: revokeBrowserSessions(paths) }, origin);
|
|
132
|
+
if (request.method === "GET" && url.pathname === "/sessions") return writeJson(response, 200, { sessions: listSessions(paths) }, origin);
|
|
133
|
+
if (request.method === "GET" && url.pathname === "/workspaces") {
|
|
134
|
+
const workspaces = new Map<string, { id: string; title: string; updatedAt: string; sessionCount: number }>();
|
|
135
|
+
for (const session of listSessions(paths)) {
|
|
136
|
+
const current = workspaces.get(session.workspaceId);
|
|
137
|
+
workspaces.set(session.workspaceId, { id: session.workspaceId, title: current?.title || session.title.replace(/^Untitled session$/, "Untitled workspace"), updatedAt: current?.updatedAt && current.updatedAt > session.updatedAt ? current.updatedAt : session.updatedAt, sessionCount: (current?.sessionCount || 0) + 1 });
|
|
138
|
+
}
|
|
139
|
+
return writeJson(response, 200, { workspaces: [...workspaces.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) }, origin);
|
|
140
|
+
}
|
|
141
|
+
if (request.method === "GET" && url.pathname === "/usage") {
|
|
142
|
+
const sessions = listSessions(paths);
|
|
143
|
+
const tabCount = sessions.reduce((total, session) => total + session.tabs.length, 0);
|
|
144
|
+
const messageCount = sessions.reduce((total, session) => total + session.messages.length, 0);
|
|
145
|
+
return writeJson(response, 200, { workspaces: new Set(sessions.map((session) => session.workspaceId)).size, sessions: sessions.length, tabs: tabCount, messages: messageCount, providers: listProviders().length, paired: pairingStatus(paths).activeSessions > 0 }, origin);
|
|
146
|
+
}
|
|
147
|
+
if (request.method === "PATCH" && url.pathname === "/providers/active") {
|
|
148
|
+
const body = await readBody(request);
|
|
149
|
+
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
150
|
+
const model = typeof body.model === "string" ? body.model : undefined;
|
|
151
|
+
if (!listProviders().some((item) => item.id === provider)) return writeJson(response, 400, { error: "provider is not registered." }, origin);
|
|
152
|
+
const next = { ...config, activeProvider: provider, ...(model ? { activeModel: model } : {}) };
|
|
153
|
+
saveConfig(next, paths);
|
|
154
|
+
Object.assign(config, next);
|
|
155
|
+
return writeJson(response, 200, { activeProvider: next.activeProvider, activeModel: next.activeModel }, origin);
|
|
156
|
+
}
|
|
157
|
+
if (request.method === "GET" && parts[0] === "workspaces" && parts[1] && parts.length === 2) {
|
|
158
|
+
const workspace = loadWorkspace(paths, parts[1]);
|
|
159
|
+
if (!workspace) return writeJson(response, 404, { error: "Workspace not found." }, origin);
|
|
160
|
+
return writeJson(response, 200, { workspace, sessions: listSessions(paths).filter((session) => session.workspaceId === workspace.id) }, origin);
|
|
161
|
+
}
|
|
162
|
+
if (request.method === "POST" && parts[0] === "sessions" && parts.length === 1) {
|
|
163
|
+
const body = await readBody(request);
|
|
164
|
+
const session = createSession(paths, { mode: typeof body.mode === "string" ? body.mode : "chat", model: typeof body.model === "string" ? body.model : config.activeModel, provider: typeof body.provider === "string" ? body.provider : config.activeProvider, workspaceId: typeof body.workspaceId === "string" ? body.workspaceId : undefined, tabId: typeof body.tabId === "string" ? body.tabId : undefined, sectionId: sectionFrom(body.sectionId), title: typeof body.title === "string" ? body.title : undefined });
|
|
165
|
+
return writeJson(response, 201, session, origin);
|
|
166
|
+
}
|
|
167
|
+
if (parts[0] === "sessions" && parts[1] && parts.length === 2 && request.method === "GET") {
|
|
168
|
+
const session = loadSession(paths, parts[1]);
|
|
169
|
+
return session ? writeJson(response, 200, session, origin) : writeJson(response, 404, { error: "Session not found." }, origin);
|
|
170
|
+
}
|
|
171
|
+
if (parts[0] === "sessions" && parts[1] && parts[2] === "tabs" && parts.length === 3 && request.method === "POST") {
|
|
172
|
+
const session = loadSession(paths, parts[1]);
|
|
173
|
+
if (!session) return writeJson(response, 404, { error: "Session not found." }, origin);
|
|
174
|
+
const body = await readBody(request);
|
|
175
|
+
return writeJson(response, 201, createTab(paths, session, { id: typeof body.id === "string" ? body.id : undefined, label: typeof body.label === "string" ? body.label : undefined, provider: typeof body.provider === "string" ? body.provider : undefined, sectionId: sectionFrom(body.sectionId) }), origin);
|
|
176
|
+
}
|
|
177
|
+
if (parts[0] === "sessions" && parts[1] && parts[2] === "tabs" && parts[3] && parts.length === 4 && request.method === "PATCH") {
|
|
178
|
+
const session = loadSession(paths, parts[1]);
|
|
179
|
+
if (!session) return writeJson(response, 404, { error: "Session not found." }, origin);
|
|
180
|
+
const body = await readBody(request);
|
|
181
|
+
return writeJson(response, 200, updateTab(paths, session, parts[3], { label: typeof body.label === "string" ? body.label : undefined, provider: typeof body.provider === "string" ? body.provider : undefined, sectionId: body.sectionId === undefined ? undefined : sectionFrom(body.sectionId) }), origin);
|
|
182
|
+
}
|
|
183
|
+
if (request.method === "POST" && url.pathname === "/chat") return streamChatRoute(request, response, config, paths, origin);
|
|
184
|
+
return writeJson(response, 404, { error: "Route not found." }, origin);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function randomPort(): number {
|
|
188
|
+
return randomInt(1, 65_535);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function bind(server: Server, port: number): Promise<number> {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const onError = (error: NodeJS.ErrnoException) => {
|
|
194
|
+
server.off("listening", onListening);
|
|
195
|
+
reject(error);
|
|
196
|
+
};
|
|
197
|
+
const onListening = () => {
|
|
198
|
+
server.off("error", onError);
|
|
199
|
+
const address = server.address();
|
|
200
|
+
resolve(typeof address === "object" && address ? address.port : port);
|
|
201
|
+
};
|
|
202
|
+
server.once("error", onError);
|
|
203
|
+
server.once("listening", onListening);
|
|
204
|
+
server.listen(port, "127.0.0.1");
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function startServer(options: ServerOptions): Promise<ServerHandle> {
|
|
209
|
+
const paths = ensurePaths(options.paths || resolvePaths());
|
|
210
|
+
const server = createServer((request, response) => {
|
|
211
|
+
route(request, response, options.config, paths).catch((error: unknown) => writeJson(response, 500, { error: error instanceof Error ? error.message : "Request failed." }, safeOrigin(request, options.config)));
|
|
212
|
+
});
|
|
213
|
+
server.setMaxListeners(0);
|
|
214
|
+
let port = options.port || randomPort();
|
|
215
|
+
let lastError: unknown;
|
|
216
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
217
|
+
try {
|
|
218
|
+
port = await bind(server, port);
|
|
219
|
+
const address = `http://127.0.0.1:${port}`;
|
|
220
|
+
return { address, port, stop: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) };
|
|
221
|
+
} catch (error) {
|
|
222
|
+
lastError = error;
|
|
223
|
+
port = randomPort();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
throw lastError instanceof Error ? lastError : new Error("Could not bind the local server.");
|
|
227
|
+
}
|