friend-assistant 0.1.3 → 0.1.5
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/README.md +2 -1
- package/dist/BUILD.json +2 -2
- package/dist/friend.mjs +232 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,10 +127,11 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
|
|
|
127
127
|
|
|
128
128
|
| Key | What it is |
|
|
129
129
|
|---|---|
|
|
130
|
-
| `provider` | Which model answers: `claude` (API key), `claude-code` (your subscription)
|
|
130
|
+
| `provider` | Which model answers: `claude` (API key), `claude-code` (your subscription), `ollama` (this PC) or `openai` (any OpenAI-compatible server, such as OmniRoute). |
|
|
131
131
|
| `apiKey`, `model` | The Anthropic API key and the Claude model used with it. |
|
|
132
132
|
| `claudeCode.model`, `claudeCode.command`, `claudeCode.fallback` | The subscription route: model (`sonnet`, `haiku`, `opus`), the command that starts Claude Code, and `ollama` or `none` for what to use when Claude is unavailable. |
|
|
133
133
|
| `ollama.url`, `ollama.model`, `ollama.vision`, `ollama.contextTokens` | The local model: server address, model name, whether it can see pictures, and how much it remembers at once. |
|
|
134
|
+
| `openai.url`, `openai.model`, `openai.apiKey`, `openai.vision` | An OpenAI-compatible server: its address (ending in `/v1`), the model name it knows, its API key (or empty), and whether the model can see pictures. For OmniRoute (https://github.com/diegosouzapw/OmniRoute) run `npm install -g omniroute` and `omniroute`, then `friend config set provider openai` (the defaults are OmniRoute's: `http://localhost:20128/v1` and model `auto`). Also works with LM Studio, LiteLLM, vLLM and OpenRouter. |
|
|
134
135
|
| `voice.enabled`, `voice.sttModel`, `voice.ttsVoice`, `voice.language`, `voice.speakTyped`, `voice.accept`, `voice.challenge` | Speech on or off, the listening model, the speaking voice, a forced language, speaking typed replies, and the two thresholds for recognising your voice. |
|
|
135
136
|
| `bind`, `port` | Where Friend listens (easier: `friend lan`). |
|
|
136
137
|
| `https` | `true` serves a secure `https://` address with a certificate Friend makes for itself, so a phone may use the microphone (easier: `friend lan https on`). |
|
package/dist/BUILD.json
CHANGED
package/dist/friend.mjs
CHANGED
|
@@ -3263,6 +3263,7 @@ import { join as join12 } from "node:path";
|
|
|
3263
3263
|
import { chmodSync as chmodSync3, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3264
3264
|
import { homedir as homedir2 } from "node:os";
|
|
3265
3265
|
import { join as join8 } from "node:path";
|
|
3266
|
+
var DEFAULT_OPENAI = { url: "http://localhost:20128/v1", model: "auto", apiKey: null, vision: false };
|
|
3266
3267
|
var DEFAULT_CLAUDE_CODE = { model: "sonnet", command: "claude", fallback: "ollama" };
|
|
3267
3268
|
function sanitizeClaudeCode(raw) {
|
|
3268
3269
|
const r = raw && typeof raw === "object" ? raw : {};
|
|
@@ -3351,6 +3352,7 @@ var defaults = (dataDir) => ({
|
|
|
3351
3352
|
apiKey: null,
|
|
3352
3353
|
provider: "claude",
|
|
3353
3354
|
ollama: { ...DEFAULT_OLLAMA },
|
|
3355
|
+
openai: { ...DEFAULT_OPENAI },
|
|
3354
3356
|
claudeCode: { ...DEFAULT_CLAUDE_CODE },
|
|
3355
3357
|
voice: { ...DEFAULT_VOICE },
|
|
3356
3358
|
view: "companion",
|
|
@@ -3386,6 +3388,7 @@ function loadConfig(dataDir = defaultDataDir()) {
|
|
|
3386
3388
|
https: stored.https === true,
|
|
3387
3389
|
destructivePatterns,
|
|
3388
3390
|
ollama: { ...DEFAULT_OLLAMA, ...stored.ollama },
|
|
3391
|
+
openai: { ...DEFAULT_OPENAI, ...stored.openai },
|
|
3389
3392
|
claudeCode: sanitizeClaudeCode(stored.claudeCode),
|
|
3390
3393
|
voice: sanitizeVoice({ ...DEFAULT_VOICE, ...stored.voice }),
|
|
3391
3394
|
view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
|
|
@@ -4991,7 +4994,7 @@ import { z as z11 } from "zod";
|
|
|
4991
4994
|
var word = /^[\w.:/\\-]+$/;
|
|
4992
4995
|
var modelName = z11.string().trim().min(1).max(100).regex(/^[A-Za-z0-9][\w.:/-]*$/, "A model name has no spaces");
|
|
4993
4996
|
var SettingsPatch = z11.object({
|
|
4994
|
-
provider: z11.enum(["claude", "claude-code", "ollama"]),
|
|
4997
|
+
provider: z11.enum(["claude", "claude-code", "ollama", "openai"]),
|
|
4995
4998
|
model: modelName,
|
|
4996
4999
|
/** Empty means "leave the saved key alone". */
|
|
4997
5000
|
apiKey: z11.string().trim().max(400),
|
|
@@ -5013,6 +5016,13 @@ var SettingsPatch = z11.object({
|
|
|
5013
5016
|
vision: z11.boolean(),
|
|
5014
5017
|
contextTokens: z11.number().int().min(1024).max(131072)
|
|
5015
5018
|
}).partial().strict(),
|
|
5019
|
+
openai: z11.object({
|
|
5020
|
+
url: z11.string().trim().max(300),
|
|
5021
|
+
model: modelName,
|
|
5022
|
+
/** Empty means "leave the saved key alone". */
|
|
5023
|
+
apiKey: z11.string().trim().max(400),
|
|
5024
|
+
vision: z11.boolean()
|
|
5025
|
+
}).partial().strict(),
|
|
5016
5026
|
claudeCode: z11.object({
|
|
5017
5027
|
model: modelName,
|
|
5018
5028
|
command: z11.string().trim().min(1).max(260).regex(word, "The command is one word or path without spaces"),
|
|
@@ -5036,6 +5046,7 @@ function settingsView(c) {
|
|
|
5036
5046
|
view: c.view,
|
|
5037
5047
|
look: { ...c.look },
|
|
5038
5048
|
ollama: { url: c.ollama.url, model: c.ollama.model, vision: !!c.ollama.vision, contextTokens: c.ollama.contextTokens },
|
|
5049
|
+
openai: { url: c.openai.url, model: c.openai.model, vision: !!c.openai.vision, apiKeySet: !!c.openai.apiKey },
|
|
5039
5050
|
claudeCode: { ...c.claudeCode },
|
|
5040
5051
|
voice: {
|
|
5041
5052
|
enabled: c.voice.enabled,
|
|
@@ -5053,10 +5064,11 @@ var SettingsError = class extends Error {
|
|
|
5053
5064
|
function refuseForAgent(raw) {
|
|
5054
5065
|
const r = raw && typeof raw === "object" ? raw : {};
|
|
5055
5066
|
const cc = r.claudeCode && typeof r.claudeCode === "object" ? r.claudeCode : {};
|
|
5056
|
-
|
|
5067
|
+
const oa = r.openai && typeof r.openai === "object" ? r.openai : {};
|
|
5068
|
+
if ("apiKey" in r || "apiKey" in oa) throw new SettingsError("The API key can only be changed by the owner on the settings screen.");
|
|
5057
5069
|
if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
|
|
5058
5070
|
}
|
|
5059
|
-
var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "claudeCode"]);
|
|
5071
|
+
var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "openai", "claudeCode"]);
|
|
5060
5072
|
var RESTART_VOICE = ["enabled", "sttModel"];
|
|
5061
5073
|
function applySettings(config, raw) {
|
|
5062
5074
|
const parsed = SettingsPatch.safeParse(raw);
|
|
@@ -5076,6 +5088,17 @@ function applySettings(config, raw) {
|
|
|
5076
5088
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw new SettingsError("ollama.url: The URL must start with http:// or https://");
|
|
5077
5089
|
p.ollama.url = u.origin;
|
|
5078
5090
|
}
|
|
5091
|
+
const openaiUrl = p.openai?.url;
|
|
5092
|
+
if (openaiUrl !== void 0) {
|
|
5093
|
+
let u;
|
|
5094
|
+
try {
|
|
5095
|
+
u = new URL(openaiUrl);
|
|
5096
|
+
} catch {
|
|
5097
|
+
throw new SettingsError("openai.url: Expected a URL such as http://localhost:20128/v1");
|
|
5098
|
+
}
|
|
5099
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new SettingsError("openai.url: The URL must start with http:// or https://");
|
|
5100
|
+
p.openai.url = (u.origin + u.pathname).replace(/\/+$/, "");
|
|
5101
|
+
}
|
|
5079
5102
|
const next = {
|
|
5080
5103
|
provider: p.provider ?? config.provider,
|
|
5081
5104
|
apiKey: p.apiKey ? p.apiKey : config.apiKey,
|
|
@@ -5098,6 +5121,11 @@ function applySettings(config, raw) {
|
|
|
5098
5121
|
if (p.model) config.model = p.model;
|
|
5099
5122
|
if (p.apiKey) config.apiKey = p.apiKey;
|
|
5100
5123
|
if (p.ollama) Object.assign(config.ollama, p.ollama);
|
|
5124
|
+
if (p.openai) {
|
|
5125
|
+
const { apiKey, ...rest } = p.openai;
|
|
5126
|
+
Object.assign(config.openai, rest);
|
|
5127
|
+
if (apiKey) config.openai.apiKey = apiKey;
|
|
5128
|
+
}
|
|
5101
5129
|
if (p.claudeCode) Object.assign(config.claudeCode, p.claudeCode);
|
|
5102
5130
|
if (p.voice) {
|
|
5103
5131
|
const safe = next.voice;
|
|
@@ -5282,6 +5310,173 @@ var OllamaProvider = class {
|
|
|
5282
5310
|
}
|
|
5283
5311
|
};
|
|
5284
5312
|
|
|
5313
|
+
// ../server/src/provider/openai.ts
|
|
5314
|
+
import { Agent as Agent3, fetch as undiciFetch2 } from "undici";
|
|
5315
|
+
var encodeName3 = (n) => n.replaceAll(".", "__");
|
|
5316
|
+
var decodeName3 = (n) => n.replaceAll("__", ".");
|
|
5317
|
+
function toOpenAiMessages(system, messages, vision) {
|
|
5318
|
+
const out = [{ role: "system", content: system }];
|
|
5319
|
+
for (const m of messages) {
|
|
5320
|
+
if (m.role === "user") {
|
|
5321
|
+
out.push({ role: "user", content: m.content.map((b) => b.type === "text" ? b.text : "").join("\n") });
|
|
5322
|
+
} else if (m.role === "assistant") {
|
|
5323
|
+
const text2 = m.content.map((b) => b.type === "text" ? b.text : "").join("");
|
|
5324
|
+
const calls = m.content.filter((b) => b.type === "tool_use");
|
|
5325
|
+
out.push({
|
|
5326
|
+
role: "assistant",
|
|
5327
|
+
content: text2 || null,
|
|
5328
|
+
...calls.length ? { tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: encodeName3(c.name), arguments: JSON.stringify(c.input ?? {}) } })) } : {}
|
|
5329
|
+
});
|
|
5330
|
+
} else {
|
|
5331
|
+
for (const b of m.content) if (b.type === "tool_result") out.push({ role: "tool", tool_call_id: b.toolUseId, content: b.content });
|
|
5332
|
+
const pictures = vision ? m.content.flatMap((b) => b.type === "image" ? [`data:${b.mime};base64,${b.data}`] : []) : [];
|
|
5333
|
+
if (pictures.length) out.push({ role: "user", content: [{ type: "text", text: "Here is the picture from the camera." }, ...pictures.map((url) => ({ type: "image_url", image_url: { url } }))] });
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
return out;
|
|
5337
|
+
}
|
|
5338
|
+
function parseArgs2(raw) {
|
|
5339
|
+
if (!raw.trim()) return {};
|
|
5340
|
+
try {
|
|
5341
|
+
return JSON.parse(raw);
|
|
5342
|
+
} catch {
|
|
5343
|
+
return {};
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
var OpenAiProvider = class {
|
|
5347
|
+
constructor(opts) {
|
|
5348
|
+
this.opts = opts;
|
|
5349
|
+
const limit = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0;
|
|
5350
|
+
this.agent = new Agent3({ headersTimeout: limit, bodyTimeout: limit });
|
|
5351
|
+
}
|
|
5352
|
+
opts;
|
|
5353
|
+
id = "openai";
|
|
5354
|
+
agent;
|
|
5355
|
+
get vision() {
|
|
5356
|
+
return this.opts.vision === true;
|
|
5357
|
+
}
|
|
5358
|
+
async *stream(req) {
|
|
5359
|
+
const base = this.opts.url.replace(/\/+$/, "");
|
|
5360
|
+
const body = {
|
|
5361
|
+
model: this.opts.model,
|
|
5362
|
+
stream: true,
|
|
5363
|
+
stream_options: { include_usage: true },
|
|
5364
|
+
messages: [
|
|
5365
|
+
...toOpenAiMessages(req.system, req.messages, this.vision),
|
|
5366
|
+
// last of all, so every message before it stays identical between requests and a gateway can reuse its cache of them
|
|
5367
|
+
...req.context ? [{ role: "user", content: req.context }] : []
|
|
5368
|
+
],
|
|
5369
|
+
...req.tools.length ? { tools: req.tools.map((t) => ({ type: "function", function: { name: encodeName3(t.name), description: t.description, parameters: t.inputSchema } })) } : {}
|
|
5370
|
+
};
|
|
5371
|
+
let res;
|
|
5372
|
+
try {
|
|
5373
|
+
res = await undiciFetch2(`${base}/chat/completions`, {
|
|
5374
|
+
method: "POST",
|
|
5375
|
+
headers: { "content-type": "application/json", ...this.opts.apiKey ? { authorization: `Bearer ${this.opts.apiKey}` } : {} },
|
|
5376
|
+
body: JSON.stringify(body),
|
|
5377
|
+
signal: req.signal,
|
|
5378
|
+
dispatcher: this.agent
|
|
5379
|
+
});
|
|
5380
|
+
} catch (e) {
|
|
5381
|
+
if (req.signal.aborted) return;
|
|
5382
|
+
const code = e.cause?.code ?? "";
|
|
5383
|
+
if (code === "UND_ERR_HEADERS_TIMEOUT" || code === "UND_ERR_BODY_TIMEOUT") {
|
|
5384
|
+
yield { type: "error", userFacing: true, message: "The model took too long to answer. Try again, or choose a faster model." };
|
|
5385
|
+
return;
|
|
5386
|
+
}
|
|
5387
|
+
yield { type: "error", userFacing: true, message: `I can't reach the model server at ${base}. Is it running? Check the address in Settings.` };
|
|
5388
|
+
return;
|
|
5389
|
+
}
|
|
5390
|
+
if (!res.ok) {
|
|
5391
|
+
let detail = "";
|
|
5392
|
+
try {
|
|
5393
|
+
const j = await res.json();
|
|
5394
|
+
detail = typeof j.error === "string" ? j.error : j.error?.message ?? "";
|
|
5395
|
+
} catch {
|
|
5396
|
+
}
|
|
5397
|
+
if (res.status === 401 || res.status === 403) yield { type: "error", userFacing: true, message: "The model server refused the API key. Check it in Settings." };
|
|
5398
|
+
else if (res.status === 404) yield { type: "error", userFacing: true, message: `The model server has no "${this.opts.model}", or the address is wrong (it should end in /v1). ${detail}`.trim() };
|
|
5399
|
+
else if (res.status === 429) yield { type: "error", userFacing: true, message: "The model server says too many requests or the limit is used up. Try again in a moment." };
|
|
5400
|
+
else yield { type: "error", message: `Model server error (${res.status})${detail ? `: ${detail}` : ""}` };
|
|
5401
|
+
return;
|
|
5402
|
+
}
|
|
5403
|
+
const reader = res.body.getReader();
|
|
5404
|
+
const decoder = new TextDecoder();
|
|
5405
|
+
let buffer = "";
|
|
5406
|
+
let finished = false;
|
|
5407
|
+
const calls = /* @__PURE__ */ new Map();
|
|
5408
|
+
let usage = null;
|
|
5409
|
+
const flush = function* () {
|
|
5410
|
+
for (const [, c] of [...calls.entries()].sort((a, b) => a[0] - b[0])) {
|
|
5411
|
+
if (c.name) yield { type: "tool_call", id: c.id || `openai-${Date.now().toString(36)}-${c.name}`, name: decodeName3(c.name), args: parseArgs2(c.args) };
|
|
5412
|
+
}
|
|
5413
|
+
calls.clear();
|
|
5414
|
+
};
|
|
5415
|
+
const handle = function* (line) {
|
|
5416
|
+
const text2 = line.trim();
|
|
5417
|
+
if (!text2.startsWith("data:")) return;
|
|
5418
|
+
const data = text2.slice(5).trim();
|
|
5419
|
+
if (data === "[DONE]") {
|
|
5420
|
+
yield* flush();
|
|
5421
|
+
if (usage) yield { type: "usage", ...usage };
|
|
5422
|
+
yield { type: "done" };
|
|
5423
|
+
finished = true;
|
|
5424
|
+
return;
|
|
5425
|
+
}
|
|
5426
|
+
let obj;
|
|
5427
|
+
try {
|
|
5428
|
+
obj = JSON.parse(data);
|
|
5429
|
+
} catch {
|
|
5430
|
+
return;
|
|
5431
|
+
}
|
|
5432
|
+
if (obj.error) {
|
|
5433
|
+
yield { type: "error", message: `Model server error: ${typeof obj.error === "string" ? obj.error : obj.error.message ?? "unknown"}` };
|
|
5434
|
+
finished = true;
|
|
5435
|
+
return;
|
|
5436
|
+
}
|
|
5437
|
+
if (obj.usage) usage = { input: obj.usage.prompt_tokens ?? 0, output: obj.usage.completion_tokens ?? 0 };
|
|
5438
|
+
for (const choice of obj.choices ?? []) {
|
|
5439
|
+
const delta = choice.delta ?? {};
|
|
5440
|
+
if (typeof delta.content === "string" && delta.content) yield { type: "text_delta", text: delta.content };
|
|
5441
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
5442
|
+
const i = typeof tc.index === "number" ? tc.index : 0;
|
|
5443
|
+
const cur = calls.get(i) ?? { id: "", name: "", args: "" };
|
|
5444
|
+
if (tc.id) cur.id = tc.id;
|
|
5445
|
+
if (tc.function?.name) cur.name += tc.function.name;
|
|
5446
|
+
if (typeof tc.function?.arguments === "string") cur.args += tc.function.arguments;
|
|
5447
|
+
calls.set(i, cur);
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
};
|
|
5451
|
+
try {
|
|
5452
|
+
while (!finished) {
|
|
5453
|
+
const { value, done } = await reader.read();
|
|
5454
|
+
if (done) break;
|
|
5455
|
+
buffer += decoder.decode(value, { stream: true });
|
|
5456
|
+
let nl;
|
|
5457
|
+
while (!finished && (nl = buffer.indexOf("\n")) >= 0) {
|
|
5458
|
+
const line = buffer.slice(0, nl);
|
|
5459
|
+
buffer = buffer.slice(nl + 1);
|
|
5460
|
+
yield* handle(line);
|
|
5461
|
+
}
|
|
5462
|
+
}
|
|
5463
|
+
if (!finished && buffer.trim()) yield* handle(buffer);
|
|
5464
|
+
if (!finished) {
|
|
5465
|
+
yield* flush();
|
|
5466
|
+
const u = usage;
|
|
5467
|
+
if (u) yield { type: "usage", input: u.input, output: u.output };
|
|
5468
|
+
yield { type: "done" };
|
|
5469
|
+
}
|
|
5470
|
+
} catch (e) {
|
|
5471
|
+
if (req.signal.aborted) return;
|
|
5472
|
+
yield { type: "error", message: `Lost the connection to the model server: ${e.message}` };
|
|
5473
|
+
} finally {
|
|
5474
|
+
reader.cancel().catch(() => {
|
|
5475
|
+
});
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
};
|
|
5479
|
+
|
|
5285
5480
|
// ../server/src/tls.ts
|
|
5286
5481
|
import { chmodSync as chmodSync5, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
|
|
5287
5482
|
import { networkInterfaces } from "node:os";
|
|
@@ -5826,6 +6021,7 @@ async function createApp(opts) {
|
|
|
5826
6021
|
return config.claudeCode.fallback === "ollama" ? new FallbackProvider(cc, new OllamaProvider({ ...config.ollama }), (reason) => tellFallback(reason)) : cc;
|
|
5827
6022
|
}
|
|
5828
6023
|
if (config.provider === "ollama") return new OllamaProvider({ ...config.ollama });
|
|
6024
|
+
if (config.provider === "openai") return new OpenAiProvider({ ...config.openai });
|
|
5829
6025
|
return config.apiKey ? new ClaudeProvider({ apiKey: config.apiKey, model: config.model }) : new MissingKeyProvider();
|
|
5830
6026
|
};
|
|
5831
6027
|
const switchable = opts.provider ? null : new SwitchableProvider(makeProvider());
|
|
@@ -6529,7 +6725,7 @@ var PARSERS = {
|
|
|
6529
6725
|
return n;
|
|
6530
6726
|
},
|
|
6531
6727
|
provider: (v) => {
|
|
6532
|
-
if (v !== "claude" && v !== "ollama" && v !== "claude-code") throw new Error("Provider must be claude, ollama
|
|
6728
|
+
if (v !== "claude" && v !== "ollama" && v !== "claude-code" && v !== "openai") throw new Error("Provider must be claude, ollama, claude-code or openai");
|
|
6533
6729
|
return v;
|
|
6534
6730
|
},
|
|
6535
6731
|
roots: list,
|
|
@@ -6558,6 +6754,24 @@ var GROUPS = {
|
|
|
6558
6754
|
return v;
|
|
6559
6755
|
}
|
|
6560
6756
|
},
|
|
6757
|
+
openai: {
|
|
6758
|
+
url: (v) => {
|
|
6759
|
+
let u;
|
|
6760
|
+
try {
|
|
6761
|
+
u = new URL(v.trim());
|
|
6762
|
+
} catch {
|
|
6763
|
+
throw new Error("Expected a URL that ends in /v1, such as http://localhost:20128/v1");
|
|
6764
|
+
}
|
|
6765
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("The URL must start with http:// or https://");
|
|
6766
|
+
return (u.origin + u.pathname).replace(/\/+$/, "");
|
|
6767
|
+
},
|
|
6768
|
+
model: (v) => {
|
|
6769
|
+
if (!/^[A-Za-z0-9][\w.:/-]*$/.test(v.trim())) throw new Error("Expected a model name without spaces, such as auto");
|
|
6770
|
+
return v.trim();
|
|
6771
|
+
},
|
|
6772
|
+
apiKey: (v) => v.trim() === "" ? null : v.trim(),
|
|
6773
|
+
vision: bool
|
|
6774
|
+
},
|
|
6561
6775
|
ollama: {
|
|
6562
6776
|
url: httpUrl,
|
|
6563
6777
|
model: nonEmpty,
|
|
@@ -6572,7 +6786,7 @@ var GROUPS = {
|
|
|
6572
6786
|
var KEY_HELP = {
|
|
6573
6787
|
apiKey: 'Anthropic API key, used when the model is "claude"',
|
|
6574
6788
|
model: "Claude model name, used with the API key",
|
|
6575
|
-
provider: "which model answers: claude (API key), claude-code (your Claude subscription)
|
|
6789
|
+
provider: "which model answers: claude (API key), claude-code (your Claude subscription), ollama (on this PC) or openai (any OpenAI-compatible server, such as OmniRoute)",
|
|
6576
6790
|
bind: "address Friend listens on: 127.0.0.1 is this PC only, 0.0.0.0 is your whole network (easier: friend lan on)",
|
|
6577
6791
|
port: "the web port (easier: friend lan port N)",
|
|
6578
6792
|
https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
|
|
@@ -6592,6 +6806,10 @@ var KEY_HELP = {
|
|
|
6592
6806
|
"ollama.model": "the local model, for example qwen2.5:7b",
|
|
6593
6807
|
"ollama.vision": "true or false: the local model can look at pictures",
|
|
6594
6808
|
"ollama.contextTokens": "how much the local model remembers at once (1024 to 131072)",
|
|
6809
|
+
"openai.url": "address of an OpenAI-compatible server, ending in /v1; OmniRoute is http://localhost:20128/v1",
|
|
6810
|
+
"openai.model": "the model name that server knows; OmniRoute answers to auto",
|
|
6811
|
+
"openai.apiKey": "the server's API key, or empty when it asks for none (stored in your private config file)",
|
|
6812
|
+
"openai.vision": "true or false: that model can look at pictures",
|
|
6595
6813
|
"claudeCode.model": "model for your Claude subscription: sonnet, haiku, opus or a full name",
|
|
6596
6814
|
"claudeCode.command": "the command that starts Claude Code, if it is not on the PATH",
|
|
6597
6815
|
"claudeCode.fallback": "ollama or none: what to use when Claude is unavailable"
|
|
@@ -8348,7 +8566,7 @@ async function model(c) {
|
|
|
8348
8566
|
io2.out("No model is ready yet. Finish one of: `friend claude setup --use`, `friend ollama setup --use`, or `friend config set apiKey <key>`.");
|
|
8349
8567
|
return "later";
|
|
8350
8568
|
}
|
|
8351
|
-
io2.out(`Friend will think with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}) through your subscription${cfg.claudeCode.fallback === "ollama" ? ", with the local model as a safety net" : ""}` : cfg.provider === "claude" ? "Claude with your API key" : `${cfg.ollama.model} on this PC`}.`);
|
|
8569
|
+
io2.out(`Friend will think with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}) through your subscription${cfg.claudeCode.fallback === "ollama" ? ", with the local model as a safety net" : ""}` : cfg.provider === "claude" ? "Claude with your API key" : cfg.provider === "openai" ? `${cfg.openai.model} through ${cfg.openai.url}` : `${cfg.ollama.model} on this PC`}.`);
|
|
8352
8570
|
return "done";
|
|
8353
8571
|
}
|
|
8354
8572
|
async function voice(c) {
|
|
@@ -8463,7 +8681,7 @@ function summary(cfg, c) {
|
|
|
8463
8681
|
}
|
|
8464
8682
|
return [
|
|
8465
8683
|
` Assistant: ${owner}`,
|
|
8466
|
-
` Thinks with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}), your subscription` : cfg.provider === "claude" ? cfg.apiKey ? "Claude, API key" : "Claude, API key missing" : `${cfg.ollama.model} on this PC`}`,
|
|
8684
|
+
` Thinks with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}), your subscription` : cfg.provider === "claude" ? cfg.apiKey ? "Claude, API key" : "Claude, API key missing" : cfg.provider === "openai" ? `${cfg.openai.model} through ${cfg.openai.url}` : `${cfg.ollama.model} on this PC`}`,
|
|
8467
8685
|
` Voice: ${has(c, "voice") ? voiceLabel(cfg.voice.ttsVoice) : "off"}`,
|
|
8468
8686
|
` Workspace: ${cfg.roots[0] ?? "none (every file action asks)"}`,
|
|
8469
8687
|
` Address: ${u.local}${u.network.length ? ` and ${u.network.join(", ")}` : ""}`,
|
|
@@ -8874,7 +9092,7 @@ var USAGE4 = `Usage: friend <command>
|
|
|
8874
9092
|
node --url <ws-url> --code <code> [--roots a,b] [--name pc]
|
|
8875
9093
|
connect this PC to a Friend gateway
|
|
8876
9094
|
ollama setup|status use a local model through Ollama (no API key needed)
|
|
8877
|
-
lan status|on|off|port N
|
|
9095
|
+
lan status|on|off|port N|https on|off let other devices on your network open Friend, change the port, or turn on the secure (https) address a phone needs for the microphone (restart to apply)
|
|
8878
9096
|
claude setup|status use your Claude subscription through Claude Code (no API key needed)
|
|
8879
9097
|
voice setup|list|status|forget install local speech and choose a female or male voice; check it; forget deletes your saved voice
|
|
8880
9098
|
memory list|forget <n>|clear see and delete the notes Friend keeps about you
|
|
@@ -8908,10 +9126,15 @@ async function run(argv, io2) {
|
|
|
8908
9126
|
return await onboardCommand(rest, io2);
|
|
8909
9127
|
case "doctor":
|
|
8910
9128
|
return await doctorCommand(io2);
|
|
9129
|
+
case "help":
|
|
9130
|
+
case "--help":
|
|
9131
|
+
case "-h":
|
|
9132
|
+
io2.out(USAGE4);
|
|
9133
|
+
return 0;
|
|
8911
9134
|
case "version":
|
|
8912
9135
|
case "--version":
|
|
8913
9136
|
case "-v":
|
|
8914
|
-
io2.out(true ? "0.1.
|
|
9137
|
+
io2.out(true ? "0.1.5" : "development");
|
|
8915
9138
|
return 0;
|
|
8916
9139
|
case "lan":
|
|
8917
9140
|
return await lanCommand(rest, io2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "friend-assistant",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "A self-hosted personal AI assistant for your own PC: a full-screen stage, voice, tools with your approval, and your Claude subscription or a local model.",
|
|
5
5
|
"keywords": ["ai", "assistant", "jarvis", "voice", "claude", "ollama", "mcp", "self-hosted"],
|
|
6
6
|
"license": "UNLICENSED",
|