@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,192 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { activate, beginRequest, endRequest, ensureActiveLoaded, getLocalLlmSettings, getLocalLlmStatus, listLocalLlmModels, parseLocalLlmSettings, unloadAll, } from '../lib/local-llm.js';
4
+ import { LOCAL_MODEL_ALIAS, parseContextTokens } from '../lib/local-llm-env.js';
5
+ import { estimateLmStudioLoad, getLmStudioSettings } from '../lib/lmstudio.js';
6
+ // Claude Code のリクエストは会話が長いと数 MB になる(画像を含む場合はさらに大きい)
7
+ const PROXY_BODY_LIMIT = 200 * 1024 * 1024;
8
+ // 転送先に渡すリクエストヘッダー(認証は転送先に合わせて付け直す)
9
+ const FORWARD_REQUEST_HEADERS = ['content-type', 'accept', 'anthropic-version', 'anthropic-beta'];
10
+ // クライアントに返さないレスポンスヘッダー(Node が付け直す/中継で意味が変わる)
11
+ const HOP_BY_HOP_HEADERS = new Set([
12
+ 'connection',
13
+ 'keep-alive',
14
+ 'transfer-encoding',
15
+ 'content-length',
16
+ ]);
17
+ /** Anthropic API 形式のエラー。4xx は Claude Code が再送せずそのまま表示する */
18
+ function sendApiError(reply, status, message) {
19
+ return reply.status(status).send({
20
+ type: 'error',
21
+ error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message },
22
+ });
23
+ }
24
+ function errorMessage(error) {
25
+ return error instanceof Error ? error.message : String(error);
26
+ }
27
+ /**
28
+ * ローカルLLMタブの Claude Code からのリクエストを、使用中のモデルに書き換えて転送する。
29
+ * 使用中のモデルがメモリになければ(他のモデルを解放してから)読み込む。
30
+ * ローカルLLMはプロンプトの読み込みに数分かかるため、転送にタイムアウトは設けない。
31
+ */
32
+ async function proxy(request, reply) {
33
+ const path = request.params['*'];
34
+ // モデル一覧は使用中のモデルだけを返す(Claude Code のモデル自動取得用。既定では使われない)
35
+ if (request.method === 'GET' && path === 'v1/models') {
36
+ return reply.send({
37
+ data: [{ id: LOCAL_MODEL_ALIAS, type: 'model', display_name: 'ローカルLLM' }],
38
+ has_more: false,
39
+ });
40
+ }
41
+ beginRequest();
42
+ let finished = false;
43
+ const finish = () => {
44
+ if (finished)
45
+ return;
46
+ finished = true;
47
+ endRequest();
48
+ };
49
+ let target;
50
+ try {
51
+ target = await ensureActiveLoaded();
52
+ }
53
+ catch (error) {
54
+ finish();
55
+ return sendApiError(reply, 400, errorMessage(error));
56
+ }
57
+ let body;
58
+ if (request.body !== undefined && request.body !== null) {
59
+ const json = request.body;
60
+ if (typeof json === 'object' && 'model' in json)
61
+ json.model = target.runModel;
62
+ body = Buffer.from(JSON.stringify(json));
63
+ }
64
+ const query = request.raw.url?.includes('?')
65
+ ? request.raw.url.slice(request.raw.url.indexOf('?'))
66
+ : '';
67
+ const url = new URL(`${target.baseUrl}/${path}${query}`);
68
+ const headers = {
69
+ 'x-api-key': target.authToken,
70
+ authorization: `Bearer ${target.authToken}`,
71
+ };
72
+ for (const name of FORWARD_REQUEST_HEADERS) {
73
+ const value = request.headers[name];
74
+ if (typeof value === 'string')
75
+ headers[name] = value;
76
+ }
77
+ if (body)
78
+ headers['content-length'] = String(body.length);
79
+ await reply.hijack();
80
+ const upstream = (url.protocol === 'https:' ? https : http).request(url, {
81
+ method: request.method,
82
+ headers,
83
+ });
84
+ // クライアント(Claude Code)が中断したら転送先も止める(ESC で生成を止めたときにモデルを空ける)
85
+ reply.raw.on('close', () => {
86
+ if (!reply.raw.writableFinished)
87
+ upstream.destroy();
88
+ finish();
89
+ });
90
+ upstream.on('response', (res) => {
91
+ const responseHeaders = {};
92
+ for (const [name, value] of Object.entries(res.headers)) {
93
+ if (value !== undefined && !HOP_BY_HOP_HEADERS.has(name))
94
+ responseHeaders[name] = value;
95
+ }
96
+ reply.raw.writeHead(res.statusCode ?? 502, responseHeaders);
97
+ res.pipe(reply.raw);
98
+ res.on('end', finish);
99
+ res.on('error', finish);
100
+ });
101
+ upstream.on('error', (error) => {
102
+ finish();
103
+ if (!reply.raw.headersSent) {
104
+ reply.raw.writeHead(502, { 'content-type': 'application/json' });
105
+ reply.raw.end(JSON.stringify({
106
+ type: 'error',
107
+ error: {
108
+ type: 'api_error',
109
+ message: `ローカルLLM(${target.baseUrl})に接続できません: ${error.message}`,
110
+ },
111
+ }));
112
+ }
113
+ else {
114
+ reply.raw.destroy(error);
115
+ }
116
+ });
117
+ upstream.end(body);
118
+ }
119
+ export async function localLlmRoutes(fastify) {
120
+ // ---- 中継口(ローカルLLMタブの ANTHROPIC_BASE_URL) ----
121
+ fastify.route({
122
+ method: ['GET', 'POST'],
123
+ url: '/local-llm/proxy/*',
124
+ bodyLimit: PROXY_BODY_LIMIT,
125
+ handler: proxy,
126
+ });
127
+ // ---- PreModelSwitch フック(ローカルLLMタブの --settings で登録) ----
128
+ // モデルは Settings でだけ切り替えるため、/model による切り替えは常に拒否する。
129
+ // Claude Code は /model の Enter や `/model 名前` の結果を ~/.claude/settings.json の model に保存し、
130
+ // 通常の Claude タブの既定モデルまで変えてしまう。opus / sonnet 等の別名も中継口の名前に割り当てて
131
+ // いるため「同じモデル」への切り替えに見えるが、保存はされるので区別せずに拒否する
132
+ fastify.post('/hooks/local-llm/pre-model-switch', async (_request, reply) => {
133
+ return reply.send({
134
+ hookSpecificOutput: {
135
+ hookEventName: 'PreModelSwitch',
136
+ permissionDecision: 'deny',
137
+ permissionDecisionReason: 'ローカルLLMのタブでは /model でモデルを切り替えられません。tsunagi の Settings(Claude Code のローカルLLM)で切り替えてください(全タブに反映されます)',
138
+ },
139
+ });
140
+ });
141
+ // ---- Settings ----
142
+ fastify.get('/settings/local-llm', async (_request, reply) => {
143
+ return reply.send({ data: await getLocalLlmSettings() });
144
+ });
145
+ // 使用中のモデルが変わった場合は、他のモデルを解放して読み込みを始める(完了は待たない)
146
+ fastify.put('/settings/local-llm', async (request, reply) => {
147
+ const parsed = parseLocalLlmSettings(request.body);
148
+ if ('error' in parsed)
149
+ return reply.status(400).send({ error: parsed.error });
150
+ const result = await activate(parsed.settings);
151
+ if (!result.ok)
152
+ return reply.status(409).send({ error: result.error });
153
+ return reply.send({ data: result.settings });
154
+ });
155
+ fastify.get('/settings/local-llm/status', async (_request, reply) => {
156
+ return reply.send({ data: await getLocalLlmStatus() });
157
+ });
158
+ fastify.get('/settings/local-llm/models', async (_request, reply) => {
159
+ return reply.send({ data: await listLocalLlmModels() });
160
+ });
161
+ // 使用中のモデルを今すぐ読み込む(完了は待たない)
162
+ fastify.post('/settings/local-llm/load', async (_request, reply) => {
163
+ ensureActiveLoaded().catch(() => undefined);
164
+ return reply.status(202).send({ data: await getLocalLlmStatus() });
165
+ });
166
+ fastify.post('/settings/local-llm/unload', async (_request, reply) => {
167
+ try {
168
+ await unloadAll();
169
+ }
170
+ catch (error) {
171
+ return reply.status(502).send({ error: `モデルを解放できません: ${errorMessage(error)}` });
172
+ }
173
+ return reply.send({ data: await getLocalLlmStatus() });
174
+ });
175
+ // GET /settings/local-llm/estimate?model=&contextTokens= - LM Studio の必要メモリの見積もり
176
+ fastify.get('/settings/local-llm/estimate', async (request, reply) => {
177
+ const { model } = request.query;
178
+ const contextTokens = parseContextTokens(request.query.contextTokens);
179
+ if (!model || !contextTokens) {
180
+ return reply.status(400).send({ error: 'model and contextTokens are required' });
181
+ }
182
+ try {
183
+ const data = await estimateLmStudioLoad(await getLmStudioSettings(), model, contextTokens);
184
+ return reply.send({ data });
185
+ }
186
+ catch (error) {
187
+ return reply
188
+ .status(502)
189
+ .send({ error: `メモリの見積もりを取得できません: ${errorMessage(error)}` });
190
+ }
191
+ });
192
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function mcpWebRoutes(fastify: FastifyInstance): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { searchWeb } from '../lib/searxng.js';
6
+ /**
7
+ * ローカルLLMタブ専用の Web 検索 MCP。組み込み WebSearch(Anthropic のサーバーツール)が
8
+ * 使えないため、SearXNG で検索する web_search を1つだけ提供する。
9
+ * ローカルLLMはツール数が増えるほど呼び出し精度が落ちるため、ツールは増やさない。
10
+ */
11
+ const DEFAULT_MAX_RESULTS = 8;
12
+ const MAX_RESULTS_LIMIT = 20;
13
+ /** セッションIDをキーにしたtransportのMap */
14
+ const transports = new Map();
15
+ function createWebMcpServer() {
16
+ const server = new Server({ name: 'tsunagi-web', version: '1.0.0' }, { capabilities: { tools: {} } });
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
18
+ tools: [
19
+ {
20
+ name: 'web_search',
21
+ description: 'Search the web and return result titles, URLs and snippets. ' +
22
+ 'Use WebFetch on a result URL to read the full page.',
23
+ inputSchema: {
24
+ type: 'object',
25
+ required: ['query'],
26
+ properties: {
27
+ query: { type: 'string', description: 'Search query' },
28
+ max_results: {
29
+ type: 'number',
30
+ description: `Number of results (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`,
31
+ },
32
+ },
33
+ additionalProperties: false,
34
+ },
35
+ },
36
+ ],
37
+ }));
38
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
39
+ const { name, arguments: args } = request.params;
40
+ if (name !== 'web_search') {
41
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
42
+ }
43
+ const query = typeof args?.query === 'string' ? args.query.trim() : '';
44
+ if (!query) {
45
+ return { content: [{ type: 'text', text: '`query` is required' }], isError: true };
46
+ }
47
+ const requested = Number(args?.max_results);
48
+ const maxResults = Number.isInteger(requested) && requested > 0
49
+ ? Math.min(requested, MAX_RESULTS_LIMIT)
50
+ : DEFAULT_MAX_RESULTS;
51
+ try {
52
+ const results = await searchWeb(query, maxResults);
53
+ const text = results.length === 0
54
+ ? `No results for "${query}".`
55
+ : results
56
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`.trimEnd())
57
+ .join('\n\n');
58
+ return { content: [{ type: 'text', text }] };
59
+ }
60
+ catch (error) {
61
+ return {
62
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
63
+ isError: true,
64
+ };
65
+ }
66
+ });
67
+ return server;
68
+ }
69
+ export async function mcpWebRoutes(fastify) {
70
+ // POST /mcp/web - セッション初期化またはメッセージ処理
71
+ fastify.post('/mcp/web', async (request, reply) => {
72
+ const sessionId = request.headers['mcp-session-id'];
73
+ if (sessionId) {
74
+ const transport = transports.get(sessionId);
75
+ if (!transport) {
76
+ return reply.status(404).send({ error: `Session not found: ${sessionId}` });
77
+ }
78
+ await reply.hijack();
79
+ await transport.handleRequest(request.raw, reply.raw, request.body);
80
+ return;
81
+ }
82
+ const transport = new StreamableHTTPServerTransport({
83
+ sessionIdGenerator: () => randomUUID(),
84
+ onsessioninitialized: (id) => {
85
+ transports.set(id, transport);
86
+ },
87
+ onsessionclosed: (id) => {
88
+ transports.delete(id);
89
+ },
90
+ });
91
+ await createWebMcpServer().connect(transport);
92
+ await reply.hijack();
93
+ await transport.handleRequest(request.raw, reply.raw, request.body);
94
+ });
95
+ // GET / DELETE /mcp/web - SSEストリーム / セッション終了(既存セッション用)
96
+ const handleExisting = async (request, reply) => {
97
+ const sessionId = request.headers['mcp-session-id'];
98
+ const transport = sessionId ? transports.get(sessionId) : undefined;
99
+ if (!transport) {
100
+ return reply.status(400).send({ error: 'Invalid or missing mcp-session-id header' });
101
+ }
102
+ await reply.hijack();
103
+ await transport.handleRequest(request.raw, reply.raw);
104
+ };
105
+ fastify.get('/mcp/web', handleExisting);
106
+ fastify.delete('/mcp/web', handleExisting);
107
+ }
@@ -1,15 +1,15 @@
1
1
  import { getEnv } from '../lib/repositories/environment.js';
2
- import { getOllamaSettings, isOllamaReady } from '../lib/ollama-settings.js';
2
+ import { isLocalLlmReady } from '../lib/local-llm.js';
3
3
  export async function onboardingRoutes(fastify) {
4
4
  // GET /onboarding/status
5
5
  fastify.get('/onboarding/status', async (_request, reply) => {
6
6
  try {
7
7
  const globalEnv = await getEnv('global');
8
8
  const hasGlobalToken = Boolean(globalEnv.ANTHROPIC_API_KEY || globalEnv.CLAUDE_CODE_OAUTH_TOKEN);
9
- // 実験的機能の Ollama を設定済みなら、Anthropic のトークンがなくても claude を起動できる
10
- const ollamaReady = isOllamaReady(await getOllamaSettings());
9
+ // 実験的機能のローカルLLM(Ollama / LM Studio)を設定済みなら、Anthropic のトークンがなくても claude を起動できる
10
+ const localLlmReady = await isLocalLlmReady();
11
11
  return reply.status(200).send({
12
- data: { completed: hasGlobalToken || ollamaReady, hasGlobalToken, ollamaReady },
12
+ data: { completed: hasGlobalToken || localLlmReady, hasGlobalToken, localLlmReady },
13
13
  });
14
14
  }
15
15
  catch (error) {
@@ -1,11 +1,26 @@
1
- import { getOllamaAccount, getOllamaSettings, listOllamaModels, parseOllamaSettings, saveOllamaSettings, } from '../lib/ollama-settings.js';
1
+ import { getOllamaAccount, getOllamaSettings, getOllamaStatus, parseOllamaSettings, saveOllamaSettings, } from '../lib/ollama-settings.js';
2
+ import { getLmStudioSettings, getLmStudioStatus, parseLmStudioSettings, saveLmStudioSettings, startLmStudioServer, stopLmStudioServer, } from '../lib/lmstudio.js';
3
+ import { unloadProvider } from '../lib/local-llm.js';
4
+ import { getSearxngSettings, getSearxngStatus, parseSearxngSettings, restartSearxng, saveSearxngSettings, stopSearxng, syncSearxng, } from '../lib/searxng.js';
5
+ import { normalizeBaseUrl } from '../lib/local-llm-env.js';
2
6
  /** クエリの baseUrl(未指定なら保存済みの値)を正規化する。不正なら null */
3
- async function resolveBaseUrl(baseUrl) {
4
- const url = (baseUrl || (await getOllamaSettings()).baseUrl).replace(/\/+$/, '');
5
- return /^https?:\/\//.test(url) ? url : null;
7
+ async function resolveOllamaBaseUrl(baseUrl) {
8
+ return normalizeBaseUrl(baseUrl || (await getOllamaSettings()).baseUrl);
9
+ }
10
+ function errorMessage(error) {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+ /** 失敗時は 502 でメッセージを返す(外部プロセス・外部サーバーの操作用) */
14
+ async function sendResult(reply, run, prefix) {
15
+ try {
16
+ return reply.status(200).send({ data: await run() });
17
+ }
18
+ catch (error) {
19
+ return reply.status(502).send({ error: `${prefix}: ${errorMessage(error)}` });
20
+ }
6
21
  }
7
22
  export async function settingsRoutes(fastify) {
8
- // GET /settings/ollama
23
+ // ---- Ollama ----
9
24
  fastify.get('/settings/ollama', async (_request, reply) => {
10
25
  try {
11
26
  return reply.status(200).send({ data: await getOllamaSettings() });
@@ -15,51 +30,110 @@ export async function settingsRoutes(fastify) {
15
30
  return reply.status(500).send({ error: 'Failed to fetch ollama settings' });
16
31
  }
17
32
  });
18
- // PUT /settings/ollama
19
33
  fastify.put('/settings/ollama', async (request, reply) => {
20
34
  const parsed = parseOllamaSettings(request.body);
21
35
  if ('error' in parsed) {
22
36
  return reply.status(400).send({ error: parsed.error });
23
37
  }
24
38
  try {
25
- return reply.status(200).send({ data: await saveOllamaSettings(parsed.settings) });
39
+ // 無効化するプロバイダーに読み込まれたモデルは、以後 tsunagi が管理しないため先に解放する
40
+ if (!parsed.settings.enabled && (await getOllamaSettings()).enabled) {
41
+ await unloadProvider('ollama').catch(() => undefined);
42
+ }
43
+ const saved = await saveOllamaSettings(parsed.settings);
44
+ // 有効/無効に合わせて SearXNG を起動・停止する(完了は待たない)
45
+ void syncSearxng();
46
+ return reply.status(200).send({ data: saved });
26
47
  }
27
48
  catch (error) {
28
49
  fastify.log.error(error, 'PUT /settings/ollama error');
29
50
  return reply.status(500).send({ error: 'Failed to save ollama settings' });
30
51
  }
31
52
  });
32
- // GET /settings/ollama/models - Ollama に pull 済みのモデル一覧
33
- // ブラウザから直接叩くと CORS / Docker 内外のホスト名差異が問題になるためサーバー経由にする
34
- fastify.get('/settings/ollama/models', async (request, reply) => {
35
- const baseUrl = await resolveBaseUrl(request.query.baseUrl);
53
+ // GET /settings/ollama/status - 接続可否・バージョン・読み込み中のモデル
54
+ fastify.get('/settings/ollama/status', async (request, reply) => {
55
+ const baseUrl = await resolveOllamaBaseUrl(request.query.baseUrl);
36
56
  if (!baseUrl) {
37
57
  return reply.status(400).send({ error: 'baseUrl must be a valid http(s) URL' });
38
58
  }
39
- try {
40
- return reply.status(200).send({ data: { models: await listOllamaModels(baseUrl) } });
41
- }
42
- catch (error) {
43
- const message = error instanceof Error ? error.message : String(error);
44
- return reply
45
- .status(502)
46
- .send({ error: `Ollama に接続できません (${baseUrl}): ${message}` });
47
- }
59
+ return reply.status(200).send({ data: await getOllamaStatus(baseUrl) });
48
60
  });
49
61
  // GET /settings/ollama/account - ollama.com のサインイン状態(WebSearch の利用可否)
50
62
  fastify.get('/settings/ollama/account', async (request, reply) => {
51
- const baseUrl = await resolveBaseUrl(request.query.baseUrl);
63
+ const baseUrl = await resolveOllamaBaseUrl(request.query.baseUrl);
52
64
  if (!baseUrl) {
53
65
  return reply.status(400).send({ error: 'baseUrl must be a valid http(s) URL' });
54
66
  }
67
+ return sendResult(reply, () => getOllamaAccount(baseUrl), `Ollama のサインイン状態を取得できません (${baseUrl})`);
68
+ });
69
+ // ---- LM Studio ----
70
+ fastify.get('/settings/lmstudio', async (_request, reply) => {
71
+ try {
72
+ return reply.status(200).send({ data: await getLmStudioSettings() });
73
+ }
74
+ catch (error) {
75
+ fastify.log.error(error, 'GET /settings/lmstudio error');
76
+ return reply.status(500).send({ error: 'Failed to fetch LM Studio settings' });
77
+ }
78
+ });
79
+ fastify.put('/settings/lmstudio', async (request, reply) => {
80
+ const parsed = parseLmStudioSettings(request.body);
81
+ if ('error' in parsed) {
82
+ return reply.status(400).send({ error: parsed.error });
83
+ }
55
84
  try {
56
- return reply.status(200).send({ data: await getOllamaAccount(baseUrl) });
85
+ if (!parsed.settings.enabled && (await getLmStudioSettings()).enabled) {
86
+ await unloadProvider('lmstudio').catch(() => undefined);
87
+ }
88
+ const saved = await saveLmStudioSettings(parsed.settings);
89
+ void syncSearxng();
90
+ return reply.status(200).send({ data: saved });
57
91
  }
58
92
  catch (error) {
59
- const message = error instanceof Error ? error.message : String(error);
60
- return reply
61
- .status(502)
62
- .send({ error: `Ollama のサインイン状態を取得できません (${baseUrl}): ${message}` });
93
+ fastify.log.error(error, 'PUT /settings/lmstudio error');
94
+ return reply.status(500).send({ error: 'Failed to save LM Studio settings' });
63
95
  }
64
96
  });
97
+ // 以下は保存済みの設定(Base URL・トークン・lms のパス)で LM Studio を操作する。
98
+ // モデルの読み込み・解放は /settings/local-llm で行う(常に1つだけ載せるため)
99
+ fastify.get('/settings/lmstudio/status', async (_request, reply) => {
100
+ return reply.status(200).send({ data: await getLmStudioStatus(await getLmStudioSettings()) });
101
+ });
102
+ fastify.post('/settings/lmstudio/server/start', async (_request, reply) => {
103
+ return sendResult(reply, async () => {
104
+ const settings = await getLmStudioSettings();
105
+ await startLmStudioServer(settings);
106
+ return getLmStudioStatus(settings);
107
+ }, 'LM Studio のサーバーを起動できません');
108
+ });
109
+ fastify.post('/settings/lmstudio/server/stop', async (_request, reply) => {
110
+ return sendResult(reply, async () => {
111
+ const settings = await getLmStudioSettings();
112
+ await stopLmStudioServer(settings);
113
+ return getLmStudioStatus(settings);
114
+ }, 'LM Studio のサーバーを停止できません');
115
+ });
116
+ // ---- SearXNG(ローカル検索) ----
117
+ fastify.get('/settings/searxng', async (_request, reply) => {
118
+ return reply.status(200).send({ data: await getSearxngSettings() });
119
+ });
120
+ fastify.put('/settings/searxng', async (request, reply) => {
121
+ const parsed = parseSearxngSettings(request.body);
122
+ if ('error' in parsed) {
123
+ return reply.status(400).send({ error: parsed.error });
124
+ }
125
+ return reply.status(200).send({ data: await saveSearxngSettings(parsed.settings) });
126
+ });
127
+ fastify.get('/settings/searxng/status', async (_request, reply) => {
128
+ return reply.status(200).send({ data: await getSearxngStatus() });
129
+ });
130
+ // 起動完了(最大60秒)を待たずに返す。状態は status をポーリングして確認する
131
+ fastify.post('/settings/searxng/restart', async (_request, reply) => {
132
+ void restartSearxng();
133
+ return reply.status(202).send({ data: await getSearxngStatus() });
134
+ });
135
+ fastify.post('/settings/searxng/stop', async (_request, reply) => {
136
+ await stopSearxng();
137
+ return reply.status(200).send({ data: await getSearxngStatus() });
138
+ });
65
139
  }
@@ -329,7 +329,11 @@ export async function tasksRoutes(fastify) {
329
329
  try {
330
330
  const { id: taskId } = request.params;
331
331
  const requested = request.body?.mode;
332
- const mode = requested === 'terminal' || requested === 'ollama' ? requested : 'claude';
332
+ const mode = requested === 'terminal'
333
+ ? 'terminal'
334
+ : requested === 'local' || requested === 'ollama' || requested === 'lmstudio'
335
+ ? 'local'
336
+ : 'claude';
333
337
  const task = await taskRepo.getTask(taskId);
334
338
  if (!task) {
335
339
  return reply.status(404).send({ error: 'Task not found' });