@minimalcorp/tsunagi 0.1.38 → 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.
Files changed (127) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +5 -5
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/server/app/_global-error/page/build-manifest.json +2 -2
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found/page/build-manifest.json +2 -2
  13. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +2 -2
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  23. package/.next/standalone/apps/web/.next/server/app/index.rsc +3 -3
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +3 -3
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  27. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
  28. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  29. package/.next/standalone/apps/web/.next/server/app/page/build-manifest.json +2 -2
  30. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/settings/page/build-manifest.json +2 -2
  33. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  34. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  36. package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
  37. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  38. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  39. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  40. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  41. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  42. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  43. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page/build-manifest.json +2 -2
  44. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  45. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0nhzci6._.js +4 -8
  47. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0vi01rl._.js +4 -8
  48. 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
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/_04l3mh~._.js +10 -0
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bt-4.4._.js +3 -0
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0e3431w._.js +10 -0
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0lirkqp._.js +10 -0
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0soxqr.._.js +3 -0
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +1 -1
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +4 -5
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0h0imx2._.js → node_modules_0_4__2k._.js} +1 -1
  58. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0pbr55t._.js → node_modules_0bqsyfk._.js} +1 -1
  59. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_06vr-0a._.js → node_modules_0bzw~9d._.js} +1 -1
  60. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0k7u4.c._.js → node_modules_0fsewd2._.js} +1 -1
  61. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0c6z_cw._.js → node_modules_0l~40og._.js} +1 -1
  62. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0jo-w3s._.js → node_modules_0pk6e0.._.js} +1 -1
  63. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zkg-x0._.js → node_modules_0smdx~2._.js} +1 -1
  64. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0fcqekz._.js → node_modules_0uej915._.js} +1 -1
  65. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +5 -5
  66. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  67. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  68. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  69. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  70. package/.next/standalone/apps/web/.next/static/chunks/{0p.4ej_8gk7m7.js → 02.u6zk77a338.js} +10 -11
  71. package/.next/standalone/apps/web/.next/static/chunks/05n6~h_wwg.jj.js +5 -0
  72. package/.next/standalone/apps/web/.next/static/chunks/0_ygv4z~5c_9z.js +5 -0
  73. package/.next/standalone/apps/web/.next/static/chunks/0g5hoj5w-z.~6.js +1 -0
  74. package/.next/standalone/apps/web/.next/static/chunks/0nhcl~svhi_26.js +7 -0
  75. package/.next/standalone/apps/web/.next/static/chunks/0p4dfdgz~3qt3.js +5 -0
  76. package/.next/standalone/apps/web/.next/static/chunks/0p9~lk406udl5.css +4 -0
  77. package/.next/standalone/apps/web/.next/static/chunks/0q-u7bl7oa42n.js +1 -0
  78. package/.next/standalone/apps/web/.next/static/chunks/0q_j4~6_etj_q.js +7 -0
  79. package/.next/standalone/apps/web/.next/static/chunks/0zjymvayyxt-d.js +5 -0
  80. package/.next/standalone/apps/web/.next/static/chunks/{0lp.2eizum8y~.js → 11rs2-0jl1rzr.js} +1 -1
  81. package/.next/standalone/apps/web/.next/static/chunks/{15~pc9ql1ab38.js → 158q94mo2-6sx.js} +7 -7
  82. package/.next/standalone/apps/web/.next/static/chunks/167xa3jy_0g.u.js +1 -0
  83. package/.next/standalone/apps/web/.next/static/chunks/{turbopack-0079niub0zo4u.js → turbopack-07pr2uza0.4cp.js} +1 -1
  84. package/dist/server/basic-auth.js +9 -1
  85. package/dist/server/generated/prisma/internal/class.js +1 -1
  86. package/dist/server/index.js +20 -0
  87. package/dist/server/lib/claude-command.d.ts +6 -0
  88. package/dist/server/lib/claude-command.js +85 -0
  89. package/dist/server/lib/claude-config-guard.d.ts +12 -0
  90. package/dist/server/lib/claude-config-guard.js +49 -0
  91. package/dist/server/lib/lmstudio.d.ts +24 -0
  92. package/dist/server/lib/lmstudio.js +198 -0
  93. package/dist/server/lib/local-llm-env.d.ts +28 -0
  94. package/dist/server/lib/local-llm-env.js +91 -0
  95. package/dist/server/lib/local-llm.d.ts +56 -0
  96. package/dist/server/lib/local-llm.js +366 -0
  97. package/dist/server/lib/ollama-settings.d.ts +16 -10
  98. package/dist/server/lib/ollama-settings.js +104 -91
  99. package/dist/server/lib/searxng.d.ts +26 -0
  100. package/dist/server/lib/searxng.js +308 -0
  101. package/dist/server/routes/local-llm.d.ts +2 -0
  102. package/dist/server/routes/local-llm.js +192 -0
  103. package/dist/server/routes/mcp-web.d.ts +2 -0
  104. package/dist/server/routes/mcp-web.js +107 -0
  105. package/dist/server/routes/onboarding.js +4 -4
  106. package/dist/server/routes/settings.js +100 -26
  107. package/dist/server/routes/tasks.js +5 -1
  108. package/dist/server/routes/terminal.js +53 -11
  109. package/npm-shrinkwrap.json +5 -5
  110. package/package.json +1 -1
  111. package/prisma/schema.prisma +1 -1
  112. package/.next/standalone/apps/web/.next/server/chunks/ssr/_016-lxb._.js +0 -3
  113. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hh~0f6._.js +0 -3
  114. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0r0xwce._.js +0 -6
  115. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0sbp1da._.js +0 -6
  116. package/.next/standalone/apps/web/.next/server/chunks/ssr/_12c1w~~._.js +0 -6
  117. package/.next/standalone/apps/web/.next/static/chunks/0229rjr88pv9k.js +0 -1
  118. package/.next/standalone/apps/web/.next/static/chunks/04l9g6rg7~-d5.js +0 -1
  119. package/.next/standalone/apps/web/.next/static/chunks/0citvl2medd0s.js +0 -11
  120. package/.next/standalone/apps/web/.next/static/chunks/0e8u7rqrqfiiy.js +0 -1
  121. package/.next/standalone/apps/web/.next/static/chunks/0pg---_6a_4ls.css +0 -4
  122. package/.next/standalone/apps/web/.next/static/chunks/0uv~zl3--e3wo.js +0 -11
  123. package/.next/standalone/apps/web/.next/static/chunks/11rpr3..poyv8.js +0 -5
  124. package/.next/standalone/apps/web/.next/static/chunks/1200zkjn5n2w7.js +0 -1
  125. /package/.next/standalone/apps/web/.next/static/{TJDp2MIqJ14hotMmE5sIo → C1cjF-gU-snY_atxJbn1v}/_buildManifest.js +0 -0
  126. /package/.next/standalone/apps/web/.next/static/{TJDp2MIqJ14hotMmE5sIo → C1cjF-gU-snY_atxJbn1v}/_clientMiddlewareManifest.js +0 -0
  127. /package/.next/standalone/apps/web/.next/static/{TJDp2MIqJ14hotMmE5sIo → C1cjF-gU-snY_atxJbn1v}/_ssgManifest.js +0 -0
@@ -1,39 +1,20 @@
1
- import { prisma } from './db.js';
2
- const SETTING_KEY = 'ollama';
3
- const DEFAULT_CONTEXT_TOKENS = 65536;
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
- const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
- /** Ollama タブでは Anthropic の認証情報を環境変数ごと取り除く(ANTHROPIC_AUTH_TOKEN と競合するため) */
8
- export const OLLAMA_UNSET_ENV_KEYS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
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 row = await prisma.appSetting.findUnique({ where: { key: SETTING_KEY } });
20
- if (!row)
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
- const value = JSON.stringify(settings);
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 = typeof b.baseUrl === 'string' ? b.baseUrl.trim().replace(/\/+$/, '') : '';
46
- try {
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
- * Ollama の Anthropic 互換 API で Claude Code を動かすための環境変数。
83
- * DB の環境変数より後に適用し、同名キーを上書きする。
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 buildOllamaEnv(settings) {
86
- const { baseUrl, model, contextTokens, extraEnv } = settings;
87
- return {
88
- ANTHROPIC_BASE_URL: baseUrl,
89
- // Ollama は値を検証しないが、未設定だと Claude Code がログインを要求する
90
- ANTHROPIC_AUTH_TOKEN: 'ollama',
91
- ANTHROPIC_MODEL: model,
92
- ANTHROPIC_DEFAULT_OPUS_MODEL: model,
93
- ANTHROPIC_DEFAULT_SONNET_MODEL: model,
94
- ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
95
- CLAUDE_CODE_SUBAGENT_MODEL: model,
96
- // 未知のモデルは 200k 扱いになり auto-compact が効かないため、Ollama 側の値に揃える
97
- CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextTokens),
98
- // リクエスト毎に変わる attribution がローカル側の KV キャッシュを無効化するため外す
99
- CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
100
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
101
- // ローカルLLMはプロンプト読み込み中(数分)にバイトを返さないため、既定の 5 分の
102
- // 無通信タイムアウト / stream watchdog で打ち切られ再送される。上限(30分)まで延ばす
103
- API_FORCE_IDLE_TIMEOUT: '0',
104
- CLAUDE_STREAM_IDLE_TIMEOUT_MS: '1800000',
105
- API_TIMEOUT_MS: '3600000',
106
- ...extraEnv,
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 ?? []).map((m) => m.name).sort();
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
  }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function localLlmRoutes(fastify: FastifyInstance): Promise<void>;