Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public

- Committed Tauri `gen/schemas/capabilities.json` aligned with source capabilities (no `shell:allow-open`)
- Windows packaging uses NSIS only — WiX `.msi` rejects semver pre-releases like `0.1.0-alpha.1`
- Desktop AI: stop implying Cursor `crsr_` keys are a chat API; clearer network errors; Test saves then probes with visible status
- Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter

## [0.1.0-alpha.1] - TBD

Expand Down
38 changes: 25 additions & 13 deletions apps/api/src/ai.cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,35 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { defaultCursorBaseUrl, resolveCursorBaseUrl } from "../src/ai.js";

describe("cursor provider base URL", () => {
it("defaults when stored empty or ollama leftover", () => {
const fallback = defaultCursorBaseUrl();
assert.equal(resolveCursorBaseUrl(null), fallback);
assert.equal(resolveCursorBaseUrl(""), fallback);
assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434"), fallback);
assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434/"), fallback);
describe("cursor / openai-compatible provider base URL", () => {
it("does not invent a silent local gateway when unset", () => {
const prev = process.env.CURSOR_API_BASE_URL;
delete process.env.CURSOR_API_BASE_URL;
try {
assert.equal(defaultCursorBaseUrl(), null);
assert.equal(resolveCursorBaseUrl(null), null);
assert.equal(resolveCursorBaseUrl(""), null);
assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434"), null);
assert.equal(resolveCursorBaseUrl("http://127.0.0.1:11434/"), null);
} finally {
if (prev === undefined) delete process.env.CURSOR_API_BASE_URL;
else process.env.CURSOR_API_BASE_URL = prev;
}
});

it("keeps an explicit Cursor gateway", () => {
it("keeps an explicit gateway and honors CURSOR_API_BASE_URL", () => {
assert.equal(
resolveCursorBaseUrl("http://127.0.0.1:18789/v1/"),
"http://127.0.0.1:18789/v1",
);
assert.equal(
resolveCursorBaseUrl("https://cursor-gateway.example.com/v1"),
resolveCursorBaseUrl("https://cursor-gateway.example.com/v1/"),
"https://cursor-gateway.example.com/v1",
);
const prev = process.env.CURSOR_API_BASE_URL;
process.env.CURSOR_API_BASE_URL = "http://127.0.0.1:18789/v1/";
try {
assert.equal(defaultCursorBaseUrl(), "http://127.0.0.1:18789/v1");
assert.equal(resolveCursorBaseUrl(null), "http://127.0.0.1:18789/v1");
} finally {
if (prev === undefined) delete process.env.CURSOR_API_BASE_URL;
else process.env.CURSOR_API_BASE_URL = prev;
}
});
});
127 changes: 87 additions & 40 deletions apps/api/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,50 @@ export type AiFeature =

export type ChatMessage = { role: "system" | "user" | "assistant"; content: string };

function formatFetchFailure(label: string, baseUrl: string, err: unknown): Error {
const detail = err instanceof Error ? err.message : String(err);
const cause =
err instanceof Error && err.cause instanceof Error
? err.cause.message
: err instanceof Error && err.cause
? String(err.cause)
: "";
const combined = [detail, cause].filter(Boolean).join(": ");
const isNetwork =
/fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|network|socket|timed out/i.test(combined);
if (isNetwork) {
return new Error(
`${label} could not reach ${baseUrl} (${combined || "network error"}). ` +
`Cursor dashboard keys (crsr_…) are for Cloud Agents — they are not a chat API. ` +
`Use Anthropic, OpenAI, or Ollama, or point this field at a running OpenAI-compatible /v1 gateway.`,
);
}
return new Error(`${label} request failed: ${combined || "unknown error"}`);
}

async function callAnthropic(apiKey: string, model: string, messages: ChatMessage[], maxTokens = 1600) {
const system = messages.find((m) => m.role === "system")?.content ?? "";
const userMessages = messages.filter((m) => m.role !== "system");
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: model || "claude-sonnet-4-5",
max_tokens: maxTokens,
system,
messages: userMessages.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content })),
}),
});
const endpoint = "https://api.anthropic.com/v1/messages";
let res: Response;
try {
res = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: model || "claude-sonnet-4-5",
max_tokens: maxTokens,
system,
messages: userMessages.map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content })),
}),
});
} catch (err) {
throw formatFetchFailure("Anthropic", endpoint, err);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Anthropic error: ${res.status} ${text.slice(0, 300)}`);
Expand All @@ -49,18 +76,24 @@ async function callOpenAI(
baseUrl = "https://api.openai.com/v1",
label = "OpenAI",
) {
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model || "gpt-4o-mini",
messages,
temperature: 0.3,
}),
});
const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
let res: Response;
try {
res = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model || "gpt-4o-mini",
messages,
temperature: 0.3,
}),
});
} catch (err) {
throw formatFetchFailure(label, endpoint, err);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`${label} error: ${res.status} ${text.slice(0, 300)}`);
Expand All @@ -70,11 +103,17 @@ async function callOpenAI(
}

async function callOllama(baseUrl: string, model: string, messages: ChatMessage[]) {
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: model || "llama3.1", messages, stream: false }),
});
const endpoint = `${baseUrl.replace(/\/$/, "")}/api/chat`;
let res: Response;
try {
res = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: model || "llama3.1", messages, stream: false }),
});
} catch (err) {
throw formatFetchFailure("Ollama", endpoint, err);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Ollama error: ${res.status} ${text.slice(0, 300)}`);
Expand All @@ -85,12 +124,14 @@ async function callOllama(baseUrl: string, model: string, messages: ChatMessage[

const DEFAULT_OLLAMA_BASE = "http://127.0.0.1:11434";

export function defaultCursorBaseUrl(): string {
return (process.env.CURSOR_API_BASE_URL?.trim() || "http://127.0.0.1:18789/v1").replace(/\/$/, "");
/** Optional env override for OpenAI-compatible gateways. No silent local default. */
export function defaultCursorBaseUrl(): string | null {
const fromEnv = process.env.CURSOR_API_BASE_URL?.trim();
return fromEnv ? fromEnv.replace(/\/$/, "") : null;
}

/** Resolve Cursor OpenAI-compatible base URL; ignore leftover Ollama default. */
export function resolveCursorBaseUrl(stored: string | null | undefined): string {
/** Resolve OpenAI-compatible base URL; ignore leftover Ollama default. */
export function resolveCursorBaseUrl(stored: string | null | undefined): string | null {
const raw = (stored ?? "").trim().replace(/\/$/, "");
if (raw && raw !== DEFAULT_OLLAMA_BASE.replace(/\/$/, "")) return raw;
return defaultCursorBaseUrl();
Expand All @@ -115,8 +156,8 @@ export function getAiConfig() {
return {
enabled: Boolean(row?.enabled),
provider,
modelDigest: row?.modelDigest ?? (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5"),
modelDraft: row?.modelDraft ?? (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5"),
modelDigest: row?.modelDigest ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"),
modelDraft: row?.modelDraft ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"),
localOnly: Boolean(row?.localOnly),
ollamaBaseUrl: row?.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE,
cursorBaseUrl: resolveCursorBaseUrl(row?.ollamaBaseUrl),
Expand Down Expand Up @@ -152,14 +193,20 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) {
return { text, model: `openai:${model}`, provider: "openai" as const };
}
if (cfg.provider === "cursor") {
if (!cfg.cursorBaseUrl) {
throw new Error(
"OpenAI-compatible base URL required. Cursor dashboard API keys (crsr_…) talk to Cloud Agents, not /v1/chat/completions. " +
"Prefer Anthropic, OpenAI, or Ollama — or set a gateway URL that implements OpenAI Chat Completions.",
);
}
const text = await callOpenAI(
cfg.apiKey,
model || "composer-2.5",
model || "gpt-4o-mini",
messages,
cfg.cursorBaseUrl,
"Cursor",
"OpenAI-compatible",
);
return { text, model: `cursor:${model || "composer-2.5"}`, provider: "cursor" as const };
return { text, model: `cursor:${model || "gpt-4o-mini"}`, provider: "cursor" as const };
}
const text = await callAnthropic(cfg.apiKey, model || "claude-sonnet-4-5", messages, maxTokens);
return { text, model: `anthropic:${model}`, provider: "anthropic" as const };
Expand Down
84 changes: 75 additions & 9 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ app.get("/api/workspace/status", (c) => {

app.post("/api/workspace/init", async (c) => {
if (isInitialized()) return c.json({ error: "Already initialized" }, 400);
const body = await c.req.json<{ name?: string; password?: string; seed?: boolean }>();
const body = await c.req.json<{ name?: string; password?: string; seed?: boolean; emName?: string }>();
const pw = validateWorkspacePassword(body.password);
if (!pw.ok) return c.json({ error: pw.error }, 400);
// Seed only when explicitly requested — never default to demo data.
Expand All @@ -367,14 +367,33 @@ app.post("/api/workspace/init", async (c) => {
id: wsId,
name: body.name || "My Team",
createdAt: nowIso(),
settingsJson: "{}",
settingsJson: JSON.stringify({ onboarding: { completed: false, step: "team" } }),
passwordHash: hash,
passwordSalt: salt,
})
.run();
db.insert(aiSettings).values({ id: "default", enabled: 0, provider: "anthropic" }).run();
const emName = (body.emName ?? "").trim();
let emPersonId: string | null = null;
if (emName) {
emPersonId = id("person");
db.insert(people)
.values({
id: emPersonId,
name: emName,
email: null,
title: "Engineering Manager",
levelKey: "M1",
managerId: null,
hireDate: null,
status: "active",
createdAt: nowIso(),
})
.run();
logActivity("person.create", "person", emPersonId, { name: emName, role: "em" });
}
logActivity("workspace.init", "workspace", wsId);
return c.json({ workspaceId: wsId, token: getSessionToken() });
return c.json({ workspaceId: wsId, token: getSessionToken(), emPersonId });
});

app.post("/api/workspace/unlock", async (c) => {
Expand Down Expand Up @@ -2183,14 +2202,17 @@ app.get("/api/ai/settings", (c) => {
const provider = row?.provider ?? "anthropic";
const hasStoredKey = Boolean(row?.apiKeyEncrypted);
const hasEnvCursorKey = provider === "cursor" && Boolean(process.env.CURSOR_API_KEY?.trim());
const cursorBaseUrl = resolveCursorBaseUrl(row?.ollamaBaseUrl);
return c.json({
enabled: Boolean(row?.enabled),
provider,
modelDigest: row?.modelDigest,
modelDraft: row?.modelDraft,
localOnly: Boolean(row?.localOnly),
ollamaBaseUrl: row?.ollamaBaseUrl,
cursorBaseUrl: resolveCursorBaseUrl(row?.ollamaBaseUrl),
// For OpenAI-compatible provider, expose the gateway URL in the shared field (never invent 18789).
ollamaBaseUrl:
provider === "cursor" ? cursorBaseUrl ?? "" : (row?.ollamaBaseUrl ?? "http://127.0.0.1:11434"),
cursorBaseUrl,
privateNotesEgress: Boolean(row?.privateNotesEgress),
meetingSummaryEgress: Boolean(row?.meetingSummaryEgress),
documentExtractEgress: Boolean(row?.documentExtractEgress),
Expand Down Expand Up @@ -3227,22 +3249,66 @@ ${ctx.lines.join("\n")}`,

app.post("/api/ai/test", async (c) => {
const cfg = getAiConfig();
const tried =
cfg.localOnly || cfg.provider === "ollama"
? cfg.ollamaBaseUrl
: cfg.provider === "cursor"
? cfg.cursorBaseUrl
: cfg.provider === "openai"
? "https://api.openai.com/v1"
: "https://api.anthropic.com";
try {
if (!cfg.enabled) {
return c.json(
{
ok: false,
error: "AI is disabled — enable AI features, Save, then Test.",
provider: cfg.provider,
baseUrlTried: tried,
},
400,
);
}
if (cfg.localOnly || cfg.provider === "ollama") {
const text = await runChat("evidence_digest", [
{ role: "system", content: "Reply with exactly: OK" },
{ role: "user", content: "ping" },
]);
return c.json({ ok: true, model: text.model, sample: text.text.slice(0, 80) });
return c.json({
ok: true,
model: text.model,
sample: text.text.slice(0, 80),
provider: cfg.provider,
baseUrlTried: tried,
});
}
if (!cfg.hasApiKey) {
return c.json(
{ ok: false, error: "No API key on file — paste a key, Save, then Test.", provider: cfg.provider, baseUrlTried: tried },
400,
);
}
if (!cfg.hasApiKey) return c.json({ ok: false, error: "No API key" }, 400);
const text = await runChat("evidence_digest", [
{ role: "system", content: "Reply with exactly: OK" },
{ role: "user", content: "ping" },
]);
return c.json({ ok: true, model: text.model, sample: text.text.slice(0, 80) });
return c.json({
ok: true,
model: text.model,
sample: text.text.slice(0, 80),
provider: cfg.provider,
baseUrlTried: tried,
});
} catch (e) {
return c.json({ ok: false, error: e instanceof Error ? e.message : "failed" }, 500);
return c.json(
{
ok: false,
error: e instanceof Error ? e.message : "failed",
provider: cfg.provider,
baseUrlTried: tried,
},
500,
);
}
});

Expand Down
Loading