@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.
Files changed (129) 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/{094hkpbli0k9s.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/repositories/task.js +3 -0
  100. package/dist/server/lib/searxng.d.ts +26 -0
  101. package/dist/server/lib/searxng.js +308 -0
  102. package/dist/server/lib/services/task-service.js +1 -3
  103. package/dist/server/routes/local-llm.d.ts +2 -0
  104. package/dist/server/routes/local-llm.js +192 -0
  105. package/dist/server/routes/mcp-web.d.ts +2 -0
  106. package/dist/server/routes/mcp-web.js +107 -0
  107. package/dist/server/routes/onboarding.js +4 -4
  108. package/dist/server/routes/settings.js +100 -26
  109. package/dist/server/routes/tasks.js +5 -1
  110. package/dist/server/routes/terminal.js +77 -28
  111. package/npm-shrinkwrap.json +5 -5
  112. package/package.json +1 -1
  113. package/prisma/schema.prisma +1 -1
  114. package/.next/standalone/apps/web/.next/server/chunks/ssr/_016-lxb._.js +0 -3
  115. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hh~0f6._.js +0 -3
  116. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0r0xwce._.js +0 -6
  117. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0sbp1da._.js +0 -6
  118. package/.next/standalone/apps/web/.next/server/chunks/ssr/_12c1w~~._.js +0 -6
  119. package/.next/standalone/apps/web/.next/static/chunks/0229rjr88pv9k.js +0 -1
  120. package/.next/standalone/apps/web/.next/static/chunks/04l9g6rg7~-d5.js +0 -1
  121. package/.next/standalone/apps/web/.next/static/chunks/0citvl2medd0s.js +0 -11
  122. package/.next/standalone/apps/web/.next/static/chunks/0e8u7rqrqfiiy.js +0 -1
  123. package/.next/standalone/apps/web/.next/static/chunks/0pg---_6a_4ls.css +0 -4
  124. package/.next/standalone/apps/web/.next/static/chunks/0uv~zl3--e3wo.js +0 -11
  125. package/.next/standalone/apps/web/.next/static/chunks/11rpr3..poyv8.js +0 -5
  126. package/.next/standalone/apps/web/.next/static/chunks/1200zkjn5n2w7.js +0 -1
  127. /package/.next/standalone/apps/web/.next/static/{otC3BXpS9D6HI8k0EQ2ZH → C1cjF-gU-snY_atxJbn1v}/_buildManifest.js +0 -0
  128. /package/.next/standalone/apps/web/.next/static/{otC3BXpS9D6HI8k0EQ2ZH → C1cjF-gU-snY_atxJbn1v}/_clientMiddlewareManifest.js +0 -0
  129. /package/.next/standalone/apps/web/.next/static/{otC3BXpS9D6HI8k0EQ2ZH → C1cjF-gU-snY_atxJbn1v}/_ssgManifest.js +0 -0
@@ -0,0 +1,91 @@
1
+ import { prisma } from './db.js';
2
+ /**
3
+ * ローカルLLM(Ollama / LM Studio)の Anthropic 互換 API で Claude Code を動かすための共通処理。
4
+ */
5
+ const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
+ /** ローカルLLMタブでは Anthropic の認証情報を環境変数ごと取り除く(ANTHROPIC_AUTH_TOKEN と競合するため) */
7
+ export const LOCAL_LLM_UNSET_ENV_KEYS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
8
+ /** AppSetting から JSON 設定を読む。未保存・破損時は既定値 */
9
+ export async function readAppSetting(key, defaults) {
10
+ const row = await prisma.appSetting.findUnique({ where: { key } });
11
+ if (!row)
12
+ return defaults;
13
+ try {
14
+ return { ...defaults, ...JSON.parse(row.value) };
15
+ }
16
+ catch {
17
+ return defaults;
18
+ }
19
+ }
20
+ export async function writeAppSetting(key, settings) {
21
+ const value = JSON.stringify(settings);
22
+ await prisma.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } });
23
+ return settings;
24
+ }
25
+ /** http(s) の URL を末尾スラッシュなしに正規化する。不正なら null */
26
+ export function normalizeBaseUrl(value) {
27
+ const baseUrl = typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
28
+ try {
29
+ const url = new URL(baseUrl);
30
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
31
+ return null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ return baseUrl;
37
+ }
38
+ export function parseContextTokens(value) {
39
+ const n = Number(value);
40
+ return Number.isInteger(n) && n > 0 ? n : null;
41
+ }
42
+ /** extraEnv を検証する。不正ならエラーメッセージ */
43
+ export function parseExtraEnv(value) {
44
+ const env = {};
45
+ if (value === undefined)
46
+ return { env };
47
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
48
+ return { error: 'extraEnv must be an object' };
49
+ }
50
+ for (const [key, v] of Object.entries(value)) {
51
+ if (!ENV_KEY_PATTERN.test(key))
52
+ return { error: `Invalid env key: ${key}` };
53
+ if (typeof v !== 'string')
54
+ return { error: `Env value must be string: ${key}` };
55
+ env[key] = v;
56
+ }
57
+ return { env };
58
+ }
59
+ /**
60
+ * ローカルLLMタブの Claude Code が名乗るモデル名。tsunagi の中継口が使用中のモデルに書き換えるため、
61
+ * タブを作った後にモデルを切り替えても既存タブがそのまま追従する。
62
+ */
63
+ export const LOCAL_MODEL_ALIAS = 'tsunagi-local';
64
+ /**
65
+ * ローカルLLMタブの Claude Code を tsunagi の中継口に向けるための環境変数。
66
+ * DB の環境変数より後に適用し、同名キーを上書きする。
67
+ */
68
+ export function buildLocalLlmEnv(params) {
69
+ const { proxyUrl, contextTokens, extraEnv } = params;
70
+ return {
71
+ ANTHROPIC_BASE_URL: proxyUrl,
72
+ // 中継口は値を検証しないが、未設定だと Claude Code がログインを要求する
73
+ ANTHROPIC_AUTH_TOKEN: 'tsunagi',
74
+ ANTHROPIC_MODEL: LOCAL_MODEL_ALIAS,
75
+ // opus / sonnet / haiku 等の別名はあえて割り当てない。中継口がどのモデル名でも使用中のモデルに
76
+ // 書き換えるため不要で、割り当てると `/model sonnet` が同じモデルへの切り替えとみなされ、
77
+ // PreModelSwitch フックを通らずに ~/.claude/settings.json へ保存されてしまう
78
+ CLAUDE_CODE_SUBAGENT_MODEL: LOCAL_MODEL_ALIAS,
79
+ // 未知のモデルは 200k 扱いになり auto-compact が効かないため、ローカル側の値に揃える
80
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextTokens),
81
+ // リクエスト毎に変わる attribution がローカル側の KV キャッシュを無効化するため外す
82
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
83
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
84
+ // ローカルLLMはプロンプト読み込み中(数分)にバイトを返さないため、既定の 5 分の
85
+ // 無通信タイムアウト / stream watchdog で打ち切られ再送される。上限(30分)まで延ばす
86
+ API_FORCE_IDLE_TIMEOUT: '0',
87
+ CLAUDE_STREAM_IDLE_TIMEOUT_MS: '1800000',
88
+ API_TIMEOUT_MS: '3600000',
89
+ ...extraEnv,
90
+ };
91
+ }
@@ -0,0 +1,56 @@
1
+ import type { LocalLlmModel, LocalLlmModelRef, LocalLlmSettings, LocalLlmStatus } from '@minimalcorp/tsunagi-shared';
2
+ export declare function defaultLocalLlmSettings(): LocalLlmSettings;
3
+ export declare function getLocalLlmSettings(): Promise<LocalLlmSettings>;
4
+ export declare function saveLocalLlmSettings(settings: LocalLlmSettings): Promise<LocalLlmSettings>;
5
+ /** リクエストボディを検証して LocalLlmSettings に正規化する。不正ならエラーメッセージを返す */
6
+ export declare function parseLocalLlmSettings(body: unknown): {
7
+ settings: LocalLlmSettings;
8
+ } | {
9
+ error: string;
10
+ };
11
+ /** ローカルLLMタブを起動できる状態か(オンボーディング完了判定にも使う) */
12
+ export declare function isLocalLlmReady(): Promise<boolean>;
13
+ /** 有効なプロバイダーのダウンロード済みモデル。取得に失敗したプロバイダーはエラーとして返す */
14
+ export declare function listLocalLlmModels(): Promise<{
15
+ models: LocalLlmModel[];
16
+ errors: Array<{
17
+ provider: LocalLlmModelRef['provider'];
18
+ message: string;
19
+ }>;
20
+ }>;
21
+ /** 中継口の転送先 */
22
+ export interface LocalLlmTarget {
23
+ baseUrl: string;
24
+ /** 転送時に付ける認証トークン */
25
+ authToken: string;
26
+ /** リクエストの model に入れる名前(Ollama はコンテキスト長を焼き込んだ派生モデル) */
27
+ runModel: string;
28
+ }
29
+ /** 指定したプロバイダーのモデルを全部解放する(プロバイダーを無効化する前に使う) */
30
+ export declare function unloadProvider(provider: LocalLlmModelRef['provider']): Promise<void>;
31
+ /**
32
+ * 使用中のモデルを読み込み(他のモデルは全部解放し)、中継口の転送先を返す。
33
+ * 読み込み済みなら何もしない。設定の不備はそのまま例外にする(タブにエラーとして表示される)。
34
+ */
35
+ export declare function ensureActiveLoaded(): Promise<LocalLlmTarget>;
36
+ /** すべてのモデルを解放する(Settings の解放ボタン・自動解放・tsunagi 終了時) */
37
+ export declare function unloadAll(): Promise<void>;
38
+ /** 中継口がリクエストを受け付けたとき */
39
+ export declare function beginRequest(): void;
40
+ /** 中継口のリクエストが終わったとき(成功・失敗・中断のいずれも) */
41
+ export declare function endRequest(): void;
42
+ export declare function getInflight(): number;
43
+ export declare function getLocalLlmStatus(): Promise<LocalLlmStatus>;
44
+ /**
45
+ * 使用中のモデルを切り替える。応答生成中のタブがあれば切り替えない(生成中のモデルを外すと壊れるため)。
46
+ * 読み込みは待たずに始める(状態は getLocalLlmStatus で確認する)。
47
+ */
48
+ export declare function activate(next: LocalLlmSettings): Promise<{
49
+ ok: true;
50
+ settings: LocalLlmSettings;
51
+ } | {
52
+ ok: false;
53
+ error: string;
54
+ }>;
55
+ /** tsunagi 終了時(設定で有効なら)にモデルを解放する。終了を妨げないよう上限時間を設ける */
56
+ export declare function unloadOnShutdown(timeoutMs?: number): Promise<void>;
@@ -0,0 +1,366 @@
1
+ import { prisma } from './db.js';
2
+ import { parseContextTokens, parseExtraEnv, readAppSetting, writeAppSetting, } from './local-llm-env.js';
3
+ import { OLLAMA_SETTING_KEY, ensureOllamaContextModel, getOllamaSettings, getOllamaStatus, listOllamaModels, loadOllamaModel, ollamaContextModelName, unloadOllamaModel, } from './ollama-settings.js';
4
+ import { LMSTUDIO_SETTING_KEY, getLmStudioSettings, getLmStudioStatus, listLmStudioModels, lmStudioAuthToken, loadLmStudioModel, unloadLmStudioModel, } from './lmstudio.js';
5
+ /**
6
+ * ローカルLLM(Ollama / LM Studio)の「使用中のモデル」を管理する。
7
+ * 一般的なマシンでは 20〜30GB 級のモデルを2つ同時に載せられないため、メモリに載せるモデルは
8
+ * プロバイダーをまたいで常に1つにする。ローカルLLMタブのリクエストは tsunagi の中継口を通り、
9
+ * ここで使用中のモデルを読み込んで(他は全部解放して)から転送される。
10
+ */
11
+ const SETTING_KEY = 'local-llm';
12
+ export function defaultLocalLlmSettings() {
13
+ return {
14
+ active: null,
15
+ idleUnloadMinutes: 15,
16
+ unloadOnExit: true,
17
+ webSearch: 'local',
18
+ extraEnv: {},
19
+ };
20
+ }
21
+ /**
22
+ * 旧形式(Ollama / LM Studio の設定ごとにモデルを持っていた)からの移行。
23
+ * local-llm の設定がまだなく、旧 Ollama 設定にモデルがあればそれを使用中のモデルにする。
24
+ */
25
+ async function migrateLegacySettings() {
26
+ const settings = defaultLocalLlmSettings();
27
+ const rows = await prisma.appSetting.findMany({
28
+ where: { key: { in: [OLLAMA_SETTING_KEY, LMSTUDIO_SETTING_KEY] } },
29
+ });
30
+ for (const row of rows) {
31
+ let legacy;
32
+ try {
33
+ legacy = JSON.parse(row.value);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (settings.active || legacy.enabled !== true || typeof legacy.model !== 'string')
39
+ continue;
40
+ if (!legacy.model)
41
+ continue;
42
+ settings.active = {
43
+ provider: row.key === OLLAMA_SETTING_KEY ? 'ollama' : 'lmstudio',
44
+ model: legacy.model,
45
+ contextTokens: parseContextTokens(legacy.contextTokens) ?? 65536,
46
+ };
47
+ const extraEnv = parseExtraEnv(legacy.extraEnv);
48
+ if ('env' in extraEnv)
49
+ settings.extraEnv = extraEnv.env;
50
+ if (legacy.webSearch === 'ollama')
51
+ settings.webSearch = 'ollama';
52
+ }
53
+ return settings;
54
+ }
55
+ export async function getLocalLlmSettings() {
56
+ const row = await prisma.appSetting.findUnique({ where: { key: SETTING_KEY } });
57
+ if (!row)
58
+ return migrateLegacySettings();
59
+ return readAppSetting(SETTING_KEY, defaultLocalLlmSettings());
60
+ }
61
+ export async function saveLocalLlmSettings(settings) {
62
+ return writeAppSetting(SETTING_KEY, settings);
63
+ }
64
+ /** リクエストボディを検証して LocalLlmSettings に正規化する。不正ならエラーメッセージを返す */
65
+ export function parseLocalLlmSettings(body) {
66
+ if (typeof body !== 'object' || body === null)
67
+ return { error: 'Invalid body' };
68
+ const b = body;
69
+ let active = null;
70
+ if (b.active !== null && b.active !== undefined) {
71
+ const a = b.active;
72
+ if (a.provider !== 'ollama' && a.provider !== 'lmstudio') {
73
+ return { error: 'active.provider must be "ollama" or "lmstudio"' };
74
+ }
75
+ if (typeof a.model !== 'string' || !a.model.trim()) {
76
+ return { error: 'active.model must be a non-empty string' };
77
+ }
78
+ const contextTokens = parseContextTokens(a.contextTokens);
79
+ if (!contextTokens)
80
+ return { error: 'active.contextTokens must be a positive integer' };
81
+ active = { provider: a.provider, model: a.model.trim(), contextTokens };
82
+ }
83
+ const idleUnloadMinutes = Number(b.idleUnloadMinutes);
84
+ if (!Number.isInteger(idleUnloadMinutes) || idleUnloadMinutes < 0) {
85
+ return { error: 'idleUnloadMinutes must be a non-negative integer' };
86
+ }
87
+ if (typeof b.unloadOnExit !== 'boolean')
88
+ return { error: 'unloadOnExit must be boolean' };
89
+ if (b.webSearch !== 'local' && b.webSearch !== 'ollama') {
90
+ return { error: 'webSearch must be "local" or "ollama"' };
91
+ }
92
+ const extraEnv = parseExtraEnv(b.extraEnv);
93
+ if ('error' in extraEnv)
94
+ return extraEnv;
95
+ return {
96
+ settings: {
97
+ active,
98
+ idleUnloadMinutes,
99
+ unloadOnExit: b.unloadOnExit,
100
+ webSearch: b.webSearch,
101
+ extraEnv: extraEnv.env,
102
+ },
103
+ };
104
+ }
105
+ /** ローカルLLMタブを起動できる状態か(オンボーディング完了判定にも使う) */
106
+ export async function isLocalLlmReady() {
107
+ const { active } = await getLocalLlmSettings();
108
+ if (!active)
109
+ return false;
110
+ const provider = active.provider === 'ollama' ? await getOllamaSettings() : await getLmStudioSettings();
111
+ return provider.enabled;
112
+ }
113
+ /** 有効なプロバイダーのダウンロード済みモデル。取得に失敗したプロバイダーはエラーとして返す */
114
+ export async function listLocalLlmModels() {
115
+ const [ollama, lmstudio] = await Promise.all([getOllamaSettings(), getLmStudioSettings()]);
116
+ const models = [];
117
+ const errors = [];
118
+ const tasks = [];
119
+ if (ollama.enabled) {
120
+ tasks.push(listOllamaModels(ollama.baseUrl)
121
+ .then((list) => void models.push(...list))
122
+ .catch((e) => void errors.push({ provider: 'ollama', message: errorMessage(e) })));
123
+ }
124
+ if (lmstudio.enabled) {
125
+ tasks.push(listLmStudioModels(lmstudio)
126
+ .then((list) => void models.push(...list))
127
+ .catch((e) => void errors.push({ provider: 'lmstudio', message: errorMessage(e) })));
128
+ }
129
+ await Promise.all(tasks);
130
+ return { models, errors };
131
+ }
132
+ // ---- 読み込み・解放(同時に走らないよう直列化する) ----
133
+ let queue = Promise.resolve();
134
+ function serialize(task) {
135
+ const run = queue.catch(() => undefined).then(task);
136
+ queue = run;
137
+ return run;
138
+ }
139
+ let loading = false;
140
+ let lastError;
141
+ let inflight = 0;
142
+ let idleTimer = null;
143
+ let idleUnloadAt = null;
144
+ function errorMessage(error) {
145
+ return error instanceof Error ? error.message : String(error);
146
+ }
147
+ /** Ollama で使用中のモデルを表す名前(派生モデル)。keep は解放しない */
148
+ function ollamaKeepName(active) {
149
+ return active?.provider === 'ollama'
150
+ ? ollamaContextModelName(active.model, active.contextTokens)
151
+ : null;
152
+ }
153
+ /**
154
+ * keep 以外のモデルを、有効なすべてのプロバイダーで解放する(keep が null なら全部)。
155
+ * LM Studio の keep は、設定のコンテキスト長以上で読み込まれているインスタンスだけ残す。
156
+ */
157
+ async function unloadOthers(keep) {
158
+ const [ollama, lmstudio] = await Promise.all([getOllamaSettings(), getLmStudioSettings()]);
159
+ if (ollama.enabled) {
160
+ const status = await getOllamaStatus(ollama.baseUrl);
161
+ const keepName = ollamaKeepName(keep);
162
+ for (const loaded of status.loaded) {
163
+ if (loaded.name !== keepName)
164
+ await unloadOllamaModel(ollama.baseUrl, loaded.name);
165
+ }
166
+ }
167
+ if (lmstudio.enabled) {
168
+ const status = await getLmStudioStatus(lmstudio);
169
+ for (const loaded of status.loaded) {
170
+ const isKeep = keep?.provider === 'lmstudio' &&
171
+ loaded.key === keep.model &&
172
+ loaded.contextLength >= keep.contextTokens;
173
+ if (!isKeep)
174
+ await unloadLmStudioModel(lmstudio, loaded.instanceId);
175
+ }
176
+ }
177
+ }
178
+ /** 指定したプロバイダーのモデルを全部解放する(プロバイダーを無効化する前に使う) */
179
+ export function unloadProvider(provider) {
180
+ return serialize(async () => {
181
+ if (provider === 'ollama') {
182
+ const ollama = await getOllamaSettings();
183
+ const status = await getOllamaStatus(ollama.baseUrl);
184
+ for (const loaded of status.loaded)
185
+ await unloadOllamaModel(ollama.baseUrl, loaded.name);
186
+ }
187
+ else {
188
+ const lmstudio = await getLmStudioSettings();
189
+ const status = await getLmStudioStatus(lmstudio);
190
+ for (const loaded of status.loaded) {
191
+ await unloadLmStudioModel(lmstudio, loaded.instanceId);
192
+ }
193
+ }
194
+ });
195
+ }
196
+ /** 使用中のモデルがメモリにあるか(読み込み済みなら転送先を返す) */
197
+ async function findLoadedTarget(active) {
198
+ if (active.provider === 'ollama') {
199
+ const ollama = await getOllamaSettings();
200
+ const status = await getOllamaStatus(ollama.baseUrl);
201
+ return status.loaded.some((l) => l.name === ollamaKeepName(active));
202
+ }
203
+ const lmstudio = await getLmStudioSettings();
204
+ const status = await getLmStudioStatus(lmstudio);
205
+ return status.loaded.some((l) => l.key === active.model && l.contextLength >= active.contextTokens);
206
+ }
207
+ /**
208
+ * 使用中のモデルを読み込み(他のモデルは全部解放し)、中継口の転送先を返す。
209
+ * 読み込み済みなら何もしない。設定の不備はそのまま例外にする(タブにエラーとして表示される)。
210
+ */
211
+ export function ensureActiveLoaded() {
212
+ return serialize(async () => {
213
+ const { active } = await getLocalLlmSettings();
214
+ if (!active) {
215
+ throw new Error('ローカルLLMのモデルが未設定です。Settings で使用するモデルを選んでください');
216
+ }
217
+ loading = true;
218
+ try {
219
+ const target = await loadActive(active);
220
+ lastError = undefined;
221
+ return target;
222
+ }
223
+ catch (error) {
224
+ lastError = errorMessage(error);
225
+ throw error;
226
+ }
227
+ finally {
228
+ loading = false;
229
+ }
230
+ });
231
+ }
232
+ async function loadActive(active) {
233
+ if (active.provider === 'ollama') {
234
+ const ollama = await getOllamaSettings();
235
+ if (!ollama.enabled)
236
+ throw new Error('Ollama が無効です。Settings で有効にしてください');
237
+ const runModel = await ensureOllamaContextModel(ollama.baseUrl, active.model, active.contextTokens);
238
+ await unloadOthers(active);
239
+ if (!(await findLoadedTarget(active)))
240
+ await loadOllamaModel(ollama.baseUrl, runModel);
241
+ return { baseUrl: ollama.baseUrl, authToken: 'ollama', runModel };
242
+ }
243
+ const lmstudio = await getLmStudioSettings();
244
+ if (!lmstudio.enabled)
245
+ throw new Error('LM Studio が無効です。Settings で有効にしてください');
246
+ const status = await getLmStudioStatus(lmstudio);
247
+ if (!status.serverRunning) {
248
+ throw new Error('LM Studio のサーバーが起動していません。Settings(LM Studio)か LM Studio アプリの Developer タブから起動してください');
249
+ }
250
+ await unloadOthers(active);
251
+ if (!(await findLoadedTarget(active))) {
252
+ // MLX はモデルの最大値で読み込まれる(KV キャッシュが動的なため)
253
+ await loadLmStudioModel(lmstudio, active.model, active.contextTokens);
254
+ }
255
+ return {
256
+ baseUrl: lmstudio.baseUrl,
257
+ authToken: lmStudioAuthToken(lmstudio),
258
+ runModel: active.model,
259
+ };
260
+ }
261
+ /** すべてのモデルを解放する(Settings の解放ボタン・自動解放・tsunagi 終了時) */
262
+ export function unloadAll() {
263
+ // 解放済みになるので、予約済みの自動解放は不要になる
264
+ clearIdleTimer();
265
+ return serialize(async () => {
266
+ await unloadOthers(null);
267
+ });
268
+ }
269
+ // ---- 中継口のリクエスト数と自動解放 ----
270
+ function clearIdleTimer() {
271
+ if (idleTimer)
272
+ clearTimeout(idleTimer);
273
+ idleTimer = null;
274
+ idleUnloadAt = null;
275
+ }
276
+ async function scheduleIdleUnload() {
277
+ clearIdleTimer();
278
+ const { idleUnloadMinutes } = await getLocalLlmSettings();
279
+ // 待っている間に次のリクエストが始まっていれば予約しない
280
+ if (idleUnloadMinutes === 0 || inflight > 0)
281
+ return;
282
+ const ms = idleUnloadMinutes * 60 * 1000;
283
+ idleUnloadAt = new Date(Date.now() + ms);
284
+ idleTimer = setTimeout(() => {
285
+ idleTimer = null;
286
+ idleUnloadAt = null;
287
+ if (inflight > 0)
288
+ return;
289
+ unloadAll().catch((error) => {
290
+ lastError = `自動解放に失敗しました: ${errorMessage(error)}`;
291
+ });
292
+ }, ms);
293
+ }
294
+ /** 中継口がリクエストを受け付けたとき */
295
+ export function beginRequest() {
296
+ inflight += 1;
297
+ clearIdleTimer();
298
+ }
299
+ /** 中継口のリクエストが終わったとき(成功・失敗・中断のいずれも) */
300
+ export function endRequest() {
301
+ inflight = Math.max(0, inflight - 1);
302
+ if (inflight === 0)
303
+ void scheduleIdleUnload();
304
+ }
305
+ export function getInflight() {
306
+ return inflight;
307
+ }
308
+ export async function getLocalLlmStatus() {
309
+ const { active } = await getLocalLlmSettings();
310
+ const base = {
311
+ active,
312
+ inflight,
313
+ idleUnloadAt: idleUnloadAt ? idleUnloadAt.toISOString() : null,
314
+ };
315
+ if (!active)
316
+ return { ...base, state: 'unconfigured' };
317
+ if (loading)
318
+ return { ...base, state: 'loading' };
319
+ try {
320
+ if (await findLoadedTarget(active))
321
+ return { ...base, state: 'loaded' };
322
+ }
323
+ catch (error) {
324
+ return { ...base, state: 'error', error: errorMessage(error) };
325
+ }
326
+ return lastError ? { ...base, state: 'error', error: lastError } : { ...base, state: 'unloaded' };
327
+ }
328
+ /**
329
+ * 使用中のモデルを切り替える。応答生成中のタブがあれば切り替えない(生成中のモデルを外すと壊れるため)。
330
+ * 読み込みは待たずに始める(状態は getLocalLlmStatus で確認する)。
331
+ */
332
+ export async function activate(next) {
333
+ const current = await getLocalLlmSettings();
334
+ const changed = JSON.stringify(current.active) !== JSON.stringify(next.active);
335
+ if (changed && inflight > 0) {
336
+ return {
337
+ ok: false,
338
+ error: '応答を生成中のタブがあるため、モデルを切り替えられません。完了してから切り替えてください',
339
+ };
340
+ }
341
+ const settings = await saveLocalLlmSettings(next);
342
+ if (changed) {
343
+ lastError = undefined;
344
+ if (settings.active) {
345
+ ensureActiveLoaded().catch(() => undefined);
346
+ }
347
+ else {
348
+ unloadAll().catch(() => undefined);
349
+ }
350
+ }
351
+ // 自動解放の時間が変わった場合に予約し直す
352
+ if (inflight === 0 && idleTimer)
353
+ void scheduleIdleUnload();
354
+ return { ok: true, settings };
355
+ }
356
+ /** tsunagi 終了時(設定で有効なら)にモデルを解放する。終了を妨げないよう上限時間を設ける */
357
+ export async function unloadOnShutdown(timeoutMs = 10000) {
358
+ clearIdleTimer();
359
+ const { unloadOnExit } = await getLocalLlmSettings().catch(() => defaultLocalLlmSettings());
360
+ if (!unloadOnExit)
361
+ return;
362
+ await Promise.race([
363
+ unloadAll().catch(() => undefined),
364
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
365
+ ]);
366
+ }
@@ -1,6 +1,5 @@
1
- import type { OllamaAccountStatus, OllamaSettings } from '@minimalcorp/tsunagi-shared';
2
- /** Ollama タブでは Anthropic の認証情報を環境変数ごと取り除く(ANTHROPIC_AUTH_TOKEN と競合するため) */
3
- export declare const OLLAMA_UNSET_ENV_KEYS: string[];
1
+ import type { LocalLlmModel, OllamaAccountStatus, OllamaSettings, OllamaStatus } from '@minimalcorp/tsunagi-shared';
2
+ export declare const OLLAMA_SETTING_KEY = "ollama";
4
3
  export declare function defaultOllamaSettings(): OllamaSettings;
5
4
  export declare function getOllamaSettings(): Promise<OllamaSettings>;
6
5
  export declare function saveOllamaSettings(settings: OllamaSettings): Promise<OllamaSettings>;
@@ -10,18 +9,25 @@ export declare function parseOllamaSettings(body: unknown): {
10
9
  } | {
11
10
  error: string;
12
11
  };
13
- /** Ollama タブを起動できる状態か(オンボーディング完了判定にも使う) */
14
- export declare function isOllamaReady(settings: OllamaSettings): boolean;
15
12
  /**
16
- * Ollama の Anthropic 互換 API で Claude Code を動かすための環境変数。
17
- * DB の環境変数より後に適用し、同名キーを上書きする。
13
+ * コンテキスト長を固定した派生モデルの名前(例: qwen3.6:35b-a3b → qwen3.6:35b-a3b-tsunagi-ctx65536)。
14
+ * Anthropic 互換 API ではリクエスト毎に num_ctx を渡せず、Ollama 全体の既定値(VRAM 24GiB 未満は 4k)
15
+ * が使われるため、num_ctx を焼き込んだモデルを作ってそれを使う。
18
16
  */
19
- export declare function buildOllamaEnv(settings: OllamaSettings): Record<string, string>;
17
+ export declare function ollamaContextModelName(model: string, contextTokens: number): string;
18
+ /** 派生モデルがなければ作る(重みは元モデルの blob を共有するため数秒で終わる)。作ったモデル名を返す */
19
+ export declare function ensureOllamaContextModel(baseUrl: string, model: string, contextTokens: number): Promise<string>;
20
+ /** モデルをメモリに読み込む(空のプロンプトで generate すると読み込みだけ行われる) */
21
+ export declare function loadOllamaModel(baseUrl: string, name: string): Promise<void>;
22
+ /** モデルをメモリから外す */
23
+ export declare function unloadOllamaModel(baseUrl: string, name: string): Promise<void>;
20
24
  /**
21
25
  * Ollama の ollama.com サインイン状態を取得する。
22
26
  * Claude Code の WebSearch(web_search サーバーツール)は Ollama が ollama.com の Web 検索 API で
23
27
  * 代行するが、未サインインだと 401 になり `web_search_tool_result_error: unavailable` が返る。
24
28
  */
25
29
  export declare function getOllamaAccount(baseUrl: string): Promise<OllamaAccountStatus>;
26
- /** Ollama に pull 済みのモデル名一覧を取得する */
27
- export declare function listOllamaModels(baseUrl: string): Promise<string[]>;
30
+ /** Ollama に pull 済みのモデル一覧を取得する(tsunagi が作った派生モデルは除く) */
31
+ export declare function listOllamaModels(baseUrl: string): Promise<LocalLlmModel[]>;
32
+ /** Ollama サーバーの接続可否・バージョン・読み込み中のモデル */
33
+ export declare function getOllamaStatus(baseUrl: string): Promise<OllamaStatus>;