@minimalcorp/tsunagi 0.1.37 → 0.1.39
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/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +5 -5
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0nhzci6._.js +4 -8
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0vi01rl._.js +4 -8
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__04g~n~m._.js → [root-of-the-server]__0zf09ry._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_04l3mh~._.js +10 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bt-4.4._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0e3431w._.js +10 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0lirkqp._.js +10 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0soxqr.._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +4 -5
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0h0imx2._.js → node_modules_0_4__2k._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0pbr55t._.js → node_modules_0bqsyfk._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_06vr-0a._.js → node_modules_0bzw~9d._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0k7u4.c._.js → node_modules_0fsewd2._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0c6z_cw._.js → node_modules_0l~40og._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0jo-w3s._.js → node_modules_0pk6e0.._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zkg-x0._.js → node_modules_0smdx~2._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0fcqekz._.js → node_modules_0uej915._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{094hkpbli0k9s.js → 02.u6zk77a338.js} +10 -11
- package/.next/standalone/apps/web/.next/static/chunks/05n6~h_wwg.jj.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0_ygv4z~5c_9z.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0g5hoj5w-z.~6.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0nhcl~svhi_26.js +7 -0
- package/.next/standalone/apps/web/.next/static/chunks/0p4dfdgz~3qt3.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0p9~lk406udl5.css +4 -0
- package/.next/standalone/apps/web/.next/static/chunks/0q-u7bl7oa42n.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0q_j4~6_etj_q.js +7 -0
- package/.next/standalone/apps/web/.next/static/chunks/0zjymvayyxt-d.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0lp.2eizum8y~.js → 11rs2-0jl1rzr.js} +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{15~pc9ql1ab38.js → 158q94mo2-6sx.js} +7 -7
- package/.next/standalone/apps/web/.next/static/chunks/167xa3jy_0g.u.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/{turbopack-0079niub0zo4u.js → turbopack-07pr2uza0.4cp.js} +1 -1
- package/dist/server/basic-auth.js +9 -1
- package/dist/server/generated/prisma/internal/class.js +1 -1
- package/dist/server/index.js +20 -0
- package/dist/server/lib/claude-command.d.ts +6 -0
- package/dist/server/lib/claude-command.js +85 -0
- package/dist/server/lib/claude-config-guard.d.ts +12 -0
- package/dist/server/lib/claude-config-guard.js +49 -0
- package/dist/server/lib/lmstudio.d.ts +24 -0
- package/dist/server/lib/lmstudio.js +198 -0
- package/dist/server/lib/local-llm-env.d.ts +28 -0
- package/dist/server/lib/local-llm-env.js +91 -0
- package/dist/server/lib/local-llm.d.ts +56 -0
- package/dist/server/lib/local-llm.js +366 -0
- package/dist/server/lib/ollama-settings.d.ts +16 -10
- package/dist/server/lib/ollama-settings.js +104 -91
- package/dist/server/lib/repositories/task.js +3 -0
- package/dist/server/lib/searxng.d.ts +26 -0
- package/dist/server/lib/searxng.js +308 -0
- package/dist/server/lib/services/task-service.js +1 -3
- package/dist/server/routes/local-llm.d.ts +2 -0
- package/dist/server/routes/local-llm.js +192 -0
- package/dist/server/routes/mcp-web.d.ts +2 -0
- package/dist/server/routes/mcp-web.js +107 -0
- package/dist/server/routes/onboarding.js +4 -4
- package/dist/server/routes/settings.js +100 -26
- package/dist/server/routes/tasks.js +5 -1
- package/dist/server/routes/terminal.js +77 -28
- package/npm-shrinkwrap.json +5 -5
- package/package.json +1 -1
- package/prisma/schema.prisma +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_016-lxb._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hh~0f6._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0r0xwce._.js +0 -6
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0sbp1da._.js +0 -6
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_12c1w~~._.js +0 -6
- package/.next/standalone/apps/web/.next/static/chunks/0229rjr88pv9k.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/04l9g6rg7~-d5.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0citvl2medd0s.js +0 -11
- package/.next/standalone/apps/web/.next/static/chunks/0e8u7rqrqfiiy.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0pg---_6a_4ls.css +0 -4
- package/.next/standalone/apps/web/.next/static/chunks/0uv~zl3--e3wo.js +0 -11
- package/.next/standalone/apps/web/.next/static/chunks/11rpr3..poyv8.js +0 -5
- package/.next/standalone/apps/web/.next/static/chunks/1200zkjn5n2w7.js +0 -1
- /package/.next/standalone/apps/web/.next/static/{otC3BXpS9D6HI8k0EQ2ZH → C1cjF-gU-snY_atxJbn1v}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{otC3BXpS9D6HI8k0EQ2ZH → C1cjF-gU-snY_atxJbn1v}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{otC3BXpS9D6HI8k0EQ2ZH → C1cjF-gU-snY_atxJbn1v}/_ssgManifest.js +0 -0
|
@@ -1,39 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
// Docker 内から host の Ollama に繋ぐ場合は compose で host.docker.internal を指定する
|
|
1
|
+
import { normalizeBaseUrl, readAppSetting, writeAppSetting } from './local-llm-env.js';
|
|
2
|
+
export const OLLAMA_SETTING_KEY = 'ollama';
|
|
3
|
+
// Docker 内から host の Ollama に繋ぐ場合は TSUNAGI_OLLAMA_URL で host.docker.internal を指定する
|
|
5
4
|
const DEFAULT_BASE_URL = process.env.TSUNAGI_OLLAMA_URL || 'http://localhost:11434';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/** コンテキスト長を固定した派生モデルの tag に付ける目印 */
|
|
6
|
+
const CONTEXT_MODEL_MARKER = '-tsunagi-ctx';
|
|
7
|
+
// 20GB 級のモデルの読み込みは数十秒かかる
|
|
8
|
+
const LOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
9
9
|
export function defaultOllamaSettings() {
|
|
10
|
-
return {
|
|
11
|
-
enabled: false,
|
|
12
|
-
baseUrl: DEFAULT_BASE_URL,
|
|
13
|
-
model: '',
|
|
14
|
-
contextTokens: DEFAULT_CONTEXT_TOKENS,
|
|
15
|
-
extraEnv: {},
|
|
16
|
-
};
|
|
10
|
+
return { enabled: false, baseUrl: DEFAULT_BASE_URL };
|
|
17
11
|
}
|
|
18
12
|
export async function getOllamaSettings() {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
return defaultOllamaSettings();
|
|
22
|
-
try {
|
|
23
|
-
return { ...defaultOllamaSettings(), ...JSON.parse(row.value) };
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return defaultOllamaSettings();
|
|
27
|
-
}
|
|
13
|
+
const { enabled, baseUrl } = await readAppSetting(OLLAMA_SETTING_KEY, defaultOllamaSettings());
|
|
14
|
+
return { enabled, baseUrl };
|
|
28
15
|
}
|
|
29
16
|
export async function saveOllamaSettings(settings) {
|
|
30
|
-
|
|
31
|
-
await prisma.appSetting.upsert({
|
|
32
|
-
where: { key: SETTING_KEY },
|
|
33
|
-
create: { key: SETTING_KEY, value },
|
|
34
|
-
update: { value },
|
|
35
|
-
});
|
|
36
|
-
return settings;
|
|
17
|
+
return writeAppSetting(OLLAMA_SETTING_KEY, settings);
|
|
37
18
|
}
|
|
38
19
|
/** リクエストボディを検証して OllamaSettings に正規化する。不正ならエラーメッセージを返す */
|
|
39
20
|
export function parseOllamaSettings(body) {
|
|
@@ -42,69 +23,67 @@ export function parseOllamaSettings(body) {
|
|
|
42
23
|
const b = body;
|
|
43
24
|
if (typeof b.enabled !== 'boolean')
|
|
44
25
|
return { error: 'enabled must be boolean' };
|
|
45
|
-
const baseUrl =
|
|
46
|
-
|
|
47
|
-
const url = new URL(baseUrl);
|
|
48
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
49
|
-
throw new Error();
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
26
|
+
const baseUrl = normalizeBaseUrl(b.baseUrl);
|
|
27
|
+
if (!baseUrl)
|
|
52
28
|
return { error: 'baseUrl must be a valid http(s) URL' };
|
|
53
|
-
}
|
|
54
|
-
if (typeof b.model !== 'string')
|
|
55
|
-
return { error: 'model must be string' };
|
|
56
|
-
const contextTokens = Number(b.contextTokens);
|
|
57
|
-
if (!Number.isInteger(contextTokens) || contextTokens <= 0) {
|
|
58
|
-
return { error: 'contextTokens must be a positive integer' };
|
|
59
|
-
}
|
|
60
|
-
const extraEnv = {};
|
|
61
|
-
if (b.extraEnv !== undefined) {
|
|
62
|
-
if (typeof b.extraEnv !== 'object' || b.extraEnv === null || Array.isArray(b.extraEnv)) {
|
|
63
|
-
return { error: 'extraEnv must be an object' };
|
|
64
|
-
}
|
|
65
|
-
for (const [key, value] of Object.entries(b.extraEnv)) {
|
|
66
|
-
if (!ENV_KEY_PATTERN.test(key))
|
|
67
|
-
return { error: `Invalid env key: ${key}` };
|
|
68
|
-
if (typeof value !== 'string')
|
|
69
|
-
return { error: `Env value must be string: ${key}` };
|
|
70
|
-
extraEnv[key] = value;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return {
|
|
74
|
-
settings: { enabled: b.enabled, baseUrl, model: b.model.trim(), contextTokens, extraEnv },
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
/** Ollama タブを起動できる状態か(オンボーディング完了判定にも使う) */
|
|
78
|
-
export function isOllamaReady(settings) {
|
|
79
|
-
return settings.enabled && settings.model !== '';
|
|
29
|
+
return { settings: { enabled: b.enabled, baseUrl } };
|
|
80
30
|
}
|
|
81
31
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
32
|
+
* コンテキスト長を固定した派生モデルの名前(例: qwen3.6:35b-a3b → qwen3.6:35b-a3b-tsunagi-ctx65536)。
|
|
33
|
+
* Anthropic 互換 API ではリクエスト毎に num_ctx を渡せず、Ollama 全体の既定値(VRAM 24GiB 未満は 4k)
|
|
34
|
+
* が使われるため、num_ctx を焼き込んだモデルを作ってそれを使う。
|
|
84
35
|
*/
|
|
85
|
-
export function
|
|
86
|
-
const
|
|
87
|
-
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
36
|
+
export function ollamaContextModelName(model, contextTokens) {
|
|
37
|
+
const withTag = model.includes(':') ? model : `${model}:latest`;
|
|
38
|
+
return `${withTag}${CONTEXT_MODEL_MARKER}${contextTokens}`;
|
|
39
|
+
}
|
|
40
|
+
/** 派生モデルがなければ作る(重みは元モデルの blob を共有するため数秒で終わる)。作ったモデル名を返す */
|
|
41
|
+
export async function ensureOllamaContextModel(baseUrl, model, contextTokens) {
|
|
42
|
+
const name = ollamaContextModelName(model, contextTokens);
|
|
43
|
+
const show = await fetch(`${baseUrl}/api/show`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: JSON.stringify({ model: name }),
|
|
46
|
+
signal: AbortSignal.timeout(10000),
|
|
47
|
+
});
|
|
48
|
+
if (show.ok)
|
|
49
|
+
return name;
|
|
50
|
+
const res = await fetch(`${baseUrl}/api/create`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
model: name,
|
|
54
|
+
from: model,
|
|
55
|
+
parameters: { num_ctx: contextTokens },
|
|
56
|
+
stream: false,
|
|
57
|
+
}),
|
|
58
|
+
signal: AbortSignal.timeout(120000),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const body = (await res.json().catch(() => ({})));
|
|
62
|
+
throw new Error(`Ollama でコンテキスト長 ${contextTokens} のモデルを作成できません: ${body.error ?? res.status}`);
|
|
63
|
+
}
|
|
64
|
+
return name;
|
|
65
|
+
}
|
|
66
|
+
/** モデルをメモリに読み込む(空のプロンプトで generate すると読み込みだけ行われる) */
|
|
67
|
+
export async function loadOllamaModel(baseUrl, name) {
|
|
68
|
+
const res = await fetch(`${baseUrl}/api/generate`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: JSON.stringify({ model: name }),
|
|
71
|
+
signal: AbortSignal.timeout(LOAD_TIMEOUT_MS),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = (await res.json().catch(() => ({})));
|
|
75
|
+
throw new Error(`Ollama でモデルを読み込めません: ${body.error ?? res.status}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** モデルをメモリから外す */
|
|
79
|
+
export async function unloadOllamaModel(baseUrl, name) {
|
|
80
|
+
const res = await fetch(`${baseUrl}/api/generate`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
body: JSON.stringify({ model: name, keep_alive: 0 }),
|
|
83
|
+
signal: AbortSignal.timeout(60000),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw new Error(`Ollama の unload に失敗しました (${res.status})`);
|
|
108
87
|
}
|
|
109
88
|
/**
|
|
110
89
|
* Ollama の ollama.com サインイン状態を取得する。
|
|
@@ -123,11 +102,45 @@ export async function getOllamaAccount(baseUrl) {
|
|
|
123
102
|
return { signedIn: false, signinUrl: json.signin_url };
|
|
124
103
|
throw new Error(`Ollama responded ${res.status}`);
|
|
125
104
|
}
|
|
126
|
-
/** Ollama に pull
|
|
105
|
+
/** Ollama に pull 済みのモデル一覧を取得する(tsunagi が作った派生モデルは除く) */
|
|
127
106
|
export async function listOllamaModels(baseUrl) {
|
|
128
107
|
const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
129
108
|
if (!res.ok)
|
|
130
109
|
throw new Error(`Ollama responded ${res.status}`);
|
|
131
110
|
const json = (await res.json());
|
|
132
|
-
return (json.models ?? [])
|
|
111
|
+
return (json.models ?? [])
|
|
112
|
+
.filter((m) => !m.name.includes(CONTEXT_MODEL_MARKER))
|
|
113
|
+
.map((m) => ({
|
|
114
|
+
provider: 'ollama',
|
|
115
|
+
model: m.name,
|
|
116
|
+
displayName: [m.name, m.details?.quantization_level].filter(Boolean).join(' '),
|
|
117
|
+
// Ollama の MLX モデルは format が safetensors になる
|
|
118
|
+
format: m.details?.format === 'safetensors' ? 'mlx' : (m.details?.format ?? ''),
|
|
119
|
+
sizeBytes: m.size ?? 0,
|
|
120
|
+
maxContextLength: null,
|
|
121
|
+
}))
|
|
122
|
+
.sort((a, b) => a.model.localeCompare(b.model));
|
|
123
|
+
}
|
|
124
|
+
/** Ollama サーバーの接続可否・バージョン・読み込み中のモデル */
|
|
125
|
+
export async function getOllamaStatus(baseUrl) {
|
|
126
|
+
try {
|
|
127
|
+
const [versionRes, psRes] = await Promise.all([
|
|
128
|
+
fetch(`${baseUrl}/api/version`, { signal: AbortSignal.timeout(3000) }),
|
|
129
|
+
fetch(`${baseUrl}/api/ps`, { signal: AbortSignal.timeout(3000) }),
|
|
130
|
+
]);
|
|
131
|
+
const version = (await versionRes.json()).version;
|
|
132
|
+
const ps = (await psRes.json());
|
|
133
|
+
return {
|
|
134
|
+
reachable: true,
|
|
135
|
+
version,
|
|
136
|
+
loaded: (ps.models ?? []).map((m) => ({ name: m.name, contextLength: m.context_length })),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return {
|
|
141
|
+
reachable: false,
|
|
142
|
+
loaded: [],
|
|
143
|
+
error: error instanceof Error ? error.message : String(error),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
133
146
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { prisma } from '../db.js';
|
|
2
|
+
import { getWorktreePath } from '../worktree-manager.js';
|
|
2
3
|
// タスク一覧取得(deleted: false のみデフォルト)
|
|
3
4
|
export async function getTasks(filter) {
|
|
4
5
|
const tasks = await prisma.task.findMany({
|
|
@@ -226,6 +227,8 @@ function mapTask(task) {
|
|
|
226
227
|
baseBranch: task.baseBranch,
|
|
227
228
|
repoId: task.repoId,
|
|
228
229
|
worktreeStatus: task.worktreeStatus,
|
|
230
|
+
// 派生値。REST / Socket.IO の全経路で必ず含める(欠けるとタブの cwd が既定ディレクトリに化ける)
|
|
231
|
+
worktreePath: getWorktreePath(task.owner, task.repo, task.branch),
|
|
229
232
|
pullRequestUrl: task.pullRequestUrl ?? undefined,
|
|
230
233
|
effort: task.effort ?? undefined,
|
|
231
234
|
order: task.order,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SearxngSettings, SearxngStatus } from '@minimalcorp/tsunagi-shared';
|
|
2
|
+
export declare function defaultSearxngSettings(): SearxngSettings;
|
|
3
|
+
export declare function getSearxngSettings(): Promise<SearxngSettings>;
|
|
4
|
+
export declare function saveSearxngSettings(settings: SearxngSettings): Promise<SearxngSettings>;
|
|
5
|
+
export declare function parseSearxngSettings(body: unknown): {
|
|
6
|
+
settings: SearxngSettings;
|
|
7
|
+
} | {
|
|
8
|
+
error: string;
|
|
9
|
+
};
|
|
10
|
+
/** searxng-run を探す(設定値 → PATH)。見つからなければ null */
|
|
11
|
+
export declare function findSearxngBin(settings: SearxngSettings): string | null;
|
|
12
|
+
/** Ollama / LM Studio の有効状態に合わせて起動・停止する。失敗しても例外は投げない */
|
|
13
|
+
export declare function syncSearxng(): Promise<void>;
|
|
14
|
+
/** 手動での再起動(設定変更後やエラーからの復帰) */
|
|
15
|
+
export declare function restartSearxng(): Promise<void>;
|
|
16
|
+
export declare function stopSearxng(): Promise<void>;
|
|
17
|
+
/** tsunagi 終了時に、自分が起動した SearXNG だけ止める('exit' でも呼べるよう同期処理) */
|
|
18
|
+
export declare function stopSearxngOnExit(): void;
|
|
19
|
+
export declare function getSearxngStatus(): Promise<SearxngStatus>;
|
|
20
|
+
export interface WebSearchResult {
|
|
21
|
+
title: string;
|
|
22
|
+
url: string;
|
|
23
|
+
snippet: string;
|
|
24
|
+
}
|
|
25
|
+
/** SearXNG で検索する。使えない状態なら理由をエラーにする */
|
|
26
|
+
export declare function searchWeb(query: string, maxResults: number): Promise<WebSearchResult[]>;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { readAppSetting, writeAppSetting } from './local-llm-env.js';
|
|
7
|
+
import { getOllamaSettings } from './ollama-settings.js';
|
|
8
|
+
import { getLmStudioSettings } from './lmstudio.js';
|
|
9
|
+
/**
|
|
10
|
+
* ローカル検索用の SearXNG をホスト上で1プロセスだけ管理する。
|
|
11
|
+
* Ollama / LM Studio のどちらかが有効なら tsunagi 起動時・有効化時に自動起動し、
|
|
12
|
+
* 両方無効化・tsunagi 終了時に止める。インストールは利用者が行う(searxng-run)。
|
|
13
|
+
* 失敗しても例外は外に出さず状態として記録するだけにし、tsunagi 本体の起動を妨げない。
|
|
14
|
+
*/
|
|
15
|
+
const SETTING_KEY = 'searxng';
|
|
16
|
+
// 電話のキーパッドで s=7 x=9 n=6 g=4
|
|
17
|
+
const DEFAULT_PORT = 7964;
|
|
18
|
+
const SEARXNG_DIR = path.join(os.homedir(), '.tsunagi', 'searxng');
|
|
19
|
+
const GENERATED_SETTINGS_PATH = path.join(SEARXNG_DIR, 'settings.yml');
|
|
20
|
+
// 強制終了で残ったプロセスを次回起動時に片付けるための記録
|
|
21
|
+
const PID_FILE = path.join(SEARXNG_DIR, 'searxng.pid');
|
|
22
|
+
const STARTUP_TIMEOUT_MS = 60_000;
|
|
23
|
+
const STDERR_TAIL_MAX_CHARS = 4000;
|
|
24
|
+
// JSON 形式(API)の結果を返せるようにする。secret_key は起動時に SEARXNG_SECRET で渡す
|
|
25
|
+
const GENERATED_SETTINGS = `# tsunagi が生成した SearXNG の設定(起動のたびに上書きされる)
|
|
26
|
+
use_default_settings: true
|
|
27
|
+
server:
|
|
28
|
+
secret_key: "overridden-by-SEARXNG_SECRET"
|
|
29
|
+
limiter: false
|
|
30
|
+
image_proxy: false
|
|
31
|
+
search:
|
|
32
|
+
formats:
|
|
33
|
+
- html
|
|
34
|
+
- json
|
|
35
|
+
`;
|
|
36
|
+
export function defaultSearxngSettings() {
|
|
37
|
+
return { port: DEFAULT_PORT, binPath: '', settingsPath: '' };
|
|
38
|
+
}
|
|
39
|
+
export async function getSearxngSettings() {
|
|
40
|
+
return readAppSetting(SETTING_KEY, defaultSearxngSettings());
|
|
41
|
+
}
|
|
42
|
+
export async function saveSearxngSettings(settings) {
|
|
43
|
+
return writeAppSetting(SETTING_KEY, settings);
|
|
44
|
+
}
|
|
45
|
+
export function parseSearxngSettings(body) {
|
|
46
|
+
if (typeof body !== 'object' || body === null)
|
|
47
|
+
return { error: 'Invalid body' };
|
|
48
|
+
const b = body;
|
|
49
|
+
const port = Number(b.port);
|
|
50
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
51
|
+
return { error: 'port must be an integer between 1 and 65535' };
|
|
52
|
+
}
|
|
53
|
+
if (typeof b.binPath !== 'string')
|
|
54
|
+
return { error: 'binPath must be string' };
|
|
55
|
+
if (typeof b.settingsPath !== 'string')
|
|
56
|
+
return { error: 'settingsPath must be string' };
|
|
57
|
+
return {
|
|
58
|
+
settings: { port, binPath: b.binPath.trim(), settingsPath: b.settingsPath.trim() },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
let state = 'stopped';
|
|
62
|
+
let lastError;
|
|
63
|
+
let managedProcess = null;
|
|
64
|
+
// 起動・停止の同時実行を直列化する
|
|
65
|
+
let queue = Promise.resolve();
|
|
66
|
+
function serialize(task) {
|
|
67
|
+
queue = queue.then(task).catch((error) => {
|
|
68
|
+
state = 'error';
|
|
69
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
70
|
+
});
|
|
71
|
+
return queue;
|
|
72
|
+
}
|
|
73
|
+
function baseUrl(settings) {
|
|
74
|
+
return `http://127.0.0.1:${settings.port}`;
|
|
75
|
+
}
|
|
76
|
+
async function isHealthy(settings) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${baseUrl(settings)}/healthz`, { signal: AbortSignal.timeout(2000) });
|
|
79
|
+
return res.ok;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isExecutable(file) {
|
|
86
|
+
try {
|
|
87
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** searxng-run を探す(設定値 → PATH)。見つからなければ null */
|
|
95
|
+
export function findSearxngBin(settings) {
|
|
96
|
+
if (settings.binPath)
|
|
97
|
+
return isExecutable(settings.binPath) ? settings.binPath : null;
|
|
98
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
99
|
+
const candidate = path.join(dir, 'searxng-run');
|
|
100
|
+
if (dir && isExecutable(candidate))
|
|
101
|
+
return candidate;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/** Ollama / LM Studio のどちらかが有効か(= SearXNG を動かしておく必要があるか) */
|
|
106
|
+
async function isRequired() {
|
|
107
|
+
const [ollama, lmstudio] = await Promise.all([getOllamaSettings(), getLmStudioSettings()]);
|
|
108
|
+
return ollama.enabled || lmstudio.enabled;
|
|
109
|
+
}
|
|
110
|
+
function commandOf(pid) {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
execFile('ps', ['-p', String(pid), '-o', 'command='], (_error, stdout) => resolve((stdout || '').trim()));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// 残ったプロセスを止めてからポートが空くまで待つ上限
|
|
116
|
+
const STALE_EXIT_TIMEOUT_MS = 10_000;
|
|
117
|
+
/** 前回 tsunagi が強制終了されて残った SearXNG を止める */
|
|
118
|
+
async function killStaleProcess(settings) {
|
|
119
|
+
let pid;
|
|
120
|
+
try {
|
|
121
|
+
pid = Number(fs.readFileSync(PID_FILE, 'utf8').trim());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
127
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
128
|
+
return;
|
|
129
|
+
// pid が別プロセスに再利用されている可能性があるため、SearXNG のときだけ止める
|
|
130
|
+
if (!/searx/i.test(await commandOf(pid)))
|
|
131
|
+
return;
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 'SIGTERM');
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// 既に終了している
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// 終了前に応答が返ると「外部で起動済み」と誤判定するため、応答しなくなるまで待つ
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
while (Date.now() - startedAt < STALE_EXIT_TIMEOUT_MS && (await isHealthy(settings))) {
|
|
142
|
+
await sleep(300);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function sleep(ms) {
|
|
146
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
147
|
+
}
|
|
148
|
+
async function start(settings) {
|
|
149
|
+
if (managedProcess)
|
|
150
|
+
return;
|
|
151
|
+
await killStaleProcess(settings);
|
|
152
|
+
if (await isHealthy(settings)) {
|
|
153
|
+
state = 'external';
|
|
154
|
+
lastError = undefined;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const bin = findSearxngBin(settings);
|
|
158
|
+
if (!bin) {
|
|
159
|
+
state = 'not-installed';
|
|
160
|
+
lastError = undefined;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
fs.mkdirSync(SEARXNG_DIR, { recursive: true });
|
|
164
|
+
let settingsPath = settings.settingsPath;
|
|
165
|
+
if (!settingsPath) {
|
|
166
|
+
fs.writeFileSync(GENERATED_SETTINGS_PATH, GENERATED_SETTINGS);
|
|
167
|
+
settingsPath = GENERATED_SETTINGS_PATH;
|
|
168
|
+
}
|
|
169
|
+
state = 'starting';
|
|
170
|
+
lastError = undefined;
|
|
171
|
+
const child = spawn(bin, [], {
|
|
172
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
173
|
+
env: {
|
|
174
|
+
...process.env,
|
|
175
|
+
SEARXNG_SETTINGS_PATH: settingsPath,
|
|
176
|
+
SEARXNG_PORT: String(settings.port),
|
|
177
|
+
SEARXNG_BIND_ADDRESS: '127.0.0.1',
|
|
178
|
+
SEARXNG_SECRET: crypto.randomBytes(32).toString('hex'),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
let stderrTail = '';
|
|
182
|
+
child.stderr?.on('data', (chunk) => {
|
|
183
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_MAX_CHARS);
|
|
184
|
+
});
|
|
185
|
+
let exitInfo = null;
|
|
186
|
+
child.on('exit', (code, signal) => {
|
|
187
|
+
exitInfo = `exit code=${code} signal=${signal}`;
|
|
188
|
+
if (managedProcess !== child)
|
|
189
|
+
return;
|
|
190
|
+
managedProcess = null;
|
|
191
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
192
|
+
// 停止操作以外で落ちた場合はエラーとして残す(自動での再起動はしない)
|
|
193
|
+
if (state === 'running' || state === 'starting') {
|
|
194
|
+
state = 'error';
|
|
195
|
+
lastError = `SearXNG が終了しました (${exitInfo})${stderrTail ? `\n${stderrTail}` : ''}`;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
child.on('error', (err) => {
|
|
199
|
+
exitInfo = `spawn error: ${err.message}`;
|
|
200
|
+
});
|
|
201
|
+
managedProcess = child;
|
|
202
|
+
if (child.pid)
|
|
203
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
while (Date.now() - startedAt < STARTUP_TIMEOUT_MS) {
|
|
206
|
+
if (exitInfo) {
|
|
207
|
+
managedProcess = null;
|
|
208
|
+
throw new Error(`SearXNG の起動に失敗しました (${exitInfo})${stderrTail ? `\n${stderrTail}` : ''}`);
|
|
209
|
+
}
|
|
210
|
+
if (await isHealthy(settings)) {
|
|
211
|
+
state = 'running';
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await sleep(500);
|
|
215
|
+
}
|
|
216
|
+
child.kill();
|
|
217
|
+
managedProcess = null;
|
|
218
|
+
throw new Error(`SearXNG が ${STARTUP_TIMEOUT_MS / 1000} 秒以内に応答しませんでした`);
|
|
219
|
+
}
|
|
220
|
+
function stop() {
|
|
221
|
+
if (managedProcess) {
|
|
222
|
+
// exit ハンドラでエラー扱いしないよう先に状態を変える
|
|
223
|
+
state = 'stopped';
|
|
224
|
+
managedProcess.kill();
|
|
225
|
+
managedProcess = null;
|
|
226
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
227
|
+
}
|
|
228
|
+
else if (state !== 'external') {
|
|
229
|
+
state = 'stopped';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Ollama / LM Studio の有効状態に合わせて起動・停止する。失敗しても例外は投げない */
|
|
233
|
+
export function syncSearxng() {
|
|
234
|
+
return serialize(async () => {
|
|
235
|
+
const settings = await getSearxngSettings();
|
|
236
|
+
if (await isRequired()) {
|
|
237
|
+
await start(settings);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
stop();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/** 手動での再起動(設定変更後やエラーからの復帰) */
|
|
245
|
+
export function restartSearxng() {
|
|
246
|
+
return serialize(async () => {
|
|
247
|
+
stop();
|
|
248
|
+
lastError = undefined;
|
|
249
|
+
await start(await getSearxngSettings());
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
export function stopSearxng() {
|
|
253
|
+
return serialize(async () => stop());
|
|
254
|
+
}
|
|
255
|
+
/** tsunagi 終了時に、自分が起動した SearXNG だけ止める('exit' でも呼べるよう同期処理) */
|
|
256
|
+
export function stopSearxngOnExit() {
|
|
257
|
+
if (!managedProcess)
|
|
258
|
+
return;
|
|
259
|
+
managedProcess.kill();
|
|
260
|
+
managedProcess = null;
|
|
261
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
262
|
+
}
|
|
263
|
+
export async function getSearxngStatus() {
|
|
264
|
+
const settings = await getSearxngSettings();
|
|
265
|
+
const required = await isRequired();
|
|
266
|
+
let current = state;
|
|
267
|
+
// 外部で起動・停止された場合に追従する
|
|
268
|
+
if (current === 'external' || current === 'stopped' || current === 'not-installed') {
|
|
269
|
+
if (await isHealthy(settings))
|
|
270
|
+
current = 'external';
|
|
271
|
+
else if (current === 'external')
|
|
272
|
+
current = 'stopped';
|
|
273
|
+
state = current;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
state: current,
|
|
277
|
+
url: baseUrl(settings),
|
|
278
|
+
required,
|
|
279
|
+
binPath: findSearxngBin(settings),
|
|
280
|
+
error: current === 'error' ? lastError : undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** SearXNG で検索する。使えない状態なら理由をエラーにする */
|
|
284
|
+
export async function searchWeb(query, maxResults) {
|
|
285
|
+
const settings = await getSearxngSettings();
|
|
286
|
+
if (!(await isHealthy(settings))) {
|
|
287
|
+
const { state: current } = await getSearxngStatus();
|
|
288
|
+
const reason = current === 'not-installed'
|
|
289
|
+
? 'SearXNG がインストールされていません(searxng-run が見つかりません)'
|
|
290
|
+
: current === 'starting'
|
|
291
|
+
? 'SearXNG を起動中です。少し待ってから再試行してください'
|
|
292
|
+
: 'SearXNG が起動していません。tsunagi の Settings から起動してください';
|
|
293
|
+
throw new Error(reason);
|
|
294
|
+
}
|
|
295
|
+
const url = `${baseUrl(settings)}/search?${new URLSearchParams({ q: query, format: 'json' })}`;
|
|
296
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
297
|
+
if (res.status === 403) {
|
|
298
|
+
throw new Error('SearXNG が JSON 形式を許可していません(settings.yml の search.formats に json が必要)');
|
|
299
|
+
}
|
|
300
|
+
if (!res.ok)
|
|
301
|
+
throw new Error(`SearXNG responded ${res.status}`);
|
|
302
|
+
const json = (await res.json());
|
|
303
|
+
return (json.results ?? []).slice(0, maxResults).map((r) => ({
|
|
304
|
+
title: r.title ?? '',
|
|
305
|
+
url: r.url ?? '',
|
|
306
|
+
snippet: r.content ?? '',
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
@@ -88,9 +88,7 @@ export async function getTask(identifier) {
|
|
|
88
88
|
if (!task) {
|
|
89
89
|
throw new TaskServiceError('Task not found', 'TASK_NOT_FOUND');
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
const worktreePath = worktreeManager.getWorktreePath(task.owner, task.repo, task.branch);
|
|
93
|
-
return { ...task, worktreePath };
|
|
91
|
+
return task;
|
|
94
92
|
}
|
|
95
93
|
/**
|
|
96
94
|
* タスクを作成
|